mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-15 12:54:58 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c3c9109a0 | ||
|
|
4cc3b69cbe | ||
|
|
543dc27244 |
@@ -418,6 +418,7 @@
|
||||
"packages/app/src/components/context-window-meter.tsx",
|
||||
"packages/app/src/components/dictation-controls.tsx",
|
||||
"packages/app/src/components/download-toast.tsx",
|
||||
"packages/app/src/components/draggable-list.native.tsx",
|
||||
"packages/app/src/components/headers/back-header.tsx",
|
||||
"packages/app/src/components/headers/screen-header.tsx",
|
||||
"packages/app/src/components/host-status-dot.tsx",
|
||||
|
||||
+5
-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 flashes on native the moment a finger lands. A bare `<Text>` styled to look like a section header is wrong; the section header is `<SettingsSection>` (`packages/app/src/components/settings/headings/settings-section.tsx`). A docs link in its header is its `link` prop, which renders `Label ↗` on the title's rail; a ghost button with an arrow is wrong. A custom `Modal` for a confirmation is wrong; the confirmation is `confirmDialog` (`packages/app/src/utils/confirm-dialog.ts`). A hand-rolled overflow menu is wrong; the menu is `<DropdownMenu>` (`packages/app/src/components/ui/dropdown-menu.tsx`). A hand-rolled status pill is wrong; the pill is `<StatusBadge>` (`packages/app/src/components/ui/status-badge.tsx`). A circle pinned over a scrolling surface is wrong; the floating action is `<FloatingActionButton>` (`packages/app/src/components/ui/floating-action-button.tsx`), one per surface, and the surface reserves `FLOATING_ACTION_BUTTON_CLEARANCE` below its content so the button never traps the last row.
|
||||
|
||||
Before adding a new component, read `components/ui/`. The primitive usually exists.
|
||||
|
||||
@@ -40,11 +40,9 @@ Weight has three tiers, applied by role:
|
||||
|
||||
The rule, condensed: text that _names_ a surface or a group is `medium`. Text that lives _inside_ a surface or a group is `normal`. Top-of-screen titles are `<ScreenTitle>`, which is lighter still.
|
||||
|
||||
Foreground is for the thing being acted on: row titles, section headings, the selected sidebar item. `foregroundMuted` is for context: hints, descriptions, secondary metadata, idle sidebar items, placeholders, status text. The composer's empty placeholder is quieter; it uses `foregroundExtraMuted`.
|
||||
Foreground is for the thing being acted on: row titles, section headings, the selected sidebar item. `foregroundMuted` is for context: hints, descriptions, secondary metadata, idle sidebar items, placeholders, status text.
|
||||
|
||||
`foregroundExtraMuted` is reserved for passive chrome that must sit behind muted text, such as an always-visible window control, and for the composer's empty placeholder. Use the solid token instead of lowering SVG opacity; per-path opacity makes overlapping icon strokes render unevenly. Interactive hover and pressed states return to `foreground`.
|
||||
|
||||
A theme states places and identity; `packages/app/src/styles/derive-colors.ts` derives their relationships. Sidebar hover and selected fills step from the sidebar toward the foreground. For plugin themes, passive text on those states derives against the actual state surface; page tertiary text is a separate relationship. Explicit plugin semantic overrides take precedence over derived defaults. Built-in themes retain their chosen passive text. `packages/app/src/styles/theme-contrast.test.ts` checks the built-in and representative plugin palettes against contrast floors.
|
||||
`foregroundExtraMuted` is reserved for passive chrome that must sit behind muted text, such as an always-visible window control. Use the solid token instead of lowering SVG opacity; per-path opacity makes overlapping icon strokes render unevenly. Interactive hover and pressed states return to `foreground`.
|
||||
|
||||
Accent is the one CTA per surface. A `<Button variant="default">` filled with `accent` appears at most once on a page. Most pages have zero — settings is mostly toggles and text, the workspace pane is mostly content, the chat composer is the input itself.
|
||||
|
||||
@@ -72,7 +70,7 @@ the sidebar. Apply it as `backgroundColor`; setting `opacity` on the control als
|
||||
|
||||
Controls come in two sizes, defined once in `control-geometry.ts`. The default is the right size almost everywhere — pages, dialogs, forms, settings rows, the setup flow — so don't pass a size. It is 32px tall where a pointer drives it and 44px where controls are touch-sized, so every touch control meets the iOS HIG's 44pt hit region. `sm` is 28px pointer and 32px touch, with `fontSize.sm` labels, and is for tight chrome only: toolbars, nested settings rows, dense inline row actions. A `size="sm"` on an ordinary page or dialog is wrong, and so is choosing a size from `useIsCompactFormFactor()`.
|
||||
|
||||
Dense toolbar rows, such as the composer's, are the exception to the 44pt control. Touch-sized the row is 44pt tall and each control is a 36pt circle (`TOOLBAR_DISC_SIZE`): send and stop fill it, and every other control's pressed highlight fills it, so every highlight is the same fully rounded shape. Labelled pickers are 36pt pills. A control lays out at its full highlight (`toolbarSlot`, `toolbarContentSlot`) with no gap between controls (`TOOLBAR_CONTROL_GAP`), so neighbouring highlights touch and never overlap. Touch-sized toolbar glyphs are 20pt at the app's 1.5px stroke. An icon-only control's glyph matches the header icons and rests at `foreground`; a glyph beside a label rests at `foregroundMuted` so the two read as one quiet unit (`toolbarGlyphsFor`). The context ring is 18pt with a 2px stroke (`TOOLBAR_RINGS`): a circle fills its box where an outline glyph does not, and a thin circle reads lighter than a glyph at the same stroke. A disc's solid glyph, such as the stop square, stays at the `md` size (`TOOLBAR_DISC_GLYPH_SIZES`). Pointer-sized toolbars keep 28px controls, a 4px gap, and a 16pt muted glyph that brightens on hover.
|
||||
Dense toolbar rows, such as the composer's, are the exception to the 44pt control. Touch-sized the row is 44pt tall and each control is a 36pt circle (`TOOLBAR_DISC_SIZE`): send and stop fill it, and every other control's pressed highlight fills it, so every highlight is the same fully rounded shape. Labelled pickers are 36pt pills. A control lays out at its full highlight (`toolbarSlot`, `toolbarContentSlot`) with no gap between controls (`TOOLBAR_CONTROL_GAP`), so neighbouring highlights touch and never overlap. Toolbar glyphs match the header icons: 20pt, the app's 1.5px stroke, resting at `foreground` (`toolbarGlyphsFor`). The context ring is 18pt with a 2px stroke (`TOOLBAR_RINGS`): a circle fills its box where an outline glyph does not, and a thin circle reads lighter than a glyph at the same stroke. A disc's solid glyph, such as the stop square, stays at the `md` size (`TOOLBAR_DISC_GLYPH_SIZES`). Pointer-sized toolbars keep 28px controls, a 4px gap, and a 16pt muted glyph that brightens on hover.
|
||||
|
||||
In the composer, the first glyph's ink starts on the text column's edge, and the trailing control is always a disc, so the row does not shift as it changes between voice mode, send and stop. The disc nests into the corner, as far from the right edge as from the bottom, and touch-sized the composer's corner radius is concentric with it (`packages/app/src/composer/input/toolbar-rails.ts`). Touch-sized the disc keeps a 6pt clearance from its neighbour, because it is always painted and a pressed neighbour would merge with it. The attach button sits 2pt closer to the picker than its highlight would put it: the plus is all thin arms, so the full gap reads wide. That offset is set by eye.
|
||||
|
||||
@@ -269,7 +267,7 @@ New status pills use `<StatusBadge>`. Identity, shortcut, and interactive link b
|
||||
- A "Settings" CTA on a detail page. Detail pages are settings; settings is reached from the sidebar, the host entry, or a row's kebab menu.
|
||||
- The word "checkout" in UI strings or identifiers. The term is "workspace".
|
||||
- New color tokens or hardcoded hex outside the palette. The identity color table is the documented exception (§13), not a license.
|
||||
- Placeholder text dimmed beyond `foregroundMuted`, except the composer's empty placeholder, which uses `foregroundExtraMuted`. No extra opacity, no italics, no ghost-text.
|
||||
- Placeholder text dimmed beyond `foregroundMuted`. No extra opacity, no italics, no ghost-text.
|
||||
- `onPointerEnter` and `onPointerLeave`. They do not fire on native iOS. Hover uses Pressable's `onHoverIn`/`onHoverOut` gated with `isHovered || isCompact || isNative`.
|
||||
- Raw DOM APIs without an `isWeb` guard.
|
||||
- Spacing values outside the scale. `padding: 20` and `gap: 10` are wrong.
|
||||
|
||||
@@ -113,38 +113,6 @@ host that is either a known cloud host or one the CLI is already authenticated
|
||||
to. Adapter probes must not make anonymous HTTP requests to remote-derived
|
||||
hosts, and adapters must not route credentials to an unauthenticated host.
|
||||
|
||||
### Web links: `forgeWeb`
|
||||
|
||||
The daemon publishes `forgeWeb` on the workspace descriptor and on
|
||||
`checkout_status_response`. The app builds every forge URL from it instead of
|
||||
parsing `origin` itself.
|
||||
|
||||
- `host` is the browsable host: an `ssh -G` alias such as `github.com-work` and
|
||||
a cloud SSH host such as `ssh.github.com` resolve to the cloud host; self-hosted
|
||||
hosts pass through.
|
||||
- `referenceRepository` is the repository that owns issue and change-request
|
||||
numbers: the parent when `origin` is a fork. `#123` links there; `owner/repo#123`
|
||||
keeps the repository you wrote.
|
||||
- Branch and file links stay on the `origin` repository. A feature branch usually
|
||||
exists only on the fork.
|
||||
|
||||
An adapter opts into `referenceRepository` with the optional
|
||||
`ForgeService.getReferenceRepository`. GitHub reads `parent` from `gh repo view`,
|
||||
GitLab reads `forked_from_project` from `glab api`, and the Gitea family reads
|
||||
`parent` from `tea api`. The lookup always names the `origin` repository
|
||||
explicitly, so it does not depend on `gh`'s default-repository choice, and it
|
||||
rejects on any CLI failure. `packages/server/src/server/forge-web.ts` keeps a
|
||||
successful answer for as long as the remote stays the same. The first snapshot
|
||||
publishes `{ host }` immediately and the reference repository follows in a later
|
||||
update, so a slow or signed-out forge CLI never holds back the checkout's git
|
||||
state or pull request status. A failed lookup (signed out, network down) leaves
|
||||
links on `origin` and is retried by the next refresh or pull request status poll
|
||||
once a minute has passed.
|
||||
|
||||
Daemons older than v0.16.0 omit the field; the app then derives the host from the
|
||||
remote and links `#N` to `origin`. That fallback is tagged `COMPAT(forgeWeb)` in
|
||||
`packages/app/src/git/forge-url.ts`.
|
||||
|
||||
## App
|
||||
|
||||
Each app forge splits into two modules so pure logic never imports the client
|
||||
|
||||
@@ -61,10 +61,6 @@ The daemon journals the assigned agent ID before creation. Concurrent retries sh
|
||||
and a retry after a lost acknowledgement or restart returns the durable agent without creating a
|
||||
workspace or starting a turn. Deleting that agent does not make its old creation key reusable.
|
||||
Confirmed message delivery is also journaled, so retrying its message ID does not submit it again.
|
||||
A direct send (one without `sourceAgentId`) rejected before its prompt is handed to a run, for
|
||||
example because the agent could not be loaded or the requested mode could not be set, leaves no
|
||||
receipt, so retrying its message ID submits it. A failure during or after run startup is treated
|
||||
as an unfinished delivery.
|
||||
An unfinished delivery after restart reports `agent_request_outcome_unknown`: a provider can have
|
||||
accepted a prompt before the daemon recorded success, so automatic resubmission could duplicate
|
||||
work. Inspect the agent before choosing a new message ID. Receipts contain identity and request
|
||||
|
||||
@@ -20,16 +20,9 @@ settings the native composer does not implement.
|
||||
|
||||
## Native sources
|
||||
|
||||
Use the iOS window press stream for hardware keys. The stream reaches JavaScript
|
||||
after the focused text control has already handled the press, so the pipeline's
|
||||
`preventDefault` cannot stop a key from typing. A key the text control would type
|
||||
must be claimed up front. `UIKeyCommand` takes only those keys: composer Return, the
|
||||
top overlay's Enter and arrows, and the Shift or Option shortcuts whose binding is
|
||||
active in the current focus scope, such as Shift+Tab in the composer.
|
||||
`sources/native.ts` computes the claims from the published bindings and recomputes
|
||||
them when the focus scope changes. Cmd and Ctrl combos are never claimed, because
|
||||
text controls do not type them. Android takes no claims: a focused `EditText`
|
||||
types nothing for these combos. Keep the stream as the sole shortcut emitter: a command-owned
|
||||
Use the iOS window press stream for hardware keys. `UIKeyCommand` only takes keys
|
||||
that must bypass the focused text control: composer Return and the top overlay's
|
||||
Enter and arrows. Keep the stream as the sole shortcut emitter: a command-owned
|
||||
press is still observed by the window.
|
||||
|
||||
The source spike used an iPad Pro 11-inch (M4) simulator on iOS 18.6 with a
|
||||
|
||||
+4
-13
@@ -27,17 +27,9 @@ interface MobilePanelSelection {
|
||||
Every semantic target change increments `revision`. Repeating the current target is idempotent.
|
||||
Compact panel selection is not persisted; a cold start begins at `agent`.
|
||||
|
||||
An overlay sidebar holds `agent-list`, and only a compact layout holds `file-explorer`.
|
||||
A compact Settings visit entered from the sidebar suspends it: the host stays mounted and
|
||||
laid out on native. Its target and scroll position survive until return to the originating
|
||||
workspace. Route changes and `onLayout` both precede native screen appearance, so the sidebar
|
||||
stays visible until Settings emits the native navigator's `transitionEnd`. Web uses layout.
|
||||
Input and activity stop during the handoff. Widening or leaving for another destination discards
|
||||
the return. Commands issued while suspended settle immediately behind Settings, so explicit
|
||||
agent navigation does not reveal a closing sidebar animation.
|
||||
|
||||
App chrome publishes the layout (compact or wide, and the left sidebar presentation `overlay`,
|
||||
`dock`, `hidden`, or `suspended`) to `panel-store`, which drops any target the layout does not present. A stale
|
||||
Only an overlay sidebar holds `agent-list`, and only a compact layout holds `file-explorer`. App
|
||||
chrome publishes the layout (compact or wide, and the left sidebar presentation `overlay`, `dock`, or
|
||||
`hidden`) to `panel-store`, which drops any target the layout does not present. A stale
|
||||
`agent-list` would reopen the overlay on the next collapse; a stale `file-explorer` after widening
|
||||
would block the navigation swipe, which begins only from `agent`.
|
||||
|
||||
@@ -128,8 +120,7 @@ definition, no longer eligible to begin.
|
||||
the overlay host fills the window.
|
||||
- The overlay's backdrop covers the whole window; safe-area padding applies to the sidebar and
|
||||
workspace content only. It closes by swiping back, tapping the backdrop, or picking a destination:
|
||||
workspace destinations close the overlay before navigating. Settings suspends the compact
|
||||
sidebar for the duration of its visit instead.
|
||||
every sidebar destination closes the overlay before it navigates.
|
||||
- Animated panel nodes use React Native static styles plus inline theme values. Do not attach
|
||||
Unistyles-generated styles to those nodes; Unistyles and Reanimated patching the same Fabric node
|
||||
has caused native crashes.
|
||||
|
||||
@@ -624,21 +624,6 @@ one section's order from built-ins, plugin groups, and the section's preference
|
||||
remembers under its own key. It shares header ids and the `plugin:<pluginId>:<id>` settings key
|
||||
with new-API items.
|
||||
|
||||
## Contribute workspace row badge items
|
||||
|
||||
The [public reference](../public-docs/plugins/reference.md#workspace-row-badge-items) owns the author API.
|
||||
The host lives in `packages/app/src/plugins/workspace-row-badge-items/`; `WorkspaceMetaRow` draws a host's
|
||||
items after its own.
|
||||
|
||||
- Plugin items render with no wrapper view. The meta line spaces children with `gap`, so an empty
|
||||
wrapper would leave a gap on rows the plugin has nothing to say about. That is also why
|
||||
the row adds no separator around plugin items; a plugin renders its slot.
|
||||
- A row only renders items from plugins on its own host. The Show menu coalesces items across hosts
|
||||
by `<pluginId>/<itemId>`, the key in the `sidebarPluginRowBadgeItems` setting, and places them in
|
||||
their own group below a separator. A missing key is visible.
|
||||
- Previews (Settings > Host appearance) pass no `workspace` to `WorkspaceMetaRow`, so plugin items
|
||||
stay off them.
|
||||
|
||||
## Contribute file panels
|
||||
|
||||
Client `addFilePanel` registrations become the body of a file tab. The tab stats the file, picks a
|
||||
|
||||
@@ -150,15 +150,6 @@ Pi import discovery reads Pi's persisted JSONL session files because Pi RPC does
|
||||
|
||||
OMP is a first-class built-in provider, disabled by default. Its launch contract, typed runtime, agent/session behavior, history, permissions, imports, and test fake live under `providers/omp/`; only the provider-neutral JSONL child-process transport is shared with Pi. It launches `omp --mode rpc-ui`, uses OMP's `get_available_commands` RPC for slash-command discovery, bridges OMP `rpc-ui` approval dialogs into Paseo permissions, and imports terminal-started sessions from `~/.omp/agent/sessions` when enabled.
|
||||
|
||||
OMP rewind is a branch, not an edit: `branch` writes a new session file whose header names the
|
||||
previous file as `parentSession` and leaves that previous file untouched. A rewind chain therefore
|
||||
adds one file to `~/.omp/agent/sessions` per step, and `omp --resume` lists all of them. The
|
||||
previous file also keeps whatever head it had recorded, which can be an older branch than the one
|
||||
the user last saw. Treat the provider session file selected by OMP as the authority; do not assume
|
||||
the newest file or the newest message wins. Import listings hand back a file path, so persist the
|
||||
resumed session's own identity (`sessionId`) with that path as `nativeHandle`, not the listed
|
||||
handle as both.
|
||||
|
||||
OMP supports native Paseo host tools. The adapter registers the full caller-scoped Paseo tool catalog directly with OMP, matching providers such as Claude that expose the full catalog through MCP. Serialize every OMP host definition with `loadMode: "essential"` so `create_agent`, `send_agent_prompt`, `wait_for_agent`, and related tools remain direct calls; omitting the field makes OMP mount non-built-in names under `xd://` instead. OMP's provider-managed task subagents are surfaced as Paseo subagents through `child_session` imports; the parent keeps the subagents track while the child runtime stays owned by OMP. Custom OMP profiles should extend `omp`; other Pi-compatible forks can still extend `pi`, override `command`, and set `params.sessionDir` to their JSONL session directory.
|
||||
|
||||
Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are bridged into Paseo question permissions and answered with `extension_ui_response`. Pi extensions such as `ask_user` may chain dialogs: for example, a `select` can be followed by an optional-comment `input`. When an `ask_user` tool call declares `allowComment: true`, Paseo presents the selection and optional comment as one question permission, answers Pi's initial `select` immediately, then auto-answers the follow-up optional `input` with the comment the user already supplied (or an empty string). Preserve placeholders and optional/skip semantics for standalone optional inputs so the app can still distinguish "skip this optional input" from "cancel the whole dialog." Fire-and-forget extension UI requests such as notifications are intentionally ignored by the provider adapter unless Paseo grows first-class UI for them.
|
||||
|
||||
+1
-2
@@ -248,7 +248,6 @@ npm run release:promote # Promote X.Y.Z-beta.N to stable X.Y.Z
|
||||
|
||||
- Beta tags are published GitHub prereleases like `v0.1.41-beta.1`
|
||||
- Betas publish npm packages with `--tag beta`, so `npm install @getpaseo/cli@beta` opts in while plain `npm install @getpaseo/cli` stays on `latest`
|
||||
- The user-facing beta instructions, including the public TestFlight join link for the `Paseo Beta` group, live in `public-docs/updates.md` and its `zh-CN` pair. Update them when a beta path is added or removed.
|
||||
- Betas publish desktop assets and APKs for testing. They also build iOS, upload it to TestFlight, add it to the `Paseo Beta` external group, and submit it for Beta App Review. They do not submit mobile builds to the production stores.
|
||||
- `release:promote` creates a fresh stable tag like `v0.1.41`; the final release never reuses the beta tag
|
||||
- Desktop assets now come from the Electron package at `packages/desktop`
|
||||
@@ -489,7 +488,7 @@ The GitHub Release body is populated automatically by the `Release Notes Sync` w
|
||||
|
||||
- The website download page defaults to GitHub's latest published **stable** release.
|
||||
- A published beta prerelease is offered behind the Stable/Beta switch on `/download` (`?channel=beta`), never as the default. The switch only appears while the newest prerelease leads stable on its core version, so promoting `X.Y.Z-beta.N` to `X.Y.Z` retires the beta channel from the page until the next beta line opens.
|
||||
- Homebrew, the Play Store, and `app.paseo.sh` have no beta. The Beta view drops those rows, and the whole Web section, rather than showing an inert "stable only" placeholder. The iOS row swaps the App Store pill for the public TestFlight link (`testFlightBetaUrl` in `packages/website/src/downloads.tsx`). When another surface gains a beta path, add its row back in `packages/website/src/routes/{-$locale}/download.tsx`.
|
||||
- Homebrew, the Play Store, the App Store, and `app.paseo.sh` have no beta. The Beta view drops those rows, and the whole Web section, rather than showing an inert "stable only" placeholder. When a surface gains a beta path — say a public TestFlight link — add its row back in `packages/website/src/routes/download.tsx`.
|
||||
- The default download target only moves when you publish the final stable release tag like `v0.1.41`.
|
||||
- The public `/changelog` page renders `CHANGELOG.md` as-is, so the in-flight `-beta.N` entry shows there once it lands on `main` — that's intended, it's where beta users check what's coming. Only the **default download target** stays pinned to the latest stable; the download links read GitHub's releases API, not the changelog, so a `-beta.N` heading on top never affects them.
|
||||
- The download page's "What's new" link deep-links the **minor group** anchor (`/changelog#release-0.3`), not the exact entry: the beta entry is renamed on each beta and on promotion, so the minor group remains the durable target. A version with no entry in the bundled changelog — a tag whose changelog commit hasn't redeployed the site yet — links the plain `/changelog` instead of a dead anchor.
|
||||
|
||||
+1
-1
@@ -346,7 +346,7 @@ Gotchas:
|
||||
- **Narrow the discriminated union before spreading.** `updateTheme`'s updater returns the theme union; spreading the union widens `colorScheme` to `"light" | "dark"`, which is assignable to neither concrete member. Branch on `t.colorScheme` so each branch spreads a single narrowed theme type (no `as`).
|
||||
- **`lineHeight.diff` is the code/diff line-height axis** — it is coupled to the code-font-size control (≈ `codeFontSize * 1.5`). Do NOT use it for prose. Markdown body line-height scales with content size (`Math.round(theme.fontSize.content * 1.4)`); routing prose through `lineHeight.diff` clips text at small code sizes.
|
||||
- **High-churn draft values** (live-while-typing in the appearance preview) bypass the theme: apply them as inline styles marked with `inlineUnistylesStyle` so per-keystroke values don't grow the `#unistyles-web` CSS registry.
|
||||
- **Web numeric appearance tokens need a render; native tracked styles update in place.** On web, string tokens such as colors become CSS variables, but numeric tokens (font sizes, line heights) are baked into generated classes. `AppearanceStyleBoundary` remounts web shell chrome and explorer content to refresh those classes. Settings retains its screen and form; `AppearanceText` refreshes numeric text styles at the leaf, and its font fields use `withUnistyles` to refresh input styles without replacing the inputs. Native sidebars and explorer hosts stay outside appearance keys: their gesture refs outlive detached dependents until Gesture Handler's passive cleanup, so remounting a related gesture can query an unmounted view. Their tracked native styles and existing themed leaf props update without replacing the gesture hosts or draggable lists. Parsed PR markdown uses its own `withUnistyles` style mapping.
|
||||
- **Web numeric appearance tokens need a render; native tracked styles update in place.** On web, string tokens such as colors become CSS variables, but numeric tokens (font sizes, line heights) are baked into generated classes. `AppearanceStyleBoundary` remounts web shell chrome and explorer content to refresh those classes. Native sidebars and explorer hosts stay outside appearance keys: their gesture refs outlive detached dependents until Gesture Handler's passive cleanup, so remounting a related gesture can query an unmounted view. Their tracked native styles and existing themed leaf props update without replacing the gesture hosts or draggable lists. Parsed PR markdown uses its own `withUnistyles` style mapping.
|
||||
- **Appearance keys belong below native lifetime owners.** `ThemedStack` wraps screen content through `screenLayout`, preserving navigator identity; nested navigators are listed in `nestedNavigatorScreens` and own their own screen boundaries. The remaining shell surfaces retain their appearance refresh separately from native panel hosts. A key above a native stack can detach its screen container inside a FragmentManager transaction during settings hydration. Keep both navigators and retained panel gesture hosts outside those keys. `applyAppearance` patches the active theme before inactive registry entries so subscribers receive the committed values in the same update. Do not add local appearance keys or a boundary above a navigator.
|
||||
- **Dynamic font tokens stay widened.** `fontFamily`, `fontSize`, and `lineHeight` on `commonTheme` are annotated `string`/`number` (not narrowed by `as const`) so the updater's return assigns; the platform default stacks live in `DEFAULT_UI_FONT_STACK` / `DEFAULT_MONO_FONT_STACK`.
|
||||
|
||||
|
||||
Generated
+15
-32
@@ -31853,6 +31853,20 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-draggable-flatlist": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/react-native-draggable-flatlist/-/react-native-draggable-flatlist-4.0.3.tgz",
|
||||
"integrity": "sha512-2F4x5BFieWdGq9SetD2nSAR7s7oQCSgNllYgERRXXtNfSOuAGAVbDb/3H3lP0y5f7rEyNwabKorZAD/SyyNbDw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/preset-typescript": "^7.17.12"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react-native": ">=0.64.0",
|
||||
"react-native-gesture-handler": ">=2.0.0",
|
||||
"react-native-reanimated": ">=2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-drawer-layout": {
|
||||
"version": "4.2.12",
|
||||
"resolved": "https://registry.npmjs.org/react-native-drawer-layout/-/react-native-drawer-layout-4.2.12.tgz",
|
||||
@@ -31895,22 +31909,6 @@
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-haptic-feedback": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/react-native-haptic-feedback/-/react-native-haptic-feedback-3.0.0.tgz",
|
||||
"integrity": "sha512-yxBQFHhXU8S0xupGtzDRToGw5yMWezUdLzxtlx2Oi9YpjPwprWZWCw8foPt0S2t84B0ZikzZYyOHiMSXIelUxg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peerDependencies": {
|
||||
"react": ">=18.0.0",
|
||||
"react-native": ">=0.71.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-is-edge-to-edge": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.3.1.tgz",
|
||||
@@ -31994,21 +31992,6 @@
|
||||
"integrity": "sha512-b+4vILXHPu++t8H41PHLBVsTab2LPqwXNdzgdScyl4+Cu8Ta34aUQW3T469cB0ogAMPdu//KV00w4YVpDZoRUQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-native-sortables": {
|
||||
"version": "1.10.1",
|
||||
"resolved": "https://registry.npmjs.org/react-native-sortables/-/react-native-sortables-1.10.1.tgz",
|
||||
"integrity": "sha512-3M+OnzE2zgN2meECvd5cGQrm5oFQSQ34HPm3DSLAo8Xaif9+Zvtv5V4uX8u/HmBtI+v1gEuw7rjuYWqvUhZB9Q==",
|
||||
"license": "MIT",
|
||||
"optionalDependencies": {
|
||||
"react-native-haptic-feedback": ">=2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "*",
|
||||
"react-native": "*",
|
||||
"react-native-gesture-handler": ">=2.0.0",
|
||||
"react-native-reanimated": ">=3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-unistyles": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/react-native-unistyles/-/react-native-unistyles-3.2.4.tgz",
|
||||
@@ -37566,6 +37549,7 @@
|
||||
"react-freeze": "1.0.4",
|
||||
"react-i18next": "^17.0.8",
|
||||
"react-native": "0.86.3",
|
||||
"react-native-draggable-flatlist": "^4.0.3",
|
||||
"react-native-edge-to-edge": "^1.7.0",
|
||||
"react-native-enriched-markdown": "1.2.0-nightly-20261014-766fc0e67",
|
||||
"react-native-gesture-handler": "~2.32.0",
|
||||
@@ -37574,7 +37558,6 @@
|
||||
"react-native-reanimated": "4.5.5",
|
||||
"react-native-safe-area-context": "~5.7.0",
|
||||
"react-native-screens": "~4.26.0",
|
||||
"react-native-sortables": "^1.10.1",
|
||||
"react-native-svg": "15.15.4",
|
||||
"react-native-unistyles": "^3.2.4",
|
||||
"react-native-web": "~0.21.0",
|
||||
|
||||
@@ -37,7 +37,7 @@ test("keeps the selected workspace visible in Light", async ({ page }, testInfo)
|
||||
await row.click();
|
||||
|
||||
await expect(row).toHaveAttribute("aria-selected", "true");
|
||||
await expect(row).toHaveCSS("background-color", "rgb(212, 212, 213)");
|
||||
await expect(row).toHaveCSS("background-color", "rgb(212, 212, 216)");
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("light-selected-workspace.png"),
|
||||
fullPage: true,
|
||||
@@ -64,7 +64,7 @@ test("keeps the selected workspace visible in Pure black", async ({ page }, test
|
||||
await row.click();
|
||||
|
||||
await expect(row).toHaveAttribute("aria-selected", "true");
|
||||
await expect(row).toHaveCSS("background-color", "rgb(29, 29, 29)");
|
||||
await expect(row).toHaveCSS("background-color", "rgb(17, 17, 17)");
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("pure-black-selected-workspace.png"),
|
||||
fullPage: true,
|
||||
|
||||
@@ -644,22 +644,6 @@ test("copying an assistant selection preserves Markdown structure and links", as
|
||||
'```typescript\nconst answer = "yes";\n return answer;\n```\n\nAfter code.',
|
||||
);
|
||||
|
||||
// Crossing into part of the block keeps the selected code lines as a fenced block.
|
||||
await selectAssistantAcrossCodeLines(page, "not a generated link.", "return");
|
||||
await copySelection(page);
|
||||
|
||||
expect((await readRichClipboard(page)).plainText).toBe(
|
||||
'not a generated link.\n\n```typescript\nconst answer = "yes";\n return\n```',
|
||||
);
|
||||
|
||||
// So does starting partway through the block and leaving it.
|
||||
await selectAssistantAcrossCodeLines(page, "answer", "After code.");
|
||||
await copySelection(page);
|
||||
|
||||
expect((await readRichClipboard(page)).plainText).toBe(
|
||||
'```typescript\nanswer = "yes";\n return answer;\n```\n\nAfter code.',
|
||||
);
|
||||
|
||||
// Selecting a whole line runs off its end into the newline node. A trailing
|
||||
// newline auto-executes the line when it is pasted into a terminal.
|
||||
await selectAssistantAcrossCodeLines(page, "const", " ");
|
||||
@@ -745,25 +729,13 @@ test.describe("on a touch-capable desktop browser", () => {
|
||||
// The pan accepts touch only, and a mouse drag must still extend the browser's selection.
|
||||
test.use({ hasTouch: true });
|
||||
|
||||
test("a mouse drag selects across assistant list, quote, and code blocks", async ({ page }) => {
|
||||
test("a mouse drag selects across assistant paragraphs", async ({ page }) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "assistant-selection-drag-",
|
||||
title: "Assistant selection drag",
|
||||
initialPrompt: "Render structured text.",
|
||||
initialPrompt: "Render three paragraphs.",
|
||||
featureValues: {
|
||||
mockAssistantResponse: [
|
||||
"First paragraph.",
|
||||
"",
|
||||
"- List item.",
|
||||
"",
|
||||
"> Quote text.",
|
||||
"",
|
||||
"```ts",
|
||||
"const answer = 1;",
|
||||
"```",
|
||||
"",
|
||||
"Third paragraph.",
|
||||
].join("\n"),
|
||||
mockAssistantResponse: "First paragraph.\n\nSecond paragraph.\n\nThird paragraph.",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -788,9 +760,6 @@ test.describe("on a touch-capable desktop browser", () => {
|
||||
|
||||
const selected = await page.evaluate(() => window.getSelection()?.toString() ?? "");
|
||||
expect(selected).toContain("First paragraph.");
|
||||
expect(selected).toContain("List item.");
|
||||
expect(selected).toContain("Quote text.");
|
||||
expect(selected).toContain("const answer = 1;");
|
||||
expect(selected).toContain("Third paragraph.");
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
|
||||
@@ -2297,9 +2297,13 @@ async function changeCodeTypographyFromSettings(
|
||||
await page.getByTestId("sidebar-settings").click();
|
||||
await expect(page).toHaveURL(new RegExp(`${buildSettingsSectionRoute("general")}|/settings$`));
|
||||
await page.getByRole("button", { name: "Appearance" }).click();
|
||||
// A committed font change remounts the screen (AppearanceStyleBoundary), and the new form starts
|
||||
// from the stored size. Edit the size only in the remounted form, or the remount drops it.
|
||||
const sizeField = page.getByLabel("Code font size");
|
||||
await sizeField.evaluate((field) => field.setAttribute("data-e2e-before-font-family", ""));
|
||||
await page.getByLabel("Code font family").fill(typography.fontFamily);
|
||||
await page.getByLabel("Code font family").press("Enter");
|
||||
await expect(page.locator("[data-e2e-before-font-family]")).toHaveCount(0);
|
||||
await sizeField.fill(String(typography.fontSize));
|
||||
await page.getByLabel("Code font size").press("Enter");
|
||||
await expect(page.getByLabel("Code font family")).toHaveValue(typography.fontFamily);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { seedSubagent } from "../support/helpers/subagents";
|
||||
import { expectWorkspaceHeaderVisible, submitMessage } from "../support/helpers/composer";
|
||||
import { submitMessage } from "../support/helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { gateNextAgentMessage } from "../support/helpers/agent-message-gate";
|
||||
import {
|
||||
@@ -918,12 +918,11 @@ test.describe("touch composer tracks", () => {
|
||||
[tasks, subagents, changes].map(async (pill) => (await pill.boundingBox())!.width),
|
||||
);
|
||||
await page.setViewportSize({ width: 200, height: 844 });
|
||||
// The compact layout mounts a new composer, so measure only after it replaces the wide one.
|
||||
await expectWorkspaceHeaderVisible(page);
|
||||
for (const [index, pill] of [tasks, subagents, changes].entries())
|
||||
await expect
|
||||
.poll(async () => (await pill.boundingBox())?.width ?? 0)
|
||||
.toBeGreaterThanOrEqual(wideWidths[index] - 1);
|
||||
const narrowWidths = await Promise.all(
|
||||
[tasks, subagents, changes].map(async (pill) => (await pill.boundingBox())!.width),
|
||||
);
|
||||
for (const [index, width] of wideWidths.entries())
|
||||
expect(narrowWidths[index]).toBeGreaterThanOrEqual(width - 1);
|
||||
const track = page.getByTestId("composer-track-scroll");
|
||||
expect(await track.evaluate((node) => node.scrollWidth)).toBeGreaterThan(
|
||||
await track.evaluate((node) => node.clientWidth),
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { composerLocator, expectComposerVisible } from "../support/helpers/composer";
|
||||
import {
|
||||
composerControl,
|
||||
expectGlyphMatchesLabel,
|
||||
expectIconOnlyGlyph,
|
||||
readComposerControlTone,
|
||||
readComposerPlaceholderTone,
|
||||
readComposerTextTone,
|
||||
} from "../support/helpers/composer-toolbar-tone";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
const THINKING = "agent-thinking-selector";
|
||||
const MODE = "mode-control";
|
||||
const ATTACH = "message-input-attach-button";
|
||||
const COMBINED_MODEL = "combined-model-selector";
|
||||
|
||||
async function openSeededComposer(
|
||||
page: import("@playwright/test").Page,
|
||||
repoPrefix: string,
|
||||
readyTestId = MODE,
|
||||
) {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix,
|
||||
title: "Toolbar tone",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
await expect(composerControl(page, readyTestId)).toBeVisible({ timeout: 30_000 });
|
||||
return agent;
|
||||
} catch (error) {
|
||||
await agent.cleanup();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
test.describe("Composer toolbar glyph tone", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test.describe("on a touch screen", () => {
|
||||
test.use({ hasTouch: true, viewport: { width: 1024, height: 1366 } });
|
||||
|
||||
test("a glyph beside its label matches the label while an icon-only glyph stays foreground", async ({
|
||||
page,
|
||||
}) => {
|
||||
const agent = await openSeededComposer(page, "composer-tone-touch-");
|
||||
try {
|
||||
const thinking = await readComposerControlTone(composerControl(page, THINKING));
|
||||
const mode = await readComposerControlTone(composerControl(page, MODE));
|
||||
const attach = await readComposerControlTone(composerControl(page, ATTACH));
|
||||
|
||||
expectGlyphMatchesLabel(thinking, "thinking");
|
||||
expectGlyphMatchesLabel(mode, "mode");
|
||||
expectIconOnlyGlyph(attach, "attach");
|
||||
expect(attach.glyph, "an icon-only glyph is brighter than a labelled glyph").not.toBe(
|
||||
thinking.label,
|
||||
);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("on a compact non-touch window", () => {
|
||||
test.use({ hasTouch: false, viewport: { width: 590, height: 844 } });
|
||||
|
||||
test("the icon-only thinking glyph stays foreground and the labelled mode glyph matches its label", async ({
|
||||
page,
|
||||
}) => {
|
||||
const agent = await openSeededComposer(page, "composer-tone-compact-");
|
||||
try {
|
||||
const thinking = await readComposerControlTone(composerControl(page, THINKING));
|
||||
const mode = await readComposerControlTone(composerControl(page, MODE));
|
||||
const attach = await readComposerControlTone(composerControl(page, ATTACH));
|
||||
|
||||
expectIconOnlyGlyph(thinking, "thinking");
|
||||
expectGlyphMatchesLabel(mode, "mode");
|
||||
expectIconOnlyGlyph(attach, "attach");
|
||||
expect(thinking.glyph, "the icon-only glyphs share one tone").toBe(attach.glyph);
|
||||
expect(thinking.glyph, "an icon-only glyph is brighter than a labelled glyph").not.toBe(
|
||||
mode.label,
|
||||
);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("on a folded compact non-touch window", () => {
|
||||
test.use({ hasTouch: false, viewport: { width: 480, height: 844 } });
|
||||
|
||||
test("the empty composer placeholder is quieter than its labels", async ({ page }) => {
|
||||
const agent = await openSeededComposer(page, "composer-tone-placeholder-", COMBINED_MODEL);
|
||||
try {
|
||||
const placeholder = await readComposerPlaceholderTone(composerLocator(page));
|
||||
const extraMuted = await readComposerTextTone(
|
||||
composerControl(page, "combined-model-selector-thinking-label"),
|
||||
);
|
||||
const muted = await readComposerTextTone(
|
||||
composerControl(page, "combined-model-selector-model-label"),
|
||||
);
|
||||
|
||||
expect(placeholder, "the composer paints a placeholder").not.toBeNull();
|
||||
expect(extraMuted, "the folded thinking label paints the extra-muted tone").not.toBeNull();
|
||||
expect(placeholder, "the placeholder uses the extra-muted tone").toBe(extraMuted);
|
||||
expect(placeholder, "the placeholder is quieter than a labelled glyph").not.toBe(muted);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("with a wide pointer", () => {
|
||||
test.use({ hasTouch: false, viewport: { width: 1280, height: 800 } });
|
||||
|
||||
test("a labelled glyph stays muted and matches its label", async ({ page }) => {
|
||||
const agent = await openSeededComposer(page, "composer-tone-pointer-");
|
||||
try {
|
||||
const thinking = await readComposerControlTone(composerControl(page, THINKING));
|
||||
const mode = await readComposerControlTone(composerControl(page, MODE));
|
||||
|
||||
expectGlyphMatchesLabel(thinking, "thinking");
|
||||
expectGlyphMatchesLabel(mode, "mode");
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
reloadAgent,
|
||||
scriptAgentUsage,
|
||||
seedAgentWithContextWindow,
|
||||
tapContextWindowMeter,
|
||||
usageCard,
|
||||
} from "../support/helpers/context-window";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
@@ -509,6 +510,24 @@ test("context details stay open while the chat streams", async ({ page }) => {
|
||||
}
|
||||
});
|
||||
|
||||
test.describe("on a wide touch screen", () => {
|
||||
test.use({ hasTouch: true });
|
||||
|
||||
test("tapping the meter opens context details that stay open", async ({ page, agent }) => {
|
||||
const usage = await scriptAgentUsage(page);
|
||||
await page.setViewportSize(DESKTOP);
|
||||
await openAgent(page, agent);
|
||||
const [claude] = claudeAndCodexReports();
|
||||
// A tap can request the agent's usage more than once.
|
||||
usage.answerNext([onPersonalLogin(claude!)]);
|
||||
usage.answerNext([onPersonalLogin(claude!)]);
|
||||
const details = await tapContextWindowMeter(page);
|
||||
// Past the card's close grace. A touch sends no pointer movement after the tap.
|
||||
await page.waitForTimeout(500);
|
||||
await expect(details).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test("pointer hovering context details preserves composer focus", async ({ page, agent }) => {
|
||||
const usage = await scriptAgentUsage(page);
|
||||
await page.setViewportSize(DESKTOP);
|
||||
|
||||
@@ -127,42 +127,8 @@ for (const scenario of [
|
||||
await creation.submitPrompt("Retry this workspace and agent together.");
|
||||
await creation.expectAgentCount(1);
|
||||
});
|
||||
|
||||
test(`an edited prompt after failed agent initialization starts the agent in its workspace${scenario.suffix}`, async ({
|
||||
creation,
|
||||
startup,
|
||||
page,
|
||||
}) => {
|
||||
await creation.openWorkspaceForm("local");
|
||||
await creation.submitPrompt("Start the original task.", "Create");
|
||||
await creation.expectAgentStillStarting();
|
||||
await startup.fail();
|
||||
await creation.expectStartupFailure();
|
||||
await creation.expectOneCreatedWorkspace();
|
||||
await scenario.prepare(creation);
|
||||
await creation.submitPrompt("Start the edited task.");
|
||||
await creation.expectPromptVisible("Start the edited task.");
|
||||
await creation.expectOneCreatedWorkspace();
|
||||
await creation.expectAgentCount(1);
|
||||
await expect(page.getByText(/request_key_conflict/)).toHaveCount(0);
|
||||
});
|
||||
}
|
||||
|
||||
test("an edited agent draft starts after its first start failed", async ({
|
||||
creation,
|
||||
startup,
|
||||
page,
|
||||
}) => {
|
||||
await creation.openAgentDraft();
|
||||
await startup.fail();
|
||||
await creation.submitPrompt("Start the original task.");
|
||||
await creation.expectStartupFailure();
|
||||
await creation.submitPrompt("Start the edited task.");
|
||||
await creation.expectPromptVisible("Start the edited task.");
|
||||
await creation.expectAgentCount(1);
|
||||
await expect(page.getByText(/request_key_conflict/)).toHaveCount(0);
|
||||
});
|
||||
|
||||
for (const entry of [
|
||||
{
|
||||
name: "agent draft",
|
||||
@@ -193,9 +159,7 @@ for (const entry of [
|
||||
await entry.settled(creation);
|
||||
await creation.expectCreatedAgentError();
|
||||
await creation.expectPromptBeforeError(prompt);
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("created-agent-prompt-error.png"),
|
||||
});
|
||||
await page.screenshot({ path: testInfo.outputPath("created-agent-prompt-error.png") });
|
||||
await creation.reloadAgent();
|
||||
await creation.expectPromptBeforeError(prompt);
|
||||
await creation.submitPrompt("emit 1 coalesced agent stream updates for a corrected prompt.");
|
||||
|
||||
@@ -3,7 +3,6 @@ import path from "node:path";
|
||||
import type { Locator } from "@playwright/test";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import {
|
||||
downloadFileFromPreview,
|
||||
expandFolder,
|
||||
openFileExplorer,
|
||||
openFileFromExplorer,
|
||||
@@ -142,29 +141,6 @@ async function seedAgentWithFileLink(input: LinkedFile) {
|
||||
}
|
||||
|
||||
test.describe("CodeMirror workspace file editing", () => {
|
||||
test("downloads an assistant-linked binary outside the workspace", async ({ page }, testInfo) => {
|
||||
const filePath = testInfo.outputPath("outside.bin");
|
||||
const source = Buffer.from([0, 1, 128, 255]);
|
||||
await writeFile(filePath, source);
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "outside-binary-",
|
||||
title: "Outside binary",
|
||||
initialPrompt: "Show the file",
|
||||
featureValues: { mockAssistantResponse: `[Open binary](${filePath})` },
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
await openAssistantMarkdownLink(page, "Open binary");
|
||||
const downloaded = await downloadFileFromPreview(page);
|
||||
expect(downloaded.suggestedFilename()).toBe("outside.bin");
|
||||
const savedFile = testInfo.outputPath("downloaded.bin");
|
||||
await downloaded.saveAs(savedFile);
|
||||
expect(await readFile(savedFile)).toEqual(source);
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("opens explicit screenshot links and explains missing files", async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "screenshot-markdown-link-",
|
||||
|
||||
@@ -2,12 +2,7 @@ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { test } from "../support/fixtures";
|
||||
import {
|
||||
downloadFileFromExplorer,
|
||||
downloadFileFromPreview,
|
||||
openFileFromExplorer,
|
||||
openFileExplorer,
|
||||
} from "../support/helpers/file-explorer";
|
||||
import { downloadFileFromExplorer, openFileExplorer } from "../support/helpers/file-explorer";
|
||||
import { openChangesPanel } from "../support/helpers/workspace-tabs";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { daemonWsRoutePattern, getE2EDaemonPort } from "../support/helpers/daemon-port";
|
||||
@@ -478,21 +473,3 @@ test("downloads a file over direct TCP with its original filename and bytes", as
|
||||
expect(await downloaded.failure()).toBeNull();
|
||||
expect(await readFile(savedFile)).toEqual(source);
|
||||
});
|
||||
|
||||
test("downloads an unsupported binary from its preview with original filename and bytes", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const fileName = "rapport données.bin";
|
||||
const source = Buffer.from([0, 1, 2, 127, 128, 255, 10]);
|
||||
await writeFile(path.join(workspace.workspaceDirectory, fileName), source);
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await openFileExplorer(page);
|
||||
await openFileFromExplorer(page, fileName);
|
||||
|
||||
const downloaded = await downloadFileFromPreview(page);
|
||||
expect(downloaded.suggestedFilename()).toBe(fileName);
|
||||
const savedFile = testInfo.outputPath(fileName);
|
||||
await downloaded.saveAs(savedFile);
|
||||
expect(await downloaded.failure()).toBeNull();
|
||||
expect(await readFile(savedFile)).toEqual(source);
|
||||
});
|
||||
|
||||
@@ -5,10 +5,6 @@ import path from "node:path";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
|
||||
import { openSettingsSection } from "../support/helpers/settings";
|
||||
import { seedMockAgentWorkspace, openAgentRoute } from "../support/helpers/mock-agent";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { contrastRatio } from "../../src/utils/color";
|
||||
import type { Locator, Page } from "@playwright/test";
|
||||
|
||||
const PLUGIN_ID = "plugin-theme-e2e";
|
||||
|
||||
@@ -44,16 +40,6 @@ const PLUGIN_SOURCE = `export default function contribute(plugin) {
|
||||
ring: "#9ca0b0",
|
||||
},
|
||||
});
|
||||
for (const [id, name, overrides] of [
|
||||
["solarized-dark", "Solarized Dark", {}],
|
||||
["solarized-dark-custom", "Solarized Dark Custom", { sidebarSelected: "#334455", foregroundExtraMuted: "#ddeeff" }],
|
||||
]) {
|
||||
plugin.addTheme({ id, name, appearance: "dark", colors: {
|
||||
background: "#002b36", foreground: "#839496", raised: "#073642",
|
||||
control: "#073642", border: "#073642", accent: "#268bd2",
|
||||
mutedForeground: "#586e75", ring: "#93a1a1", ...overrides,
|
||||
}});
|
||||
}
|
||||
return () => {};
|
||||
}`;
|
||||
|
||||
@@ -61,88 +47,6 @@ const PLUGIN_SOURCE = `export default function contribute(plugin) {
|
||||
// contributed palette reached the semantic tokens rather than just the swatch.
|
||||
const MOCHA_MUTED_FOREGROUND = "rgb(166, 173, 200)";
|
||||
const LATTE_MUTED_FOREGROUND = "rgb(108, 111, 133)";
|
||||
const LATTE_RING = "rgb(156, 160, 176)";
|
||||
|
||||
async function selectTheme(page: Page, name: string) {
|
||||
await page.goto("/settings");
|
||||
await openSettingsSection(page, "appearance");
|
||||
const theme = page.getByRole("radio", { name, exact: true });
|
||||
await expect(theme).toBeVisible({ timeout: 30_000 });
|
||||
await theme.click();
|
||||
await expect(theme).toBeChecked();
|
||||
}
|
||||
|
||||
async function expectTimestampContrast(row: Locator) {
|
||||
const timestamp = row.getByTestId("sidebar-workspace-timestamp");
|
||||
await expect(timestamp).toBeVisible();
|
||||
const hex = (rgb: string) =>
|
||||
`#${rgb
|
||||
.match(/\d+/g)!
|
||||
.slice(0, 3)
|
||||
.map((value) => Number(value).toString(16).padStart(2, "0"))
|
||||
.join("")}`;
|
||||
const background = await row.evaluate((element) => getComputedStyle(element).backgroundColor);
|
||||
const foreground = await timestamp.evaluate((element) => getComputedStyle(element).color);
|
||||
expect(contrastRatio(hex(background), hex(foreground))).toBeGreaterThanOrEqual(2.5);
|
||||
}
|
||||
|
||||
test("keeps plugin workspace timestamps readable on state surfaces and honors overrides", async ({
|
||||
page,
|
||||
}) => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-theme-state-"));
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previousConfig = await client.getDaemonConfig();
|
||||
const selected = await seedMockAgentWorkspace({
|
||||
repoPrefix: "theme-selected-",
|
||||
title: "Selected theme workspace",
|
||||
});
|
||||
const hovered = await seedMockAgentWorkspace({
|
||||
repoPrefix: "theme-hovered-",
|
||||
title: "Hovered theme workspace",
|
||||
});
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
|
||||
);
|
||||
await writeFile(path.join(directory, "index.client.ts"), PLUGIN_SOURCE);
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(directory);
|
||||
await selectTheme(page, "Solarized Dark");
|
||||
await openAgentRoute(page, selected);
|
||||
const selectedRow = page.getByTestId(
|
||||
`sidebar-workspace-row-${getServerId()}:${selected.workspaceId}`,
|
||||
);
|
||||
const hoveredRow = page.getByTestId(
|
||||
`sidebar-workspace-row-${getServerId()}:${hovered.workspaceId}`,
|
||||
);
|
||||
await expect(selectedRow).toHaveAttribute("aria-selected", "true", { timeout: 30_000 });
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(selectedRow).toHaveCSS("background-color", "rgb(33, 58, 66)");
|
||||
await expectTimestampContrast(selectedRow);
|
||||
await hoveredRow.hover();
|
||||
await expect(hoveredRow).toHaveCSS("background-color", "rgb(19, 50, 60)");
|
||||
await expectTimestampContrast(hoveredRow);
|
||||
|
||||
await selectTheme(page, "Solarized Dark Custom");
|
||||
await openAgentRoute(page, selected);
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(selectedRow).toHaveCSS("background-color", "rgb(51, 68, 85)");
|
||||
await expect(selectedRow.getByTestId("sidebar-workspace-timestamp")).toHaveCSS(
|
||||
"color",
|
||||
"rgb(221, 238, 255)",
|
||||
);
|
||||
} finally {
|
||||
await client.removePlugin(PLUGIN_ID).catch(() => undefined);
|
||||
await client
|
||||
.patchDaemonConfig({ pluginsEnabled: previousConfig.config.pluginsEnabled ?? false })
|
||||
.catch(() => undefined);
|
||||
await client.close();
|
||||
await selected.cleanup();
|
||||
await hovered.cleanup();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("applies a contributed theme and falls back when its plugin is gone", async ({
|
||||
page,
|
||||
@@ -179,13 +83,6 @@ test("applies a contributed theme and falls back when its plugin is gone", async
|
||||
await expect(sectionTitle).toHaveCSS("color", LATTE_MUTED_FOREGROUND);
|
||||
});
|
||||
|
||||
await test.step("keyboard focus draws the outline in the theme's ring color", async () => {
|
||||
await page.getByRole("radio", { name: "Catppuccin Latte", exact: true }).click();
|
||||
await page.mouse.move(0, 0);
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(page.locator(":focus-visible").first()).toHaveCSS("outline-color", LATTE_RING);
|
||||
});
|
||||
|
||||
await mochaItem.click();
|
||||
await expect(mochaItem).toBeChecked();
|
||||
await expect(sectionTitle).toHaveCSS("color", MOCHA_MUTED_FOREGROUND);
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
|
||||
import { pluginRequirements } from "../support/helpers/plugin-fixture";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import {
|
||||
closeSidebarDisplayPreferences,
|
||||
openMobileAgentSidebar,
|
||||
openSidebarDisplayPage,
|
||||
} from "../support/helpers/sidebar";
|
||||
|
||||
const PLUGIN_ID = "row-badges-e2e";
|
||||
const TITLE = "Badge host workspace";
|
||||
|
||||
const PLUGIN_SOURCE = `import { WorkspaceRowBadge } from "@getpaseo/plugin/client/ui";
|
||||
|
||||
function Review() {
|
||||
return <WorkspaceRowBadge icon="Eye" label="In review" tone="warning" />;
|
||||
}
|
||||
|
||||
function Docs() {
|
||||
return <WorkspaceRowBadge icon="BookOpen" label="Docs" />;
|
||||
}
|
||||
|
||||
function IconOnly() {
|
||||
return <WorkspaceRowBadge icon="Star" />;
|
||||
}
|
||||
|
||||
function EmptyLabel() {
|
||||
return <WorkspaceRowBadge icon="Circle" label="" />;
|
||||
}
|
||||
|
||||
function Quiet() {
|
||||
return null;
|
||||
}
|
||||
|
||||
function Broken() {
|
||||
throw new Error("badge failed");
|
||||
}
|
||||
|
||||
export default function contribute(client) {
|
||||
client.addWorkspaceRowBadgeItem({ id: "review", title: "Review state", icon: "Eye", Component: Review });
|
||||
client.addWorkspaceRowBadgeItem({ id: "docs", title: "Docs state", icon: "BookOpen", Component: Docs });
|
||||
client.addWorkspaceRowBadgeItem({ id: "icon", title: "Icon state", icon: "Star", Component: IconOnly });
|
||||
client.addWorkspaceRowBadgeItem({ id: "empty", title: "Empty label state", icon: "Circle", Component: EmptyLabel });
|
||||
client.addWorkspaceRowBadgeItem({ id: "quiet", title: "Quiet state", icon: "Moon", Component: Quiet });
|
||||
client.addWorkspaceRowBadgeItem({ id: "broken", title: "Broken state", icon: "Bug", Component: Broken });
|
||||
return () => {};
|
||||
}
|
||||
`;
|
||||
|
||||
// The meta line the badges draw on. Each item sits in one wrapper of its own, so the line is two up.
|
||||
const LABELS = { review: "In review", docs: "Docs" } as const;
|
||||
|
||||
function badgeLine(page: Page, itemId: keyof typeof LABELS) {
|
||||
return badge(page, itemId).locator("xpath=../..");
|
||||
}
|
||||
|
||||
function badge(page: Page, itemId: keyof typeof LABELS) {
|
||||
return page.getByLabel(LABELS[itemId], { exact: true }).locator("visible=true");
|
||||
}
|
||||
|
||||
async function toggleShowItem(page: Page, label: string, touch: boolean) {
|
||||
await openSidebarDisplayPage(page, "sidebar-display-show");
|
||||
await page.getByRole("menuitem", { name: label, exact: true }).click();
|
||||
if (touch) {
|
||||
await page
|
||||
.getByRole("button", { name: "Bottom sheet backdrop", exact: true })
|
||||
.click({ position: { x: 5, y: 5 } });
|
||||
} else {
|
||||
await closeSidebarDisplayPreferences(page);
|
||||
}
|
||||
}
|
||||
|
||||
test("a plugin badge shows on workspace rows, hides quietly, and follows its Show toggle", async ({
|
||||
page,
|
||||
}, info) => {
|
||||
test.setTimeout(120_000);
|
||||
const workspace = await seedWorkspace({ repoPrefix: "row-badges-", title: TITLE });
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const config = await client.getDaemonConfig();
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-row-badges-"));
|
||||
try {
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
|
||||
);
|
||||
await writeFile(path.join(directory, "index.client.tsx"), PLUGIN_SOURCE);
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(directory);
|
||||
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await gotoAppShell(page);
|
||||
|
||||
await test.step("the badge draws on the row; a null item and a throwing item draw nothing", async () => {
|
||||
await expect(badge(page, "review")).toBeVisible();
|
||||
await expect(badge(page, "review")).toContainText("In review");
|
||||
// The null and throwing items after them draw nothing, and the line adds nothing of its own.
|
||||
await expect(badgeLine(page, "review")).toHaveText("In reviewDocs");
|
||||
// An icon-only badge is named by its item's title.
|
||||
await expect(
|
||||
page.getByLabel("Icon state", { exact: true }).locator("visible=true"),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByLabel("Empty label state", { exact: true }).locator("visible=true"),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText(TITLE).first()).toBeVisible();
|
||||
await page.screenshot({ path: info.outputPath("wide.png") });
|
||||
});
|
||||
|
||||
await test.step("every item has a Show toggle, and switching one off removes only that badge", async () => {
|
||||
await openSidebarDisplayPage(page, "sidebar-display-show");
|
||||
for (const label of ["Review state", "Quiet state", "Broken state", "Docs state"]) {
|
||||
await expect(page.getByRole("menuitem", { name: label, exact: true })).toBeVisible();
|
||||
}
|
||||
await expect(page.getByTestId("sidebar-plugin-row-badge-items-separator")).toBeVisible();
|
||||
await page.screenshot({ path: info.outputPath("show-menu.png") });
|
||||
await page.getByRole("menuitem", { name: "Review state", exact: true }).click();
|
||||
await closeSidebarDisplayPreferences(page);
|
||||
await expect(badge(page, "review")).toHaveCount(0);
|
||||
// The first badge is gone, so the next one leads the line without a "·".
|
||||
await expect(badgeLine(page, "docs")).toHaveText("Docs");
|
||||
await toggleShowItem(page, "Review state", false);
|
||||
await expect(badge(page, "review")).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("the badge draws on a compact layout", async () => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await openMobileAgentSidebar(page);
|
||||
await expect(badge(page, "review")).toBeVisible();
|
||||
await page.screenshot({ path: info.outputPath("compact.png") });
|
||||
await toggleShowItem(page, "Review state", true);
|
||||
await expect(badge(page, "review")).toHaveCount(0);
|
||||
await toggleShowItem(page, "Review state", true);
|
||||
await expect(badge(page, "review")).toBeVisible();
|
||||
});
|
||||
} finally {
|
||||
await client.removePlugin(PLUGIN_ID).catch(() => undefined);
|
||||
await client.patchDaemonConfig({ pluginsEnabled: config.config.pluginsEnabled });
|
||||
await client.close();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,4 +1,3 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import {
|
||||
buildHostWorkspaceRoute,
|
||||
@@ -7,31 +6,20 @@ import {
|
||||
buildSettingsRoute,
|
||||
buildSettingsSectionRoute,
|
||||
} from "@/utils/host-routes";
|
||||
import { openCompactSidebar } from "../support/helpers/usage-sidebar-item";
|
||||
import { gotoAppShell, openSettings } from "../support/helpers/app";
|
||||
import { getE2EDaemonPort } from "../support/helpers/daemon-port";
|
||||
import {
|
||||
closeCompactSettings,
|
||||
captureCompactSidebarReturn,
|
||||
openSettingsFromCompactSidebar,
|
||||
captureCompactSidebarContext,
|
||||
expectCompactSidebarOpen,
|
||||
closeCompactSidebar,
|
||||
scrollSettingsSidebarToEnd,
|
||||
revealSettingsSidebarSection,
|
||||
expectSettingsSidebarScrollSettled,
|
||||
scrollSettingsSidebarUp,
|
||||
changeSettingsInterfaceFontSize,
|
||||
selectSettingsHostSectionsRapidly,
|
||||
expectSettingsSidebarScrollOffset,
|
||||
scrollSettingsDetailDown,
|
||||
expectSettingsDetailScrollOffset,
|
||||
openSettingsSection,
|
||||
changeSettingsSyntaxTheme,
|
||||
revealSettingsSyntaxTheme,
|
||||
expectSettingsSyntaxColorChanged,
|
||||
changeSettingsTypography,
|
||||
normalizeSettingsFontSizes,
|
||||
changeSettingsInterfaceFontSizeKeepingSidebarPosition,
|
||||
expectSettingsHeader,
|
||||
openAddHostFlow,
|
||||
selectHostConnectionType,
|
||||
@@ -96,32 +84,13 @@ test.describe("Settings sidebar scroll position", () => {
|
||||
await expectSettingsSidebarScrollOffset(page, 0);
|
||||
});
|
||||
|
||||
test("updates appearance without losing the detail scroll or form edits", async ({ page }) => {
|
||||
test("keeps the sidebar position through interface font changes", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsSection(page, "appearance");
|
||||
const { offset, keywordColor } = await revealSettingsSyntaxTheme(page);
|
||||
await changeSettingsSyntaxTheme(page, "Dracula");
|
||||
await expectSettingsDetailScrollOffset(page, offset);
|
||||
await expectSettingsSyntaxColorChanged(page, keywordColor);
|
||||
await changeSettingsTypography(page);
|
||||
});
|
||||
|
||||
test("shows normalized font sizes after committing out-of-range and empty values", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsSection(page, "appearance");
|
||||
await normalizeSettingsFontSizes(page);
|
||||
});
|
||||
|
||||
test("keeps the sidebar context through interface font changes", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsSection(page, "appearance");
|
||||
await scrollSettingsSidebarToEnd(page);
|
||||
const offset = await changeSettingsInterfaceFontSizeKeepingSidebarPosition(page, 20);
|
||||
const offset = await scrollSettingsSidebarToEnd(page);
|
||||
await changeSettingsInterfaceFontSize(page, 20);
|
||||
await expectSettingsSidebarScrollOffset(page, offset);
|
||||
await openSettingsHostSection(page, getServerId(), "providers");
|
||||
await expectSettingsSidebarScrollOffset(page, offset);
|
||||
});
|
||||
@@ -381,87 +350,6 @@ test.describe("Settings sidebar navigation", () => {
|
||||
test.describe("Settings — compact master-detail", () => {
|
||||
test.use({ viewport: { width: 390, height: 844 } });
|
||||
|
||||
test("returning from Settings preserves the open sidebar from the first paint", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}, testInfo) => {
|
||||
const workspace = await withWorkspace({ prefix: "settings-sidebar-continuity-" });
|
||||
for (let i = 0; i < 12; i++) await withWorkspace({ prefix: "settings-sidebar-scroll-" });
|
||||
await openWorkspace(page, workspace);
|
||||
await openCompactSidebar(page);
|
||||
await expectCompactSidebarOpen(page);
|
||||
const expectContextPreserved = await captureCompactSidebarContext(page);
|
||||
const openingFrames = await captureCompactSidebarReturn(page, () =>
|
||||
openSettingsFromCompactSidebar(page),
|
||||
);
|
||||
await writeFile(
|
||||
testInfo.outputPath("sidebar-opening-frames.json"),
|
||||
JSON.stringify(openingFrames, null, 2),
|
||||
);
|
||||
expect(openingFrames.some((frame) => frame.settingsVisible)).toBe(true);
|
||||
expect(openingFrames.every((frame) => frame.sidebarVisible || frame.settingsVisible)).toBe(
|
||||
true,
|
||||
);
|
||||
await openSettingsSection(page, "appearance");
|
||||
await goBackInSettings(page);
|
||||
const frames = await captureCompactSidebarReturn(page, () => closeCompactSettings(page));
|
||||
await writeFile(
|
||||
testInfo.outputPath("sidebar-return-frames.json"),
|
||||
JSON.stringify(frames, null, 2),
|
||||
);
|
||||
await testInfo.attach("sidebar-return-frames", {
|
||||
body: JSON.stringify(frames, null, 2),
|
||||
contentType: "application/json",
|
||||
});
|
||||
const workspaceFrames = frames.filter((frame) => !frame.pathname.startsWith("/settings"));
|
||||
expect(workspaceFrames.length).toBeGreaterThan(0);
|
||||
expect(workspaceFrames.every((frame) => frame.sidebarVisible)).toBe(true);
|
||||
const firstX = workspaceFrames[0]!.closeX;
|
||||
expect(
|
||||
workspaceFrames.every((frame) => Math.abs((frame.closeX ?? Infinity) - firstX!) < 1),
|
||||
).toBe(true);
|
||||
await expectCompactSidebarOpen(page);
|
||||
await expectContextPreserved();
|
||||
await openSettingsFromCompactSidebar(page);
|
||||
const backFrames = await captureCompactSidebarReturn(page, async () => {
|
||||
await page.goBack();
|
||||
});
|
||||
await writeFile(
|
||||
testInfo.outputPath("browser-back-frames.json"),
|
||||
JSON.stringify(backFrames, null, 2),
|
||||
);
|
||||
await testInfo.attach("browser-back-frames", {
|
||||
body: JSON.stringify(backFrames, null, 2),
|
||||
contentType: "application/json",
|
||||
});
|
||||
const backWorkspaceFrames = backFrames.filter(
|
||||
(frame) => !frame.pathname.startsWith("/settings"),
|
||||
);
|
||||
expect(backWorkspaceFrames.length).toBeGreaterThan(0);
|
||||
expect(backWorkspaceFrames.every((frame) => frame.sidebarVisible)).toBe(true);
|
||||
await expectCompactSidebarOpen(page);
|
||||
await closeCompactSidebar(page);
|
||||
});
|
||||
|
||||
test("widening during Settings discards the mobile sidebar return", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const workspace = await withWorkspace({ prefix: "settings-sidebar-resize-" });
|
||||
await openWorkspace(page, workspace);
|
||||
await openCompactSettings(page, buildHostWorkspaceRoute(getServerId(), workspace.workspaceId));
|
||||
await page.setViewportSize({ width: 1000, height: 844 });
|
||||
await expect(page.getByTestId("settings-detail-pane")).toBeVisible();
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await goBackInSettings(page);
|
||||
await expectCompactSettingsList(page);
|
||||
await closeCompactSettings(page);
|
||||
await expect(page.getByRole("button", { name: "Close sidebar", exact: true })).toBeHidden();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Open menu", exact: true }).first(),
|
||||
).toBeInViewport();
|
||||
});
|
||||
|
||||
test("the compact tab switcher reopens after Settings hides its workspace", async ({ page }) => {
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "settings-tab-switcher-",
|
||||
@@ -489,7 +377,6 @@ test.describe("Settings — compact master-detail", () => {
|
||||
await expect(newAgent).toHaveCount(0);
|
||||
|
||||
await closeCompactSettings(page);
|
||||
await closeCompactSidebar(page);
|
||||
await expect(switcher).toBeVisible();
|
||||
await expect(newAgent).toHaveCount(0);
|
||||
await switcher.click();
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
pinWorkspaceFromSidebar,
|
||||
scrollWorkspaceList,
|
||||
sweepPointerAcrossWorkspaceRows,
|
||||
watchWorkspaceHoverCard,
|
||||
} from "../support/helpers/sidebar";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { expectWorkspaceHeader } from "../support/helpers/workspace-ui";
|
||||
@@ -543,6 +544,29 @@ test.describe("Sidebar workspace list", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// On a wide touch screen (iPad) a tap is not a hover. Opening the card on the tap changes the page
|
||||
// under the finger, and iPad Safari then drops the tap instead of opening the workspace.
|
||||
test.describe("Sidebar workspace rows on a touch screen", () => {
|
||||
test.use({ hasTouch: true, viewport: { width: 1180, height: 820 } });
|
||||
|
||||
test("tapping a workspace row opens it without the hover card", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-touch-tap-" });
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarProject(page, path.basename(workspace.repoPath));
|
||||
const row = await waitForSidebarWorkspace(page, workspace.workspaceId);
|
||||
const hoverCard = await watchWorkspaceHoverCard(page);
|
||||
await row.tap();
|
||||
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
|
||||
await expectWorkspaceHoverCardClosed(page);
|
||||
// A card that opens during the tap is what makes iPad Safari drop the tap.
|
||||
expect(await hoverCard.hasOpened()).toBe(false);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Mobile sidebar panelState transition", () => {
|
||||
test.use({ viewport: { width: 390, height: 844 } });
|
||||
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
} from "../support/helpers/usage-login";
|
||||
import {
|
||||
claudeAndCodexReports,
|
||||
closeUsage,
|
||||
expectPinnedUsage,
|
||||
openCompactSidebar,
|
||||
openUsageFromIcon,
|
||||
@@ -80,17 +79,12 @@ function twoHoursAgo(): string {
|
||||
}
|
||||
|
||||
/** Set PASEO_QA_SCREENSHOT_DIR to keep a QA screenshot. */
|
||||
async function qaScreenshot(page: Page, name: string, target: "page" | "summary" = "page") {
|
||||
async function qaScreenshot(page: Page, name: string) {
|
||||
const directory = process.env.PASEO_QA_SCREENSHOT_DIR;
|
||||
if (!directory) return;
|
||||
await page.waitForTimeout(600);
|
||||
await page.addStyleTag({ content: ".__expo_fast_refresh { display: none !important; }" });
|
||||
const file = path.join(directory, `${name}.png`);
|
||||
if (target === "summary") {
|
||||
await usageItem(page).screenshot({ path: file });
|
||||
return;
|
||||
}
|
||||
await page.screenshot({ path: file });
|
||||
await page.screenshot({ path: path.join(directory, `${name}.png`) });
|
||||
}
|
||||
|
||||
function hostFilter(page: Page) {
|
||||
@@ -634,15 +628,6 @@ for (const compact of [false, true]) {
|
||||
await refreshLoginUsage(page);
|
||||
await expectRetainedLoginUsage(page, "Claude: Rate limited");
|
||||
await expectLoginUsageFreshness(page, "Last updated 1h ago", compact);
|
||||
await expectPinnedUsage(page, ["42% Weekly"]);
|
||||
await expect(usageItem(page).getByTestId("sidebar-usage-error")).toBeVisible();
|
||||
await expect(usageItem(page)).toHaveAccessibleName(/Unable to refresh usage/);
|
||||
await qaScreenshot(page, `usage-error-detail-${compact ? "compact" : "desktop"}`);
|
||||
await closeUsage(page);
|
||||
await qaScreenshot(page, `usage-error-summary-${compact ? "compact" : "desktop"}`, "summary");
|
||||
await openUsageFromItem(page);
|
||||
await expectRetainedLoginUsage(page, "Claude: Rate limited");
|
||||
|
||||
await refreshLoginUsage(page);
|
||||
await expectRetainedLoginUsage(page, "Claude: Rate limited");
|
||||
await expectLoginUsageFreshness(page, "Last updated 1h ago", compact);
|
||||
@@ -654,8 +639,6 @@ for (const compact of [false, true]) {
|
||||
await refreshLoginUsage(page);
|
||||
await expectRetainedLoginUsage(page, "Claude: No active quota");
|
||||
await expectLoginUsageFreshness(page, "Last updated 1h ago", compact);
|
||||
await expectPinnedUsage(page, ["42% Weekly"]);
|
||||
await expect(usageItem(page).getByTestId("sidebar-usage-error")).toHaveCount(0);
|
||||
|
||||
await fixture.setReport({
|
||||
status: "available",
|
||||
@@ -664,9 +647,6 @@ for (const compact of [false, true]) {
|
||||
await page.clock.setFixedTime(new Date(Date.now() + 150_000));
|
||||
await refreshLoginUsage(page);
|
||||
await expectLoginUsageOnly(page, "43% used");
|
||||
await expectPinnedUsage(page, ["43% Weekly"]);
|
||||
await expect(usageItem(page).getByTestId("sidebar-usage-error")).toHaveCount(0);
|
||||
await expect(usageItem(page)).not.toHaveAccessibleName(/Unable to refresh usage/);
|
||||
await advanceUsageClock(page, 60_000);
|
||||
await expect(page.getByText("42% used", { exact: true })).toHaveCount(0);
|
||||
await expectLoginUsageFreshness(page, "Updated 2m ago", compact);
|
||||
|
||||
@@ -29,7 +29,6 @@ import {
|
||||
openCompactSidebar,
|
||||
openUsageFromIcon,
|
||||
openUsageFromItem,
|
||||
openUsageFromItemWithPressFeedback,
|
||||
pinRow,
|
||||
scrollUsageToWindow,
|
||||
setSummaryInSidebar,
|
||||
@@ -198,7 +197,7 @@ test.describe("Usage item", () => {
|
||||
});
|
||||
|
||||
await test.step("the summary opens the same sheet", async () => {
|
||||
await openUsageFromItemWithPressFeedback(page);
|
||||
await openUsageFromItem(page);
|
||||
await expect(usageModal(page).getByText("Codex", { exact: true })).toBeVisible();
|
||||
await expect(page).toHaveURL(startingUrl);
|
||||
await qaScreenshot(page, "compact-usage-summary-sheet");
|
||||
|
||||
@@ -136,7 +136,7 @@ async function expectForkFailureWithoutOverlappingStatus(page: Page) {
|
||||
async function observeToastReplacement(page: Page) {
|
||||
const observation = await page
|
||||
.getByRole("alert")
|
||||
.filter({ hasText: "Reconnecting" })
|
||||
.filter({ hasText: "Reconnecting to host" })
|
||||
.evaluateHandle((toast) => {
|
||||
const frames: Array<{ connected: boolean; opacity: number; transform: string }> = [];
|
||||
let frame = 0;
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { clickNewTerminal, gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||
|
||||
// The browser owns Cmd+Shift+brackets for its own tabs, so web keeps
|
||||
// Alt+Shift+brackets on every OS. Desktop bindings live in the desktop suite.
|
||||
test("web switches to the previous and next tab with Alt+Shift+[ and ]", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "tab-relative-web-" });
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
const terminalTabs = page
|
||||
.locator('[data-testid^="workspace-tab-terminal_"]')
|
||||
.filter({ visible: true });
|
||||
await clickNewTerminal(page);
|
||||
await expect(terminalTabs).toHaveCount(1, { timeout: 30_000 });
|
||||
await clickNewTerminal(page);
|
||||
await expect(terminalTabs).toHaveCount(2, { timeout: 30_000 });
|
||||
const first = terminalTabs.nth(0);
|
||||
const second = terminalTabs.nth(1);
|
||||
await expect(second).toHaveAttribute("aria-selected", "true");
|
||||
|
||||
await page.keyboard.press("Alt+Shift+BracketLeft");
|
||||
await expect(first).toHaveAttribute("aria-selected", "true");
|
||||
|
||||
await page.keyboard.press("Alt+Shift+BracketRight");
|
||||
await expect(second).toHaveAttribute("aria-selected", "true");
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -3,7 +3,7 @@ env APP_ID=sh.paseo.debug
|
||||
|
||||
# Start with the sidebar closed. WORKSPACE_ROW is the testID of a visible,
|
||||
# unselected PINNED workspace row. Ordinary workspace rows are not draggable.
|
||||
# Hold past press feedback (130ms) and drag arming (180ms), then release.
|
||||
# Hold past drag arming (180ms), then release.
|
||||
open "${APP_ID}"
|
||||
press "label=\"Open menu\""
|
||||
wait "id=\"sidebar-close\"" 10000
|
||||
@@ -12,7 +12,7 @@ press "id=\"sidebar-close\""
|
||||
wait "label=\"Open menu\"" 5000
|
||||
press "label=\"Open menu\""
|
||||
wait "id=\"${WORKSPACE_ROW}\"" 10000
|
||||
longpress "id=\"${WORKSPACE_ROW}\"" 400
|
||||
longpress "id=\"${WORKSPACE_ROW}\"" 250
|
||||
press "id=\"${WORKSPACE_ROW}\""
|
||||
# A released drag must not swallow the next tap: navigation closes the sidebar.
|
||||
wait "label=\"Open menu\"" 5000
|
||||
|
||||
@@ -85,20 +85,6 @@ agent-device test packages/app/e2e/mobile/modal-sheet/model-tablet.android.ad \
|
||||
--artifacts-dir /tmp/paseo-model-tablet-qa
|
||||
```
|
||||
|
||||
`model-tablet-profile.android.ad` taps the `+` of a model row in New workspace, asserts the seeded
|
||||
profile form opens, and asserts that closing the form leaves the remembered model selected. Run it
|
||||
on the same tablet setup, after `model-tablet.android.ad` or with `MODEL_LABEL` remembered, against a
|
||||
host with no agent profiles: with a profile saved, the picker opens on its root view, which has no
|
||||
Back. Set `PROFILE_MODEL_ID` to a different model on the provider's first page:
|
||||
|
||||
```sh
|
||||
agent-device test packages/app/e2e/mobile/modal-sheet/model-tablet-profile.android.ad \
|
||||
--serial "$ANDROID_SERIAL" --metro-port "$MODEL_QA_METRO_PORT" \
|
||||
--env FORM_URL="paseo://new?serverId=${SHEET_QA_SERVER_ID}" \
|
||||
--env PROVIDER_ID=codex --env MODEL_LABEL="$MODEL_QA_MODEL_LABEL" \
|
||||
--env PROFILE_MODEL_ID="$MODEL_QA_PROFILE_MODEL_ID"
|
||||
```
|
||||
|
||||
The existing-agent journey needs the sidebar pinned so its composer stays narrow. Start each run with no modal open;
|
||||
a failed provider tap leaves its modal open, so dismiss it before the next run. These host-dependent
|
||||
journeys remain outside the default mobile suite, alongside the compact sheet journeys above.
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
# Connected host with MODEL_LABEL selected and no agent profiles, 1600x2560 at density 320. See README.md.
|
||||
# FORM_URL=paseo://new?serverId=<serverId>; PROFILE_MODEL_ID is another catalog model with no profile.
|
||||
context platform=android timeout=60000 retries=0
|
||||
env APP_ID=sh.paseo.debug
|
||||
env PROVIDER_ID=codex
|
||||
|
||||
open "${APP_ID}" "${FORM_URL}"
|
||||
wait "label=\"Workspace project\"" 20000
|
||||
press "id=\"combined-model-selector\""
|
||||
wait "id=\"combobox-desktop-container\"" 10000
|
||||
# A remembered model opens its provider's list. Return to the provider page first.
|
||||
press "label=\"Back\""
|
||||
wait "id=\"model-provider-${PROVIDER_ID}\"" 10000
|
||||
press "id=\"model-provider-${PROVIDER_ID}\""
|
||||
wait "id=\"compact-model-list\"" 3000
|
||||
# The row's profile action sits inside Android's independent scroll viewport.
|
||||
press "id=\"model-create-profile-${PROVIDER_ID}-${PROFILE_MODEL_ID}\""
|
||||
wait "text=\"Add agent profile\"" 5000
|
||||
# Closing the form keeps the remembered model: the tap did not also select the row.
|
||||
back
|
||||
wait "id=\"combined-model-selector\" label=\"Select model (${MODEL_LABEL})\"" 3000
|
||||
close
|
||||
@@ -1,83 +0,0 @@
|
||||
// Record a sidebar toast through timeout on the 2560×1600 Android tablet.
|
||||
// Pass a vertical sample line through the card's plain interior, away from text,
|
||||
// icons, and Android's clipboard preview, plus the visible-to-dismissed interval:
|
||||
// node .../toast-dismissal.mjs recording.mp4 sample-x start-seconds end-seconds
|
||||
// This checks actual native frames; a browser layout test misses this regression.
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
const [, , recording, sampleX, start, end] = process.argv;
|
||||
assert(recording && sampleX && start && end, "Pass recording, sample x, start and end seconds");
|
||||
const { frames } = JSON.parse(
|
||||
execFileSync(
|
||||
"ffprobe",
|
||||
[
|
||||
"-v",
|
||||
"error",
|
||||
"-select_streams",
|
||||
"v",
|
||||
"-show_entries",
|
||||
"frame=best_effort_timestamp_time",
|
||||
"-of",
|
||||
"json",
|
||||
recording,
|
||||
],
|
||||
{ encoding: "utf8" },
|
||||
),
|
||||
);
|
||||
const selectedFrames = frames
|
||||
.map((frame, index) => ({ time: Number(frame.best_effort_timestamp_time), index }))
|
||||
.filter(({ time }) => time >= Number(start) && time <= Number(end));
|
||||
assert(selectedFrames.length > 2, "Capture at least three frames in the supplied interval");
|
||||
const firstFrame = selectedFrames[0].index;
|
||||
const endFrame = selectedFrames.at(-1).index + 1;
|
||||
const bytesPerFrame = 800;
|
||||
// Bound both output and buffer to the requested frames, even for a long recording.
|
||||
const pixels = execFileSync(
|
||||
"ffmpeg",
|
||||
[
|
||||
"-v",
|
||||
"fatal",
|
||||
"-i",
|
||||
recording,
|
||||
"-vf",
|
||||
`trim=start_frame=${firstFrame}:end_frame=${endFrame},crop=2:400:${Number(sampleX)}:1100,format=gray`,
|
||||
"-fps_mode",
|
||||
"passthrough",
|
||||
"-f",
|
||||
"rawvideo",
|
||||
"-",
|
||||
],
|
||||
{ maxBuffer: selectedFrames.length * bytesPerFrame + 1024 },
|
||||
);
|
||||
assert.equal(
|
||||
pixels.length,
|
||||
selectedFrames.length * bytesPerFrame,
|
||||
"Every selected frame must be decoded",
|
||||
);
|
||||
const positions = [];
|
||||
let disappeared = false;
|
||||
for (const [index, { time }] of selectedFrames.entries()) {
|
||||
const column = pixels.subarray(index * bytesPerFrame, (index + 1) * bytesPerFrame);
|
||||
const runs = [];
|
||||
let run = [];
|
||||
for (let y = 0; y < 400; y++) {
|
||||
if (column[y * 2] > 250) run.push(y);
|
||||
else {
|
||||
if (run.length > 40) runs.push(run);
|
||||
run = [];
|
||||
}
|
||||
}
|
||||
if (run.length > 40) runs.push(run);
|
||||
runs.sort((a, b) => b.length - a.length);
|
||||
if (runs.length) {
|
||||
const card = runs[0];
|
||||
positions.push({ time, centre: 1100 + (card[0] + card.at(-1)) / 2 });
|
||||
} else if (positions.length) disappeared = true;
|
||||
}
|
||||
assert(positions.length > 2, "Capture at least three visible card frames");
|
||||
assert(disappeared, "Recording must include the card disappearing");
|
||||
const centres = positions.map(({ centre }) => centre);
|
||||
const shift = Math.max(...centres) - Math.min(...centres);
|
||||
console.log(JSON.stringify({ positions, maximumShiftPixels: shift }, null, 2));
|
||||
assert(shift <= 2, `Toast moved ${shift}px during dismissal`);
|
||||
@@ -505,14 +505,10 @@ export async function expectCreateProfileFromModelRow(
|
||||
page: Page,
|
||||
input: { provider: string; modelId: string; modelLabel: string },
|
||||
): Promise<void> {
|
||||
const action = pickerViewport(page).getByTestId(
|
||||
`model-create-profile-${input.provider}-${input.modelId}`,
|
||||
);
|
||||
// Until its row is hovered the plus is invisible, so a tap on its spot reaches the row.
|
||||
await expect(action).toHaveCSS("pointer-events", "none");
|
||||
await modelRow(page, input).hover();
|
||||
await expect(action).toHaveCSS("pointer-events", "auto");
|
||||
await action.click();
|
||||
await pickerViewport(page)
|
||||
.getByTestId(`model-create-profile-${input.provider}-${input.modelId}`)
|
||||
.click();
|
||||
const modal = editModal(page);
|
||||
await expect(modal).toBeVisible({ timeout: 30_000 });
|
||||
await expect(modal.getByRole("textbox", { name: "Name", exact: true })).toHaveValue(
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface OlderTimelinePagesGate {
|
||||
getRepeatedEntryCount(): number;
|
||||
getOwnedEntryCountContaining(text: string): number;
|
||||
releasePage(pageNumber: number): void;
|
||||
waitForRequestCount(count: number): Promise<void>;
|
||||
}
|
||||
|
||||
export interface DaemonHydrationGate {
|
||||
@@ -370,6 +371,15 @@ export async function holdAgentOlderTimelinePages(
|
||||
const ownedEntries = new Map<string, string>();
|
||||
const releasedPages = new Set<number>();
|
||||
const delayedForwards = new Map<number, Array<() => void>>();
|
||||
const requestWaiters = new Map<number, Array<() => void>>();
|
||||
|
||||
const resolveRequestWaiters = () => {
|
||||
for (const [count, resolvers] of requestWaiters) {
|
||||
if (requestCount < count) continue;
|
||||
requestWaiters.delete(count);
|
||||
for (const resolve of resolvers) resolve();
|
||||
}
|
||||
};
|
||||
|
||||
const routePattern =
|
||||
daemonPort === undefined ? daemonWsRoutePattern() : wsRoutePatternForPort(String(daemonPort));
|
||||
@@ -383,6 +393,7 @@ export async function holdAgentOlderTimelinePages(
|
||||
sessionMessage.direction === "before"
|
||||
) {
|
||||
requestCount += 1;
|
||||
resolveRequestWaiters();
|
||||
}
|
||||
server.send(message);
|
||||
});
|
||||
@@ -428,6 +439,14 @@ export async function holdAgentOlderTimelinePages(
|
||||
for (const forward of delayedForwards.get(pageNumber) ?? []) forward();
|
||||
delayedForwards.delete(pageNumber);
|
||||
},
|
||||
waitForRequestCount(count) {
|
||||
if (requestCount >= count) return Promise.resolve();
|
||||
return new Promise<void>((resolve) => {
|
||||
const resolvers = requestWaiters.get(count) ?? [];
|
||||
resolvers.push(resolve);
|
||||
requestWaiters.set(count, resolvers);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
|
||||
/** The tones a composer toolbar control paints: its glyph's stroke and its label's text color. */
|
||||
export interface ComposerControlTone {
|
||||
/** The visible glyph's stroke, or null when the control paints no glyph. */
|
||||
glyph: string | null;
|
||||
/** The visible label's color, or null when the control is icon-only. */
|
||||
label: string | null;
|
||||
}
|
||||
|
||||
export function composerControl(page: Page, testId: string): Locator {
|
||||
return page.getByTestId(testId).filter({ visible: true }).first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the colors the control actually paints. The toolbar glyph and its label are separate
|
||||
* nodes, so this is the only way to tell whether they match.
|
||||
*/
|
||||
export async function readComposerControlTone(control: Locator): Promise<ComposerControlTone> {
|
||||
return control.evaluate((element) => {
|
||||
const visible = (node: Element) => node.getClientRects().length > 0;
|
||||
const glyph = Array.from(element.querySelectorAll("svg")).find(visible);
|
||||
const label = Array.from(element.querySelectorAll("*"))
|
||||
.filter(visible)
|
||||
.find((node) =>
|
||||
[...node.childNodes].some(
|
||||
(child) => child.nodeType === Node.TEXT_NODE && child.textContent?.trim(),
|
||||
),
|
||||
);
|
||||
return {
|
||||
glyph: glyph ? getComputedStyle(glyph).stroke : null,
|
||||
label: label ? getComputedStyle(label).color : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function expectGlyphMatchesLabel(tone: ComposerControlTone, name: string): void {
|
||||
expect(tone.label, `${name} paints a label`).not.toBeNull();
|
||||
expect(tone.glyph, `${name}'s glyph matches its label`).toBe(tone.label);
|
||||
}
|
||||
|
||||
export function expectIconOnlyGlyph(tone: ComposerControlTone, name: string): void {
|
||||
expect(tone.label, `${name} is icon-only`).toBeNull();
|
||||
expect(tone.glyph, `${name} paints a glyph`).not.toBeNull();
|
||||
}
|
||||
|
||||
/** The color the browser paints in a text input's placeholder. */
|
||||
export async function readComposerPlaceholderTone(input: Locator): Promise<string | null> {
|
||||
return input.evaluate((element) => getComputedStyle(element, "::placeholder").color);
|
||||
}
|
||||
|
||||
/** The color the browser paints for a text node. */
|
||||
export async function readComposerTextTone(text: Locator): Promise<string | null> {
|
||||
return text.evaluate((element) => getComputedStyle(element).color);
|
||||
}
|
||||
@@ -41,6 +41,14 @@ export async function hoverContextWindowMeter(page: Page, name = METER_NAME): Pr
|
||||
return card;
|
||||
}
|
||||
|
||||
/** Taps the meter on a touch screen and returns its details once they are on screen. */
|
||||
export async function tapContextWindowMeter(page: Page, name = METER_NAME): Promise<Locator> {
|
||||
await page.getByRole("img", { name, exact: true }).tap({ timeout: 30_000 });
|
||||
const card = contextWindowDetails(page);
|
||||
await expect(card).toBeVisible();
|
||||
return card;
|
||||
}
|
||||
|
||||
/** Walks the pointer from the meter up onto its hover card, across the gap between them. */
|
||||
export async function moveOntoContextWindowDetails(page: Page, card: Locator): Promise<void> {
|
||||
const meter = await page.getByRole("img", { name: METER_NAME }).boundingBox();
|
||||
|
||||
@@ -142,8 +142,6 @@ export async function createCreationScenario(page: Page) {
|
||||
const requests = await observeCreationRequests(page);
|
||||
const project = await seedWorkspace({ repoPrefix: "creation-idempotency-" });
|
||||
let workspaceId = project.workspaceId;
|
||||
// Workspaces the scenario opens itself to evict the deck; they are not creations under test.
|
||||
const evictionWorkspaceIds = new Set<string>();
|
||||
return {
|
||||
cleanup: project.cleanup,
|
||||
async openWorkspaceForm(isolation: "local" | "worktree") {
|
||||
@@ -234,9 +232,7 @@ export async function createCreationScenario(page: Page) {
|
||||
await requests.settled();
|
||||
const workspaces = (await project.client.fetchWorkspaces()).entries.filter(
|
||||
(workspace) =>
|
||||
workspace.projectId === project.projectId &&
|
||||
workspace.id !== project.workspaceId &&
|
||||
!evictionWorkspaceIds.has(workspace.id),
|
||||
workspace.projectId === project.projectId && workspace.id !== project.workspaceId,
|
||||
);
|
||||
expect(workspaces).toHaveLength(1);
|
||||
workspaceId = workspaces[0]!.id;
|
||||
@@ -280,7 +276,6 @@ export async function createCreationScenario(page: Page) {
|
||||
source: { kind: "directory", path: project.repoPath },
|
||||
});
|
||||
if (!result.workspace) throw new Error(result.error ?? "Failed to seed eviction workspace");
|
||||
evictionWorkspaceIds.add(result.workspace.id);
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId: getServerId(),
|
||||
|
||||
@@ -46,12 +46,3 @@ export async function downloadFileFromExplorer(page: Page, fileName: string): Pr
|
||||
await page.getByRole("menuitem", { name: "Download", exact: true }).click();
|
||||
return downloaded;
|
||||
}
|
||||
|
||||
export async function downloadFileFromPreview(page: Page): Promise<Download> {
|
||||
await expect(page.getByText("Binary preview unavailable", { exact: true })).toBeVisible();
|
||||
const button = page.getByRole("button", { name: "Download", exact: true });
|
||||
await expect(button).toBeVisible();
|
||||
const downloaded = page.waitForEvent("download");
|
||||
await button.click();
|
||||
return downloaded;
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ export async function measureRelayRestartDuringStream(input: {
|
||||
agentTitle: string;
|
||||
}): Promise<RelayDeploymentMeasurements> {
|
||||
const { page, relay, agentTitle } = input;
|
||||
const toast = page.getByRole("alert").filter({ hasText: "Reconnecting" });
|
||||
const toast = page.getByRole("alert").filter({ hasText: "Reconnecting to host" });
|
||||
const beforeOutage = await latestAssistantText(page);
|
||||
await waitForAssistantTextToGrow(page, beforeOutage);
|
||||
|
||||
|
||||
@@ -106,33 +106,6 @@ export async function changeSettingsInterfaceFontSize(page: Page, size: number):
|
||||
await input.fill(String(size));
|
||||
await input.press("Tab");
|
||||
await expect(input).toHaveValue(String(size));
|
||||
await expect(
|
||||
page.getByRole("navigation", { name: "Settings" }).getByText("Appearance", { exact: true }),
|
||||
).toHaveCSS("font-size", `${size}px`);
|
||||
}
|
||||
|
||||
export async function changeSettingsInterfaceFontSizeKeepingSidebarPosition(
|
||||
page: Page,
|
||||
size: number,
|
||||
): Promise<number> {
|
||||
const navigation = page.getByRole("navigation", { name: "Settings" });
|
||||
const anchor = navigation.getByRole("button", { name: "About", exact: true });
|
||||
const before = await anchor.evaluate((node) => ({
|
||||
top: node.getBoundingClientRect().top,
|
||||
height: node.getBoundingClientRect().height,
|
||||
}));
|
||||
await changeSettingsInterfaceFontSize(page, size);
|
||||
// Larger fonts reflow rows. Preserve the visible destination, rather than its old pixel offset.
|
||||
await expect
|
||||
.poll(() =>
|
||||
anchor.evaluate((node, top) => Math.abs(node.getBoundingClientRect().top - top), before.top),
|
||||
)
|
||||
.toBeLessThan(before.height);
|
||||
const offset = await page
|
||||
.getByTestId("settings-sidebar-scroll-body")
|
||||
.evaluate((node) => node.scrollTop);
|
||||
expect(offset).toBeGreaterThan(0);
|
||||
return offset;
|
||||
}
|
||||
|
||||
export async function scrollSettingsSidebarToEnd(page: Page): Promise<number> {
|
||||
@@ -640,192 +613,3 @@ export async function expectRestartRecoveryPrompt(page: Page, prompt: string) {
|
||||
await sheet.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||
await expect(sheet).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function revealSettingsSyntaxTheme(page: Page) {
|
||||
await page.getByLabel(/Highlight theme:/).scrollIntoViewIfNeeded();
|
||||
const offset = await page
|
||||
.getByTestId("settings-detail-scroll-body")
|
||||
.evaluate((node) => node.scrollTop);
|
||||
expect(offset).toBeGreaterThan(0);
|
||||
const keywordColor = await page
|
||||
.getByRole("img", { name: "Live preview of content typography, syntax theme, and code font" })
|
||||
.getByText("export", { exact: true })
|
||||
.first()
|
||||
.evaluate((node) => getComputedStyle(node).color);
|
||||
return { offset, keywordColor };
|
||||
}
|
||||
|
||||
export async function changeSettingsSyntaxTheme(page: Page, theme: string): Promise<void> {
|
||||
await page.getByLabel(/Highlight theme:/).click();
|
||||
await page.getByRole("menuitem", { name: theme, exact: true }).click();
|
||||
await expect(page.getByLabel(`Highlight theme: ${theme}`, { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectSettingsSyntaxColorChanged(
|
||||
page: Page,
|
||||
previousColor: string,
|
||||
): Promise<void> {
|
||||
await expect(
|
||||
page
|
||||
.getByRole("img", { name: "Live preview of content typography, syntax theme, and code font" })
|
||||
.getByText("export", { exact: true })
|
||||
.first(),
|
||||
).not.toHaveCSS("color", previousColor);
|
||||
}
|
||||
|
||||
export async function changeSettingsTypography(page: Page): Promise<void> {
|
||||
const input = page.getByRole("textbox", { name: "Interface font size", exact: true });
|
||||
await input.fill("18");
|
||||
await input.press("Enter");
|
||||
await expect(input).toHaveValue("18");
|
||||
await expect(input).toHaveCSS("font-size", "18px");
|
||||
await expect(page.getByText("Code size", { exact: true })).toHaveCSS("font-size", "18px");
|
||||
const navigation = page.getByRole("navigation", { name: "Settings" });
|
||||
await expect(navigation.getByText("Appearance", { exact: true })).toHaveCSS("font-size", "18px");
|
||||
await expect(navigation.getByText("Providers", { exact: true })).toHaveCSS("font-size", "18px");
|
||||
await expect(page.getByText("Dracula", { exact: true })).toHaveCSS("font-size", "18px");
|
||||
const familyInput = page.getByRole("textbox", { name: "Code font family", exact: true });
|
||||
await familyInput.fill("Courier New, Courier, monospace");
|
||||
await familyInput.press("Enter");
|
||||
const codeInput = page.getByRole("textbox", { name: "Code font size", exact: true });
|
||||
await codeInput.fill("16");
|
||||
await codeInput.press("Enter");
|
||||
await expect(codeInput).toHaveValue("16");
|
||||
const preview = page.getByRole("img", {
|
||||
name: "Live preview of content typography, syntax theme, and code font",
|
||||
});
|
||||
await expect(preview.getByText("export", { exact: true }).first()).toHaveCSS("font-size", "16px");
|
||||
await expect(preview.getByText("export", { exact: true }).first()).toHaveCSS(
|
||||
"line-height",
|
||||
"24px",
|
||||
);
|
||||
await expect(preview.getByText("export", { exact: true }).first()).toHaveCSS(
|
||||
"font-family",
|
||||
/Courier/,
|
||||
);
|
||||
const contentInput = page.getByRole("textbox", { name: "Content font size", exact: true });
|
||||
await contentInput.fill("20");
|
||||
await contentInput.press("Enter");
|
||||
await expect(preview.getByText("Readable content and code preview", { exact: true })).toHaveCSS(
|
||||
"font-size",
|
||||
"20px",
|
||||
);
|
||||
}
|
||||
|
||||
export async function normalizeSettingsFontSizes(page: Page): Promise<void> {
|
||||
for (const [name, minimum, maximum] of [
|
||||
["Interface font size", "10", "21"],
|
||||
["Content font size", "10", "21"],
|
||||
["Code font size", "9", "22"],
|
||||
] as const) {
|
||||
const input = page.getByRole("textbox", { name, exact: true });
|
||||
await input.fill("999");
|
||||
await input.press("Enter");
|
||||
await expect(input).toHaveValue(maximum);
|
||||
// Clamp again when the saved value is already at the maximum.
|
||||
await input.fill("999");
|
||||
await input.press("Tab");
|
||||
await expect(input).toHaveValue(maximum);
|
||||
await input.fill("");
|
||||
await input.press("Enter");
|
||||
await expect(input).toHaveValue(maximum);
|
||||
await input.fill("1");
|
||||
await input.press("Enter");
|
||||
await expect(input).toHaveValue(minimum);
|
||||
await input.fill("1");
|
||||
await input.press("Tab");
|
||||
await expect(input).toHaveValue(minimum);
|
||||
}
|
||||
}
|
||||
|
||||
/** Sample actual paints, including the first workspace frame after Settings. */
|
||||
export async function captureCompactSidebarReturn(page: Page, returnAction: () => Promise<void>) {
|
||||
await page.evaluate(() => {
|
||||
const capture = {
|
||||
frames: [] as {
|
||||
time: number;
|
||||
pathname: string;
|
||||
sidebarVisible: boolean;
|
||||
settingsVisible: boolean;
|
||||
closeX: number | null;
|
||||
}[],
|
||||
stopped: false,
|
||||
};
|
||||
Object.assign(window, { __sidebarFrameCapture: capture });
|
||||
const start = performance.now();
|
||||
function sample() {
|
||||
if (capture.stopped) return;
|
||||
const close = document.querySelector('[data-testid="sidebar-close"]');
|
||||
capture.frames.push({
|
||||
time: performance.now() - start,
|
||||
pathname: location.pathname,
|
||||
settingsVisible:
|
||||
document
|
||||
.querySelector('[data-testid="settings-sidebar"]')
|
||||
?.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true }) ?? false,
|
||||
sidebarVisible:
|
||||
(close?.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true }) ?? false) &&
|
||||
close!.getBoundingClientRect().x >= 0 &&
|
||||
close!.getBoundingClientRect().right <= innerWidth,
|
||||
closeX: close?.getBoundingClientRect().x ?? null,
|
||||
});
|
||||
requestAnimationFrame(sample);
|
||||
}
|
||||
requestAnimationFrame(sample);
|
||||
});
|
||||
await returnAction();
|
||||
return page.evaluate(async () => {
|
||||
const capture = (
|
||||
window as typeof window & {
|
||||
__sidebarFrameCapture: {
|
||||
frames: {
|
||||
time: number;
|
||||
pathname: string;
|
||||
sidebarVisible: boolean;
|
||||
settingsVisible: boolean;
|
||||
closeX: number | null;
|
||||
}[];
|
||||
stopped: boolean;
|
||||
};
|
||||
}
|
||||
).__sidebarFrameCapture;
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 1500));
|
||||
capture.stopped = true;
|
||||
return capture.frames;
|
||||
});
|
||||
}
|
||||
|
||||
export async function captureCompactSidebarContext(page: Page) {
|
||||
const sidebar = await page.getByTestId("sidebar-project-workspace-list-scroll").elementHandle();
|
||||
if (!sidebar) throw new Error("Open sidebar has no workspace list");
|
||||
await expect
|
||||
.poll(() => sidebar.evaluate((el) => el.scrollHeight - el.clientHeight))
|
||||
.toBeGreaterThan(0);
|
||||
const offset = await sidebar.evaluate((el) => {
|
||||
el.scrollTop = el.scrollHeight;
|
||||
return el.scrollTop;
|
||||
});
|
||||
expect(offset).toBeGreaterThan(0);
|
||||
return async () => {
|
||||
expect(await sidebar.evaluate((el) => el.isConnected)).toBe(true);
|
||||
expect(await sidebar.evaluate((el) => el.scrollTop)).toBe(offset);
|
||||
await sidebar.dispose();
|
||||
};
|
||||
}
|
||||
|
||||
export async function expectCompactSidebarOpen(page: Page) {
|
||||
await expect(page.getByRole("button", { name: "Close sidebar", exact: true })).toBeInViewport();
|
||||
}
|
||||
|
||||
export async function closeCompactSidebar(page: Page) {
|
||||
await page.getByRole("button", { name: "Close sidebar", exact: true }).click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Open menu", exact: true }).first(),
|
||||
).toBeInViewport();
|
||||
}
|
||||
|
||||
export async function openSettingsFromCompactSidebar(page: Page) {
|
||||
await page.getByRole("button", { name: "Settings", exact: true }).click();
|
||||
await expectAppRoute(page, buildSettingsRoute());
|
||||
await expectCompactSettingsList(page);
|
||||
}
|
||||
|
||||
@@ -132,6 +132,31 @@ export async function expectWorkspaceHoverCardClosed(page: Page): Promise<void>
|
||||
await expect(page.getByTestId("workspace-hover-card")).toHaveCount(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Records whether a workspace hover card mounts at any point from now on, including one that
|
||||
* closes again before an assertion could see it.
|
||||
*/
|
||||
export async function watchWorkspaceHoverCard(
|
||||
page: Page,
|
||||
): Promise<{ hasOpened: () => Promise<boolean> }> {
|
||||
await page.evaluate(() => {
|
||||
const target = window as unknown as { __workspaceHoverCardOpened?: boolean };
|
||||
const isOpen = () => document.querySelector('[data-testid="workspace-hover-card"]') !== null;
|
||||
target.__workspaceHoverCardOpened = isOpen();
|
||||
new MutationObserver(() => {
|
||||
if (isOpen()) target.__workspaceHoverCardOpened = true;
|
||||
}).observe(document.body, { childList: true, subtree: true });
|
||||
});
|
||||
return {
|
||||
hasOpened: () =>
|
||||
page.evaluate(
|
||||
() =>
|
||||
(window as unknown as { __workspaceHoverCardOpened?: boolean })
|
||||
.__workspaceHoverCardOpened === true,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export async function closeWorkspaceContextMenu(page: Page, workspaceId: string): Promise<void> {
|
||||
const workspaceKey = `${getServerId()}:${workspaceId}`;
|
||||
await page.getByTestId(`sidebar-workspace-context-menu-${workspaceKey}-backdrop`).click();
|
||||
|
||||
@@ -427,11 +427,7 @@ export async function holdOlderHistoryPages(
|
||||
const loader = page.getByTestId("load-older-history-spinner");
|
||||
return {
|
||||
async expectRequestedPages(count) {
|
||||
await expect
|
||||
.poll(() => gate.getRequestCount(), {
|
||||
message: `older history requests ${count} page(s)`,
|
||||
})
|
||||
.toBeGreaterThanOrEqual(count);
|
||||
await gate.waitForRequestCount(count);
|
||||
expect(gate.getRequestCount()).toBe(count);
|
||||
},
|
||||
async expectSettledWithRequestedPages(count) {
|
||||
|
||||
@@ -200,18 +200,6 @@ export async function openUsageFromItem(page: Page): Promise<void> {
|
||||
await expect(usageModal(page)).toBeVisible();
|
||||
}
|
||||
|
||||
export async function openUsageFromItemWithPressFeedback(page: Page): Promise<void> {
|
||||
const summary = page.getByRole("button", { name: /^Usage:/ });
|
||||
await summary.hover();
|
||||
const hoveredFill = await summary.evaluate(
|
||||
(element) => getComputedStyle(element).backgroundColor,
|
||||
);
|
||||
await page.mouse.down();
|
||||
await expect(summary).not.toHaveCSS("background-color", hoveredFill);
|
||||
await page.mouse.up();
|
||||
await expect(usageModal(page)).toBeVisible();
|
||||
}
|
||||
|
||||
/** Scrolls the Usage modal's body with the mouse wheel, as a user does. */
|
||||
export async function scrollUsage(page: Page, deltaY: number): Promise<void> {
|
||||
const body = await usageModal(page).boundingBox();
|
||||
|
||||
@@ -109,18 +109,18 @@ export async function expectReconnectingToastVisible(
|
||||
page: Page,
|
||||
options?: { timeout?: number },
|
||||
): Promise<void> {
|
||||
const toast = page.getByRole("alert").filter({ hasText: "Reconnecting" });
|
||||
const toast = page.getByRole("alert").filter({ hasText: "Reconnecting to host" });
|
||||
await expect(toast).toBeVisible({
|
||||
timeout: options?.timeout ?? 30_000,
|
||||
});
|
||||
await expect(toast).toHaveText("Reconnecting");
|
||||
await expect(toast).toHaveText("Reconnecting to host");
|
||||
}
|
||||
|
||||
export async function expectReconnectingToastGone(
|
||||
page: Page,
|
||||
options?: { timeout?: number },
|
||||
): Promise<void> {
|
||||
await expect(page.getByRole("alert").filter({ hasText: "Reconnecting" })).toHaveCount(0, {
|
||||
await expect(page.getByRole("alert").filter({ hasText: "Reconnecting to host" })).toHaveCount(0, {
|
||||
timeout: options?.timeout ?? 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -18,9 +18,9 @@ maestro test packages/app/maestro/ios-sidebar-close-regression.yaml --udid <simu
|
||||
|
||||
`test-sidebar-drag-cancellation-ios.sh` creates three temporary projects and
|
||||
uses real native slow swipes to cover successful reorder, a second touch during
|
||||
the drop spring, a horizontal swipe after the lift that keeps the panel open
|
||||
and the order unchanged, outer-list scroll recovery, and a subsequent reorder.
|
||||
The harness removes its daemon projects and local fixture directories on exit.
|
||||
the drop spring, horizontal close cancellation, outer-list scroll recovery, and
|
||||
a subsequent reorder. The harness removes its daemon projects and local fixture
|
||||
directories on exit.
|
||||
|
||||
Start the debug dev client against the intended Metro server, then run:
|
||||
|
||||
|
||||
@@ -49,22 +49,29 @@ appId: ${PASEO_MAESTRO_APP_ID}
|
||||
above:
|
||||
text: ".*${PASEO_MAESTRO_DRAG_A_NAME}, Create a new workspace for ${PASEO_MAESTRO_DRAG_A_NAME}, Project actions"
|
||||
|
||||
# Horizontal movement after the lift stays with the lifted row: the panel does
|
||||
# not close, the drop commits no reorder, and the outer list stays scrollable.
|
||||
# Horizontal intent after the lift belongs to panel close. It must cancel the
|
||||
# drag without committing another reorder or leaving the outer list locked.
|
||||
- swipe:
|
||||
from:
|
||||
text: ".*${PASEO_MAESTRO_DRAG_B_NAME}, Create a new workspace for ${PASEO_MAESTRO_DRAG_B_NAME}, Project actions"
|
||||
direction: LEFT
|
||||
duration: 20000
|
||||
- assertVisible:
|
||||
- assertNotVisible:
|
||||
id: "sidebar-project-list"
|
||||
|
||||
- tapOn:
|
||||
id: "menu-button"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: "sidebar-project-list"
|
||||
timeout: 10000
|
||||
- assertVisible:
|
||||
text: ".*${PASEO_MAESTRO_DRAG_B_NAME}, Create a new workspace for ${PASEO_MAESTRO_DRAG_B_NAME}, Project actions"
|
||||
above:
|
||||
text: ".*${PASEO_MAESTRO_DRAG_A_NAME}, Create a new workspace for ${PASEO_MAESTRO_DRAG_A_NAME}, Project actions"
|
||||
|
||||
# Reaching a fixture at the bottom proves the outer scroll still works after
|
||||
# the horizontal drag.
|
||||
# Reaching a fixture at the bottom proves the nestable outer scroll was
|
||||
# unlocked by cancellation.
|
||||
- scrollUntilVisible:
|
||||
element:
|
||||
text: ".*${PASEO_MAESTRO_DRAG_Z_NAME}, Create a new workspace for ${PASEO_MAESTRO_DRAG_Z_NAME}, Project actions"
|
||||
@@ -85,7 +92,7 @@ appId: ${PASEO_MAESTRO_APP_ID}
|
||||
visibilityPercentage: 50
|
||||
centerElement: true
|
||||
|
||||
# A real reorder after the horizontal drag proves the list is not merely scrollable;
|
||||
# A real reorder after cancellation proves the list is not merely scrollable;
|
||||
# its drag recognizer and semantic order update both recovered.
|
||||
- swipe:
|
||||
from:
|
||||
|
||||
@@ -8,7 +8,6 @@ private let hardwareShortcutEventName = "onHardwareKeyboardShortcut"
|
||||
private weak var activeModule: PaseoHardwareKeyboardModule?
|
||||
private var isHardwareSubmitEnabled = false
|
||||
private var overlayKeys: Set<String> = []
|
||||
private var claimedShortcuts: [ClaimedShortcut] = []
|
||||
|
||||
/// UIKit spells non-character keys as sentinel input strings rather than as the
|
||||
/// character they would type. JS sends the DOM `code` and the mapping lives
|
||||
@@ -20,42 +19,6 @@ private let namedKeyInputs: [String: String] = [
|
||||
"Enter": "\r",
|
||||
]
|
||||
|
||||
/// A shortcut the window takes from the focused text control: Shift+Tab would
|
||||
/// otherwise insert a tab, and Option+Shift+[ would type a quote.
|
||||
private struct ClaimedShortcut {
|
||||
let input: String
|
||||
let modifierFlags: UIKeyModifierFlags
|
||||
}
|
||||
|
||||
struct ClaimedShortcutRecord: Record {
|
||||
@Field var code: String = ""
|
||||
@Field var shift: Bool = false
|
||||
@Field var alt: Bool = false
|
||||
}
|
||||
|
||||
private let claimableKeyInputs: [String: String] = [
|
||||
"Tab": "\t", "Space": " ", "Enter": "\r", "Escape": UIKeyCommand.inputEscape,
|
||||
"ArrowUp": UIKeyCommand.inputUpArrow, "ArrowDown": UIKeyCommand.inputDownArrow,
|
||||
"ArrowLeft": UIKeyCommand.inputLeftArrow, "ArrowRight": UIKeyCommand.inputRightArrow,
|
||||
"Minus": "-", "Equal": "=", "BracketLeft": "[", "BracketRight": "]", "Backslash": "\\",
|
||||
"Semicolon": ";", "Quote": "'", "Backquote": "`", "Comma": ",", "Period": ".", "Slash": "/",
|
||||
]
|
||||
|
||||
/// UIKit matches a key command against the unmodified character, so
|
||||
/// Option+Shift+[ is registered as "[" with both modifier flags.
|
||||
private func claimedShortcut(_ record: ClaimedShortcutRecord) -> ClaimedShortcut? {
|
||||
let code = record.code
|
||||
let input: String?
|
||||
if code.count == 4 && code.hasPrefix("Key") { input = code.suffix(1).lowercased() }
|
||||
else if code.count == 6 && code.hasPrefix("Digit") { input = String(code.suffix(1)) }
|
||||
else { input = claimableKeyInputs[code] }
|
||||
guard let input else { return nil }
|
||||
var modifierFlags: UIKeyModifierFlags = []
|
||||
if record.shift { modifierFlags.insert(.shift) }
|
||||
if record.alt { modifierFlags.insert(.alternate) }
|
||||
return ClaimedShortcut(input: input, modifierFlags: modifierFlags)
|
||||
}
|
||||
|
||||
/// Whether the first responder is mid-IME-composition.
|
||||
///
|
||||
/// A committing key has to stay out of the way while a CJK candidate is being
|
||||
@@ -99,14 +62,6 @@ public class PaseoHardwareKeyboardModule: Module {
|
||||
}
|
||||
}
|
||||
|
||||
Function("setClaimedShortcuts") { (shortcuts: [ClaimedShortcutRecord]) in
|
||||
let claimed = shortcuts.compactMap(claimedShortcut)
|
||||
DispatchQueue.main.async {
|
||||
claimedShortcuts = claimed
|
||||
UIMenuSystem.main.setNeedsRebuild()
|
||||
}
|
||||
}
|
||||
|
||||
OnDestroy {
|
||||
self.keyboardObservers.forEach(NotificationCenter.default.removeObserver)
|
||||
self.keyboardObservers = []
|
||||
@@ -114,7 +69,6 @@ public class PaseoHardwareKeyboardModule: Module {
|
||||
activeModule = nil
|
||||
isHardwareSubmitEnabled = false
|
||||
overlayKeys = []
|
||||
claimedShortcuts = []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -185,10 +139,7 @@ public final class PaseoHardwareKeyboardWindow: UIWindow {
|
||||
{
|
||||
commands.append(makeSubmitCommand())
|
||||
}
|
||||
if !isComposingText() {
|
||||
commands.append(contentsOf: overlayKeys.compactMap(makeShortcutCommand))
|
||||
commands.append(contentsOf: claimedShortcuts.map(makeClaimedCommand))
|
||||
}
|
||||
if !isComposingText() { commands.append(contentsOf: overlayKeys.compactMap(makeShortcutCommand)) }
|
||||
return commands
|
||||
}
|
||||
|
||||
@@ -221,18 +172,6 @@ public final class PaseoHardwareKeyboardWindow: UIWindow {
|
||||
return command
|
||||
}
|
||||
|
||||
/// The window's press stream still emits the shortcut; the command only keeps
|
||||
/// the key away from the text control.
|
||||
private func makeClaimedCommand(_ shortcut: ClaimedShortcut) -> UIKeyCommand {
|
||||
let command = UIKeyCommand(
|
||||
input: shortcut.input,
|
||||
modifierFlags: shortcut.modifierFlags,
|
||||
action: #selector(handleHardwareKeyboardShortcut(_:))
|
||||
)
|
||||
if #available(iOS 15.0, *) { command.wantsPriorityOverSystemBehavior = true }
|
||||
return command
|
||||
}
|
||||
|
||||
@objc
|
||||
private func handleHardwareKeyboardSubmit(_ sender: UIKeyCommand) {
|
||||
guard canSubmitCurrentTextInput() else {
|
||||
|
||||
@@ -122,6 +122,7 @@
|
||||
"react-freeze": "1.0.4",
|
||||
"react-i18next": "^17.0.8",
|
||||
"react-native": "0.86.3",
|
||||
"react-native-draggable-flatlist": "^4.0.3",
|
||||
"react-native-edge-to-edge": "^1.7.0",
|
||||
"react-native-enriched-markdown": "1.2.0-nightly-20261014-766fc0e67",
|
||||
"react-native-gesture-handler": "~2.32.0",
|
||||
@@ -130,7 +131,6 @@
|
||||
"react-native-reanimated": "4.5.5",
|
||||
"react-native-safe-area-context": "~5.7.0",
|
||||
"react-native-screens": "~4.26.0",
|
||||
"react-native-sortables": "^1.10.1",
|
||||
"react-native-svg": "15.15.4",
|
||||
"react-native-unistyles": "^3.2.4",
|
||||
"react-native-web": "~0.21.0",
|
||||
|
||||
@@ -81,14 +81,12 @@
|
||||
|
||||
/* Suppress the browser's default focus outline — it appears on mouse
|
||||
clicks and looks out of place against our themed surfaces. Keep a
|
||||
themed ring for keyboard users via :focus-visible. The ring is the active
|
||||
theme's `ring` color, exposed by Unistyles as a CSS variable; the fallback
|
||||
only shows before the theme mounts. */
|
||||
themed ring for keyboard users via :focus-visible. */
|
||||
*:focus {
|
||||
outline: none;
|
||||
}
|
||||
*:focus-visible {
|
||||
outline: 2px solid var(--colors-ring, #20744a);
|
||||
outline: 2px solid #20744a;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
</style>
|
||||
|
||||
+6
-4
@@ -5,7 +5,7 @@ import equal from "fast-deep-equal";
|
||||
import { normalizeWorkspacePath } from "@/utils/workspace-identity";
|
||||
|
||||
export const AGENT_COMMANDS_QUERY_ROOT = "agentCommands";
|
||||
export const agentCommandQueryPolicy = {
|
||||
export const sessionCommandQueryPolicy = {
|
||||
staleTime: 600_000,
|
||||
gcTime: 1_800_000,
|
||||
refetchOnMount: true,
|
||||
@@ -102,12 +102,14 @@ export function draftAgentCommandsQueryKey(input: {
|
||||
] as const;
|
||||
}
|
||||
|
||||
// Refresh checkout skills after branch changes while keeping the previous catalog usable.
|
||||
export function invalidateDraftAgentCommandsForCheckout(
|
||||
// Draft commands include project skills read from the checkout. When the checkout moves to
|
||||
// another branch the cached list describes files that are gone, so it is dropped rather than
|
||||
// shown while a refetch runs.
|
||||
export function resetDraftAgentCommandsForCheckout(
|
||||
queryClient: QueryClient,
|
||||
input: { serverId: string; cwd: string },
|
||||
): Promise<void> {
|
||||
return queryClient.invalidateQueries({ queryKey: draftAgentCommandsCheckoutScope(input) });
|
||||
return queryClient.resetQueries({ queryKey: draftAgentCommandsCheckoutScope(input) });
|
||||
}
|
||||
|
||||
export function agentCommandsQueryKey(input: {
|
||||
|
||||
@@ -2,13 +2,12 @@ import { describe, expect, it } from "vitest";
|
||||
import { QueryClient, QueryObserver } from "@tanstack/react-query";
|
||||
import { fetchQueryOptions } from "@/data/query";
|
||||
import {
|
||||
invalidateDraftAgentCommandsForCheckout,
|
||||
agentCommandsQueryKey,
|
||||
agentCommandsQueryRoot,
|
||||
draftAgentCommandsQueryKey,
|
||||
normalizeAgentCommandsCwd,
|
||||
sessionAgentCommandsQueryKey,
|
||||
agentCommandQueryPolicy,
|
||||
sessionCommandQueryPolicy,
|
||||
} from "./cache";
|
||||
|
||||
it("reuses fresh commands and keeps stale commands during a shared refresh and failure", async () => {
|
||||
@@ -21,8 +20,8 @@ it("reuses fresh commands and keeps stale commands during a shared refresh and f
|
||||
resolveRefresh = resolve;
|
||||
});
|
||||
const options = fetchQueryOptions({
|
||||
...agentCommandQueryPolicy,
|
||||
staleTimeMs: agentCommandQueryPolicy.staleTime,
|
||||
...sessionCommandQueryPolicy,
|
||||
staleTimeMs: sessionCommandQueryPolicy.staleTime,
|
||||
dataShape: "value",
|
||||
queryKey: key,
|
||||
retry: false,
|
||||
@@ -125,35 +124,3 @@ describe("agent command query keys", () => {
|
||||
expect(agentCommandsQueryRoot("server-1")).toEqual(["agentCommands", "server-1"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("retains draft commands while checkout invalidation refreshes the catalog", async () => {
|
||||
const client = new QueryClient();
|
||||
const key = draftAgentCommandsQueryKey({
|
||||
serverId: "s",
|
||||
draftConfig: { provider: "codex", cwd: "/repo" },
|
||||
});
|
||||
client.setQueryData(key, ["old"]);
|
||||
let finish: (commands: string[]) => void = () => {};
|
||||
const refresh = new Promise<string[]>((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
const observer = new QueryObserver(
|
||||
client,
|
||||
fetchQueryOptions({
|
||||
queryKey: key,
|
||||
dataShape: "value",
|
||||
immutableWhen: () => true,
|
||||
queryFn: () => refresh,
|
||||
}),
|
||||
);
|
||||
const unsubscribe = observer.subscribe(() => {});
|
||||
const updating = invalidateDraftAgentCommandsForCheckout(client, { serverId: "s", cwd: "/repo" });
|
||||
expect(observer.getCurrentResult().data).toEqual(["old"]);
|
||||
expect(observer.getCurrentResult().isPending).toBe(false);
|
||||
expect(observer.getCurrentResult().isFetching).toBe(true);
|
||||
finish(["new"]);
|
||||
await updating;
|
||||
expect(observer.getCurrentResult().data).toEqual(["new"]);
|
||||
unsubscribe();
|
||||
client.clear();
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useShallow } from "zustand/shallow";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import {
|
||||
agentCommandsQueryKey,
|
||||
agentCommandQueryPolicy,
|
||||
sessionCommandQueryPolicy,
|
||||
type AgentCommandsDraftConfig,
|
||||
} from "./cache";
|
||||
|
||||
@@ -92,8 +92,9 @@ export function useAgentCommandsQuery({
|
||||
agentCommandProvider: draftConfig?.provider ?? provider,
|
||||
agentCommandCwd: draftConfig?.cwd ?? cwd,
|
||||
},
|
||||
...agentCommandQueryPolicy,
|
||||
staleTimeMs: agentCommandQueryPolicy.staleTime,
|
||||
...(draftConfig
|
||||
? { immutableWhen: () => true as const }
|
||||
: { ...sessionCommandQueryPolicy, staleTimeMs: sessionCommandQueryPolicy.staleTime }),
|
||||
subscribed: queryEnabled,
|
||||
retry: 3,
|
||||
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 5000),
|
||||
|
||||
@@ -49,12 +49,7 @@ afterEach(() => {
|
||||
host.remove();
|
||||
});
|
||||
|
||||
type OlderHistoryInput = Pick<
|
||||
StreamRenderInput,
|
||||
"onNearHistoryStart" | "hasOlderHistory" | "olderHistoryProgressKey"
|
||||
>;
|
||||
|
||||
function mountTimeline(historyMounted: StreamItem[], olderHistory?: OlderHistoryInput) {
|
||||
function mountTimeline(historyMounted: StreamItem[]) {
|
||||
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
|
||||
const viewportRef = React.createRef<StreamViewportHandle>();
|
||||
const input: StreamRenderInput = {
|
||||
@@ -81,7 +76,6 @@ function mountTimeline(historyMounted: StreamItem[], olderHistory?: OlderHistory
|
||||
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 } }));
|
||||
@@ -106,11 +100,6 @@ function mountTimeline(historyMounted: StreamItem[], olderHistory?: OlderHistory
|
||||
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,
|
||||
// Resize observers and the hold's settle run on animation frames.
|
||||
@@ -219,30 +208,3 @@ describe("web disclosure hold", () => {
|
||||
expect(timeline.distanceFromEnd()).toBeLessThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("web history start", () => {
|
||||
it("loads older history when an upward wheel lands at the start after the reader stopped following", async () => {
|
||||
let olderHistoryLoads = 0;
|
||||
const history = [1, 2, 3, 4, 5, 6].map((index) => row(index));
|
||||
const timeline = mountTimeline(history, {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -605,15 +605,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
window.performance.now() + UPWARD_INPUT_EVIDENCE_TIMEOUT_MS;
|
||||
}, []);
|
||||
|
||||
const hasUpwardInputEvidence = useCallback(() => {
|
||||
const mouseGesture = mouseScrollGestureRef.current;
|
||||
return (
|
||||
mouseGesture?.kind === "scrollbar" ||
|
||||
(mouseGesture?.kind === "autoscroll" && mouseGesture.hasUpwardEvidence) ||
|
||||
window.performance.now() < upwardInputEvidenceUntilRef.current
|
||||
);
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (isActive) {
|
||||
return;
|
||||
@@ -800,16 +791,16 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - USER_SCROLL_DELTA_EPSILON;
|
||||
const scrolledDown =
|
||||
currentScrollTop > lastKnownScrollTopRef.current + USER_SCROLL_DELTA_EPSILON;
|
||||
const userScrolledUp = scrolledUp && hasUpwardInputEvidence();
|
||||
const mouseGesture = mouseScrollGestureRef.current;
|
||||
const hasUpwardInputEvidence =
|
||||
mouseGesture?.kind === "scrollbar" ||
|
||||
(mouseGesture?.kind === "autoscroll" && mouseGesture.hasUpwardEvidence) ||
|
||||
window.performance.now() < upwardInputEvidenceUntilRef.current;
|
||||
|
||||
if (!followOutputRef.current && isAtBottom && scrolledDown && !isJumpSettling()) {
|
||||
setFollowOutput(true);
|
||||
} else if (followOutputRef.current && userScrolledUp) {
|
||||
} else if (followOutputRef.current && scrolledUp && hasUpwardInputEvidence) {
|
||||
stopFollowingOutputFromUserIntent();
|
||||
} else if (userScrolledUp) {
|
||||
// Wheel listeners can run before or after the browser applies the scroll, so the
|
||||
// upward scroll itself re-arms loading when it reaches the start.
|
||||
rearmHistoryStartFromUserIntent();
|
||||
}
|
||||
|
||||
lastKnownScrollTopRef.current = currentScrollTop;
|
||||
@@ -820,10 +811,8 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
evaluateHistoryStart();
|
||||
}, [
|
||||
evaluateHistoryStart,
|
||||
hasUpwardInputEvidence,
|
||||
isJumpSettling,
|
||||
readingAnchor,
|
||||
rearmHistoryStartFromUserIntent,
|
||||
stopFollowingOutputFromUserIntent,
|
||||
updateScrollMetrics,
|
||||
]);
|
||||
|
||||
@@ -87,10 +87,7 @@ import {
|
||||
type StartupBlocker,
|
||||
} from "@/navigation/host-runtime-bootstrap";
|
||||
import { registerWorkspaceRouteNavigationRef } from "@/navigation/workspace-route-navigation";
|
||||
import {
|
||||
useSettingsRouteEntry,
|
||||
useSettingsSidebarPresentation,
|
||||
} from "@/navigation/settings-presentation";
|
||||
import { useSettingsRouteEntry } from "@/navigation/settings-presentation";
|
||||
import { ThemedStack } from "@/navigation/themed-stack";
|
||||
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||
import { AgentNavigationListener } from "@/desktop/agent-navigation";
|
||||
@@ -476,16 +473,12 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
viewportWidth,
|
||||
}),
|
||||
});
|
||||
const { presentation: sidebarPresentation, visible: sidebarVisible } =
|
||||
useSettingsSidebarPresentation({
|
||||
visible: chromeEnabled,
|
||||
presentation: resolveLeftSidebarPresentation({
|
||||
chromeEnabled,
|
||||
isCompactLayout,
|
||||
isFocusModeEnabled: isWorkspaceFocusModeEnabled,
|
||||
desktopSidebarVisible,
|
||||
}),
|
||||
});
|
||||
const sidebarPresentation = resolveLeftSidebarPresentation({
|
||||
chromeEnabled,
|
||||
isCompactLayout,
|
||||
isFocusModeEnabled: isWorkspaceFocusModeEnabled,
|
||||
desktopSidebarVisible,
|
||||
});
|
||||
const applyPanelLayout = usePanelStore((state) => state.applyPanelLayout);
|
||||
// Publishes the layout to the store, which owns the panel targets each layout allows.
|
||||
useLayoutEffect(() => {
|
||||
@@ -553,11 +546,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
<DesktopWindowControls />
|
||||
<FloatingPanelPortalHost />
|
||||
</AppearanceStyleBoundary>
|
||||
{isCompactLayout ? (
|
||||
<CompactSidebarHost visible={sidebarVisible} interactive={chromeEnabled}>
|
||||
{themedSidebarChrome}
|
||||
</CompactSidebarHost>
|
||||
) : null}
|
||||
{isCompactLayout ? themedSidebarChrome : null}
|
||||
<AppearanceStyleBoundary>
|
||||
<DownloadToast />
|
||||
<RosettaCalloutSource />
|
||||
@@ -595,34 +584,6 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
);
|
||||
}
|
||||
|
||||
// Keep native scroll ranges and gesture hosts intact while Settings covers the sidebar.
|
||||
function CompactSidebarHost({
|
||||
visible,
|
||||
interactive,
|
||||
children,
|
||||
}: {
|
||||
visible: boolean;
|
||||
interactive: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<View
|
||||
collapsable={false}
|
||||
style={[
|
||||
layoutStyles.compactSidebarHost,
|
||||
{ opacity: visible ? 1 : 0 },
|
||||
// DOM descendants can override pointer-events:none.
|
||||
isWeb && !visible && { display: "none" },
|
||||
]}
|
||||
pointerEvents={interactive ? "box-none" : "none"}
|
||||
accessibilityElementsHidden={!interactive}
|
||||
importantForAccessibility={interactive ? "auto" : "no-hide-descendants"}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function AppContainerLifetime({
|
||||
children,
|
||||
sidebarGesturesEnabled,
|
||||
@@ -653,18 +614,14 @@ function SidebarChrome({
|
||||
const isCompactLayout = useIsCompactFormFactor();
|
||||
const isMobileActive = useIsMobilePanelActive("agent-list");
|
||||
const overlay = presentation === "overlay";
|
||||
const active = resolveSidebarActivity(
|
||||
presentation !== "hidden" && presentation !== "suspended",
|
||||
overlay,
|
||||
isMobileActive,
|
||||
);
|
||||
const active = resolveSidebarActivity(presentation !== "hidden", overlay, isMobileActive);
|
||||
const hardwareKeyboardPresent = useHardwareKeyboardPresent();
|
||||
return (
|
||||
<SidebarModelProvider
|
||||
active={active}
|
||||
keyboardNavigationActive={keyboardShortcutsEnabled && hardwareKeyboardPresent}
|
||||
>
|
||||
{mounted || presentation === "suspended" ? (
|
||||
{mounted ? (
|
||||
<LeftSidebar active={active} desktopOverlay={overlay && !isCompactLayout} />
|
||||
) : null}
|
||||
<WorkspaceShortcutTargetsSubscriber enabled={keyboardShortcutsEnabled} />
|
||||
@@ -907,8 +864,6 @@ const ROOT_STACK_SCREEN_OPTIONS = {
|
||||
animation: "none" as const,
|
||||
};
|
||||
const ROOT_STACK_NESTED_NAVIGATOR_SCREENS = ["h/[serverId]"] as const;
|
||||
// Settings owns reactive typography leaves; its scroll and form state survive appearance edits.
|
||||
const ROOT_STACK_SCREEN_OWNED_APPEARANCE = ["settings/[section]"] as const;
|
||||
|
||||
function RootStack() {
|
||||
const storeReady = useStoreReady();
|
||||
@@ -916,7 +871,6 @@ function RootStack() {
|
||||
<ThemedStack
|
||||
screenOptions={ROOT_STACK_SCREEN_OPTIONS}
|
||||
nestedNavigatorScreens={ROOT_STACK_NESTED_NAVIGATOR_SCREENS}
|
||||
screenOwnedAppearance={ROOT_STACK_SCREEN_OWNED_APPEARANCE}
|
||||
>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Protected guard={storeReady}>
|
||||
@@ -1050,7 +1004,6 @@ export default function RootLayout() {
|
||||
}
|
||||
|
||||
const layoutStyles = StyleSheet.create((theme) => ({
|
||||
compactSidebarHost: { position: "absolute", top: 0, bottom: 0, left: 0, right: 0 },
|
||||
surfaceFill: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
import { Text } from "react-native";
|
||||
|
||||
export { Text as AppearanceText };
|
||||
@@ -1,12 +0,0 @@
|
||||
import { Text as NativeText } from "react-native";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
// Web numeric tokens are baked into classes. Subscribe this leaf so withUnistyles
|
||||
// resolves its style prop again without replacing the surrounding form or scroll view.
|
||||
function typographyChanges(theme: Theme) {
|
||||
void theme.fontSize;
|
||||
return {};
|
||||
}
|
||||
|
||||
export const AppearanceText = withUnistyles(NativeText, typographyChanges);
|
||||
@@ -1,7 +1,6 @@
|
||||
import { AppearanceText as Text } from "@/appearance/text";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View, type PressableStateCallbackType } from "react-native";
|
||||
import { Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { PressHighlight } from "@/components/ui/press-highlight";
|
||||
import { DEFAULT_THEME_PREFERENCE, useAppSettings, type AppSettings } from "@/hooks/use-settings";
|
||||
|
||||
@@ -759,56 +759,6 @@ describe("assistant selection copy inside highlighted code", () => {
|
||||
"```typescript\nconst answer = 1;\n if (answer) {\n doThing();\n```\n\nAfter the block.",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps line breaks when a quote selection ends partway through code", () => {
|
||||
const message = mountHighlighted();
|
||||
const quote = document.createElement("div");
|
||||
quote.setAttribute("data-paseo-markdown-tag", "blockquote");
|
||||
quote.textContent = "Quoted context";
|
||||
message.querySelector('[data-paseo-markdown-tag="pre"]')!.before(quote);
|
||||
|
||||
const content = copyBetween(
|
||||
[tokenText(message, "Quoted context"), 0],
|
||||
[tokenText(message, " (answer)"), 9],
|
||||
);
|
||||
|
||||
expect(content?.plainText).toBe(
|
||||
"> Quoted context\n\n```typescript\nconst answer = 1;\n if (answer)\n```",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps indented code lines inside their list item", () => {
|
||||
const host = document.createElement("div");
|
||||
host.innerHTML = [
|
||||
'<div data-testid="assistant-message">',
|
||||
'<div data-paseo-markdown-tag="ol" data-paseo-markdown-list-start="1">',
|
||||
'<div data-paseo-markdown-tag="li">',
|
||||
'<div data-paseo-markdown-ignore="true" data-paseo-markdown-list-marker="true">1.</div>',
|
||||
'<div><div data-paseo-markdown-tag="p"><span>Item text</span></div>',
|
||||
highlightedCodeBlock("typescript"),
|
||||
"</div></div></div></div>",
|
||||
].join("");
|
||||
document.body.append(host);
|
||||
|
||||
const content = copyBetween([tokenText(host, "1."), 0], [tokenText(host, " (answer)"), 9]);
|
||||
|
||||
expect(content?.plainText).toBe(
|
||||
"1. Item text\n\n ```typescript\n const answer = 1;\n if (answer)\n ```",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps line breaks when a selection starts partway through code", () => {
|
||||
const message = mountHighlighted();
|
||||
|
||||
const content = copyBetween(
|
||||
[tokenText(message, " answer"), 1],
|
||||
[tokenText(message, "After the block."), "After the block.".length],
|
||||
);
|
||||
|
||||
expect(content?.plainText).toBe(
|
||||
"```typescript\nanswer = 1;\n if (answer) {\n doThing();\n```\n\nAfter the block.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("semantic library output", () => {
|
||||
|
||||
@@ -81,8 +81,7 @@ turndown.addRule("compactListItem", {
|
||||
const item = content
|
||||
.replace(/^\n+/, "")
|
||||
.replace(/\n+$/, "\n")
|
||||
// Every non-empty line, so indented code lines stay inside the item.
|
||||
.replace(/\n(?=[^\n])/g, "\n ");
|
||||
.replace(/\n(?=\S)/g, "\n ");
|
||||
const parent = node.parentElement;
|
||||
if (parent?.nodeName !== "OL") {
|
||||
return `${options.bulletListMarker} ${item}\n`;
|
||||
@@ -360,9 +359,6 @@ function shouldPreserveSemanticElement(range: Range, element: Element): boolean
|
||||
if (tag === "p" || tag === "img" || isTableStructure(tag)) {
|
||||
return true;
|
||||
}
|
||||
if (isCrossBoundaryCodeElement(range, element, tag)) {
|
||||
return true;
|
||||
}
|
||||
const isSelectableSemantic = tag !== null && tag !== "li" && tag !== "ol" && tag !== "ul";
|
||||
if (
|
||||
(isSelectableSemantic || element.tagName === "A") &&
|
||||
@@ -390,11 +386,6 @@ function shouldPreserveSemanticElement(range: Range, element: Element): boolean
|
||||
return hasSelectedAllContents(range, element, tag === "li");
|
||||
}
|
||||
|
||||
function isCrossBoundaryCodeElement(range: Range, element: Element, tag: string | null): boolean {
|
||||
const isCode = tag === "pre" || (tag === "code" && Boolean(element.closest(CODE_BLOCK_SELECTOR)));
|
||||
return isCode && !selectionStaysInsideElement(range, element);
|
||||
}
|
||||
|
||||
function selectionStaysInsideElement(range: Range, element: Element): boolean {
|
||||
return (
|
||||
range.startContainer !== element &&
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { orderDroppedItems } from "./draggable-list-drop-order";
|
||||
|
||||
interface Row {
|
||||
key: string;
|
||||
}
|
||||
|
||||
const rows = (...keys: string[]): Row[] => keys.map((key) => ({ key }));
|
||||
const keyExtractor = (row: Row) => row.key;
|
||||
|
||||
describe("orderDroppedItems", () => {
|
||||
it("returns the rows in the order the list shows at the drop", () => {
|
||||
const result = orderDroppedItems({
|
||||
data: rows("a", "b", "c"),
|
||||
keyExtractor,
|
||||
indexToKey: ["c", "a", "b"],
|
||||
});
|
||||
|
||||
expect(result.map(keyExtractor)).toEqual(["c", "a", "b"]);
|
||||
});
|
||||
|
||||
it("leaves out a row that disappeared while it was dropped", () => {
|
||||
const result = orderDroppedItems({
|
||||
data: rows("a", "c"),
|
||||
keyExtractor,
|
||||
indexToKey: ["c", "b", "a"],
|
||||
});
|
||||
|
||||
expect(result).toEqual(rows("c", "a"));
|
||||
});
|
||||
|
||||
it("leaves out a row that arrived while it was dropped, so its stored place is kept", () => {
|
||||
const result = orderDroppedItems({
|
||||
data: rows("a", "b", "new"),
|
||||
keyExtractor,
|
||||
indexToKey: ["b", "a"],
|
||||
});
|
||||
|
||||
expect(result).toEqual(rows("b", "a"));
|
||||
});
|
||||
});
|
||||
@@ -1,24 +0,0 @@
|
||||
/**
|
||||
* The rows in the order a native list shows when a drag ends.
|
||||
*
|
||||
* Sortables' own `data` for `onDragEnd` indexes rows through `keyToIndex`, a derived value that can
|
||||
* trail `indexToKey` by a frame. A drop right after a swap would then save the order from before the
|
||||
* swap, and a row added or removed during the drop leaves a hole. `indexToKey` is the order on
|
||||
* screen. Keys without a row are skipped, and rows without a key are left out, so callers keep
|
||||
* their stored place.
|
||||
*/
|
||||
export function orderDroppedItems<T>(input: {
|
||||
data: readonly T[];
|
||||
keyExtractor: (item: T) => string;
|
||||
indexToKey: readonly string[];
|
||||
}): T[] {
|
||||
const itemsByKey = new Map(input.data.map((item) => [input.keyExtractor(item), item]));
|
||||
const ordered: T[] = [];
|
||||
for (const key of input.indexToKey) {
|
||||
const item = itemsByKey.get(key);
|
||||
if (item !== undefined) {
|
||||
ordered.push(item);
|
||||
}
|
||||
}
|
||||
return ordered;
|
||||
}
|
||||
@@ -1,169 +1,133 @@
|
||||
import { createContext, useCallback, useContext, useState, type ReactElement } from "react";
|
||||
import { View } from "react-native";
|
||||
import { ScrollView as GestureScrollView } from "react-native-gesture-handler";
|
||||
import {
|
||||
createAnimatedComponent,
|
||||
useAnimatedReaction,
|
||||
useAnimatedRef,
|
||||
type AnimatedRef,
|
||||
} from "react-native-reanimated";
|
||||
import Sortable, {
|
||||
useItemContext,
|
||||
type SortableGridDragEndParams,
|
||||
type SortableGridRenderItem,
|
||||
} from "react-native-sortables";
|
||||
import { scheduleOnRN } from "react-native-worklets";
|
||||
import type {
|
||||
DraggableListDragHandleProps,
|
||||
DraggableListHandleProps,
|
||||
DraggableListProps,
|
||||
DraggableListScrollViewProps,
|
||||
DraggableRenderItemInfo,
|
||||
} from "./draggable-list.types";
|
||||
import { orderDroppedItems } from "./draggable-list-drop-order";
|
||||
import { RefreshControl } from "react-native";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import DraggableFlatList, {
|
||||
NestableDraggableFlatList,
|
||||
type RenderItemParams,
|
||||
} from "react-native-draggable-flatlist";
|
||||
import { useUnistyles } from "react-native-unistyles";
|
||||
import type { DraggableListProps, DraggableRenderItemInfo } from "./draggable-list.types";
|
||||
|
||||
export type { DraggableListProps, DraggableRenderItemInfo };
|
||||
|
||||
// Sortables places every row with UI-thread offsets, so the reorder commit never moves a row by
|
||||
// itself. Lists that position rows by React child order plus a held drag translate paint one
|
||||
// frame of new order + old translate when the reorder commits.
|
||||
//
|
||||
// Render lists inside `DraggableListScrollView` so a dragged row autoscrolls it.
|
||||
//
|
||||
// A drag interrupted by the system (app backgrounded, incoming call) commits the order it
|
||||
// previewed: sortables ends a cancelled drag through the same `onDragEnd` as a release.
|
||||
|
||||
// A row lifts after a still hold. Moving first fails the drag, so the touch scrolls the list or
|
||||
// swipes the panel instead.
|
||||
const DRAG_ACTIVATION_DELAY_MS = 180;
|
||||
const DRAG_ACTIVATION_FAIL_OFFSET_PX = 4;
|
||||
|
||||
// Native handles carry no props; their presence marks the row as the handle.
|
||||
const NATIVE_DRAG_HANDLE_PROPS: DraggableListDragHandleProps = {};
|
||||
|
||||
const AnimatedGestureScrollView = createAnimatedComponent(GestureScrollView);
|
||||
|
||||
const ScrollViewRefContext = createContext<AnimatedRef | null>(null);
|
||||
|
||||
// True inside a row of another DraggableList, e.g. the workspaces inside a project block.
|
||||
const NestedListContext = createContext(false);
|
||||
|
||||
export function DraggableListScrollView({
|
||||
children,
|
||||
simultaneousGestureRef,
|
||||
...scrollViewProps
|
||||
}: DraggableListScrollViewProps) {
|
||||
const scrollViewRef = useAnimatedRef();
|
||||
return (
|
||||
<AnimatedGestureScrollView
|
||||
{...scrollViewProps}
|
||||
ref={scrollViewRef}
|
||||
simultaneousHandlers={simultaneousGestureRef}
|
||||
>
|
||||
<ScrollViewRefContext.Provider value={scrollViewRef}>
|
||||
{children}
|
||||
</ScrollViewRefContext.Provider>
|
||||
</AnimatedGestureScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
export function DraggableListHandle({ children, dragHandleProps }: DraggableListHandleProps) {
|
||||
if (!dragHandleProps) {
|
||||
return children;
|
||||
}
|
||||
return <Sortable.Handle>{children}</Sortable.Handle>;
|
||||
}
|
||||
const SCROLL_ENABLED_FLEX_STYLE = { flex: 1 };
|
||||
|
||||
export function DraggableList<T>({
|
||||
data,
|
||||
keyExtractor,
|
||||
renderItem,
|
||||
onDragEnd,
|
||||
style,
|
||||
containerStyle,
|
||||
contentContainerStyle,
|
||||
testID,
|
||||
useDragHandle = false,
|
||||
ListFooterComponent,
|
||||
ListHeaderComponent,
|
||||
ListEmptyComponent,
|
||||
showsVerticalScrollIndicator = true,
|
||||
scrollEnabled = true,
|
||||
useDragHandle: _useDragHandle = false,
|
||||
refreshing,
|
||||
onRefresh,
|
||||
extraData,
|
||||
simultaneousGestureRef,
|
||||
gestureHostPresented,
|
||||
waitFor,
|
||||
onDragBegin: onDragBeginProp,
|
||||
nestable = false,
|
||||
}: DraggableListProps<T>) {
|
||||
const scrollViewRef = useContext(ScrollViewRefContext);
|
||||
const nested = useContext(NestedListContext);
|
||||
const dragHandleProps = useDragHandle ? NATIVE_DRAG_HANDLE_PROPS : undefined;
|
||||
const { theme } = useUnistyles();
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
const renderGridItem = useCallback<SortableGridRenderItem<T>>(
|
||||
({ item, index }) => (
|
||||
<DraggableListItem
|
||||
item={item}
|
||||
index={index}
|
||||
renderItem={renderItem}
|
||||
dragHandleProps={dragHandleProps}
|
||||
/>
|
||||
),
|
||||
[dragHandleProps, renderItem],
|
||||
// Pass the ref directly to DraggableFlatList - it handles gesture
|
||||
// coordination internally for nestable lists.
|
||||
const simultaneousHandlers = useMemo(
|
||||
() => (simultaneousGestureRef ? [simultaneousGestureRef] : undefined),
|
||||
[simultaneousGestureRef],
|
||||
);
|
||||
|
||||
const refreshColors = useMemo(
|
||||
() => [theme.colors.foregroundMuted],
|
||||
[theme.colors.foregroundMuted],
|
||||
);
|
||||
|
||||
const handleRenderItem = useCallback(
|
||||
({ item, drag, isActive, getIndex }: RenderItemParams<T>) => {
|
||||
const index = getIndex() ?? 0;
|
||||
const info: DraggableRenderItemInfo<T> = {
|
||||
item,
|
||||
index,
|
||||
drag,
|
||||
isActive,
|
||||
};
|
||||
return renderItem(info);
|
||||
},
|
||||
[renderItem],
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
({ indexToKey }: SortableGridDragEndParams<T>) => {
|
||||
onDragEnd(orderDroppedItems({ data, keyExtractor, indexToKey }));
|
||||
({ data: newData }: { data: T[] }) => {
|
||||
setIsDragging(false);
|
||||
onDragEnd(newData);
|
||||
},
|
||||
[data, keyExtractor, onDragEnd],
|
||||
[onDragEnd],
|
||||
);
|
||||
|
||||
const handleDragBegin = useCallback(() => {
|
||||
setIsDragging(true);
|
||||
onDragBeginProp?.();
|
||||
}, [onDragBeginProp]);
|
||||
|
||||
const handleRelease = useCallback(() => {
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
const showRefreshControl = Boolean(onRefresh) && (!isDragging || Boolean(refreshing));
|
||||
const resolvedContainerStyle =
|
||||
containerStyle ?? (scrollEnabled ? SCROLL_ENABLED_FLEX_STYLE : undefined);
|
||||
const shouldShowRefreshControl = showRefreshControl && !nestable;
|
||||
const ListComponent: typeof DraggableFlatList = (
|
||||
nestable ? (NestableDraggableFlatList as unknown) : DraggableFlatList
|
||||
) as typeof DraggableFlatList;
|
||||
|
||||
const refreshControl = useMemo(
|
||||
() =>
|
||||
shouldShowRefreshControl ? (
|
||||
<RefreshControl
|
||||
refreshing={refreshing ?? false}
|
||||
onRefresh={onRefresh}
|
||||
tintColor={theme.colors.foregroundMuted}
|
||||
colors={refreshColors}
|
||||
/>
|
||||
) : undefined,
|
||||
[shouldShowRefreshControl, refreshing, onRefresh, theme.colors.foregroundMuted, refreshColors],
|
||||
);
|
||||
|
||||
return (
|
||||
<View testID={testID} style={containerStyle}>
|
||||
<Sortable.Grid
|
||||
columns={1}
|
||||
data={data}
|
||||
keyExtractor={keyExtractor}
|
||||
renderItem={renderGridItem}
|
||||
onDragEnd={handleDragEnd}
|
||||
customHandle={useDragHandle}
|
||||
dragActivationDelay={DRAG_ACTIVATION_DELAY_MS}
|
||||
dragActivationFailOffset={DRAG_ACTIVATION_FAIL_OFFSET_PX}
|
||||
scrollableRef={scrollViewRef ?? undefined}
|
||||
autoScrollEnabled={scrollViewRef !== null}
|
||||
// Clamped to the single column's width, so a dragged row moves only vertically. A nested
|
||||
// list also stays inside its row: its lifted row would draw under the rows that follow.
|
||||
overDrag={nested ? "none" : "vertical"}
|
||||
// The lifted row keeps the point where the finger grabbed it.
|
||||
enableActiveItemSnap={false}
|
||||
// Rows animate only while a drag reorders them. Otherwise a project expanding or
|
||||
// collapsing would slide the rows below over its rows, and added rows would fade in.
|
||||
itemsLayoutTransitionMode="reorder"
|
||||
itemEntering={null}
|
||||
itemExiting={null}
|
||||
// Rows style themselves from `isActive`; the other rows stay as they are.
|
||||
activeItemScale={1}
|
||||
activeItemShadowOpacity={0}
|
||||
inactiveItemOpacity={1}
|
||||
inactiveItemScale={1}
|
||||
hapticsEnabled
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function DraggableListItem<T>({
|
||||
item,
|
||||
index,
|
||||
renderItem,
|
||||
dragHandleProps,
|
||||
}: {
|
||||
item: T;
|
||||
index: number;
|
||||
renderItem: (info: DraggableRenderItemInfo<T>) => ReactElement;
|
||||
dragHandleProps: DraggableListDragHandleProps | undefined;
|
||||
}) {
|
||||
const { isActive } = useItemContext();
|
||||
const [active, setActive] = useState(false);
|
||||
useAnimatedReaction(
|
||||
() => isActive.value,
|
||||
(next, previous) => {
|
||||
if (previous !== null && next !== previous) {
|
||||
scheduleOnRN(setActive, next);
|
||||
}
|
||||
},
|
||||
);
|
||||
return (
|
||||
<NestedListContext.Provider value={true}>
|
||||
{renderItem({ item, index, isActive: active, dragHandleProps })}
|
||||
</NestedListContext.Provider>
|
||||
<ListComponent
|
||||
testID={testID}
|
||||
data={data}
|
||||
keyExtractor={keyExtractor}
|
||||
renderItem={handleRenderItem}
|
||||
onDragEnd={handleDragEnd}
|
||||
style={style}
|
||||
containerStyle={resolvedContainerStyle}
|
||||
contentContainerStyle={contentContainerStyle}
|
||||
ListFooterComponent={ListFooterComponent}
|
||||
ListHeaderComponent={ListHeaderComponent}
|
||||
ListEmptyComponent={ListEmptyComponent}
|
||||
showsVerticalScrollIndicator={showsVerticalScrollIndicator}
|
||||
scrollEnabled={scrollEnabled}
|
||||
extraData={extraData}
|
||||
simultaneousHandlers={simultaneousHandlers}
|
||||
dragGestureHostPresented={gestureHostPresented}
|
||||
// Higher activation distance reduces accidental drag capture while nested
|
||||
// lists are inside a scroll container.
|
||||
activationDistance={20}
|
||||
onDragBegin={handleDragBegin}
|
||||
onRelease={handleRelease}
|
||||
// @ts-ignore - waitFor is supported by RNGH FlatList but missing from DraggableFlatList types
|
||||
waitFor={waitFor}
|
||||
refreshControl={refreshControl}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import type { MutableRefObject, PropsWithChildren, ReactElement } from "react";
|
||||
import type { ReactElement, MutableRefObject } from "react";
|
||||
import type { StyleProp, ViewStyle } from "react-native";
|
||||
import type { GestureType } from "react-native-gesture-handler";
|
||||
|
||||
/**
|
||||
* Marks the row a drag starts from when the list uses a drag handle. On web, spread
|
||||
* `attributes` and `listeners` and assign `setActivatorNodeRef` on the handle element. On
|
||||
* every platform, wrap the handle in `DraggableListHandle` with these props.
|
||||
*/
|
||||
export interface DraggableListDragHandleProps {
|
||||
/**
|
||||
* Web-only drag handle props (from dnd-kit). Spread these onto the element
|
||||
* that should initiate the drag. Native uses the `drag()` callback instead.
|
||||
*/
|
||||
attributes?: Record<string, unknown>;
|
||||
listeners?: Record<string, unknown>;
|
||||
setActivatorNodeRef?: (node: unknown) => void;
|
||||
@@ -16,34 +15,53 @@ export interface DraggableListDragHandleProps {
|
||||
export interface DraggableRenderItemInfo<T> {
|
||||
item: T;
|
||||
index: number;
|
||||
drag: () => void;
|
||||
isActive: boolean;
|
||||
dragHandleProps?: DraggableListDragHandleProps;
|
||||
}
|
||||
|
||||
export interface DraggableListProps<T> {
|
||||
data: T[];
|
||||
keyExtractor: (item: T) => string;
|
||||
keyExtractor: (item: T, index: number) => string;
|
||||
renderItem: (info: DraggableRenderItemInfo<T>) => ReactElement;
|
||||
onDragEnd: (data: T[]) => void;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
/** Outer container style (useful for nested, non-scrolling lists). */
|
||||
containerStyle?: StyleProp<ViewStyle>;
|
||||
contentContainerStyle?: StyleProp<ViewStyle>;
|
||||
testID?: string;
|
||||
ListFooterComponent?: ReactElement | null;
|
||||
ListHeaderComponent?: ReactElement | null;
|
||||
ListEmptyComponent?: ReactElement | null;
|
||||
showsVerticalScrollIndicator?: boolean;
|
||||
/** When false, disables internal scrolling (use outer list to scroll). */
|
||||
scrollEnabled?: boolean;
|
||||
/**
|
||||
* When true, a drag starts only from the `DraggableListHandle` that receives the row's
|
||||
* `dragHandleProps`, so a list nested inside a row keeps its own drags.
|
||||
* Web-only: when true, the drag can only be initiated from the handle props
|
||||
* passed to `renderItem` (prevents nested lists from fighting).
|
||||
*/
|
||||
useDragHandle?: boolean;
|
||||
}
|
||||
|
||||
export type DraggableListHandleProps = PropsWithChildren<{
|
||||
dragHandleProps: DraggableListDragHandleProps | undefined;
|
||||
}>;
|
||||
|
||||
/** The vertical scroll view that hosts draggable lists and scrolls while a row is dragged. */
|
||||
export type DraggableListScrollViewProps = PropsWithChildren<{
|
||||
style?: StyleProp<ViewStyle>;
|
||||
contentContainerStyle?: StyleProp<ViewStyle>;
|
||||
showsVerticalScrollIndicator?: boolean;
|
||||
testID?: string;
|
||||
/** Native-only: a gesture that keeps recognizing while the list scrolls, e.g. a panel close. */
|
||||
refreshing?: boolean;
|
||||
onRefresh?: () => void;
|
||||
/** Fill remaining space when content is smaller than container */
|
||||
contentContainerFlexGrow?: boolean;
|
||||
/** External row state that should invalidate virtualized native cells. */
|
||||
extraData?: unknown;
|
||||
/** Gesture ref for simultaneous handling with parent gestures (e.g., sidebar close) */
|
||||
simultaneousGestureRef?: MutableRefObject<GestureType | undefined>;
|
||||
}>;
|
||||
/** Whether the retained native gesture host is currently presented. */
|
||||
gestureHostPresented?: boolean;
|
||||
/** Gesture ref(s) that the list should wait for before handling scroll */
|
||||
waitFor?: MutableRefObject<GestureType | undefined> | MutableRefObject<GestureType | undefined>[];
|
||||
/** Called when a drag gesture begins (before items are reordered) */
|
||||
onDragBegin?: () => void;
|
||||
/** Called immediately before invoking row `drag()` to lock outer owners. */
|
||||
onDragIntent?: () => void;
|
||||
/** Called when drag interaction ends (finger released). */
|
||||
onDragRelease?: () => void;
|
||||
/**
|
||||
* Native-only: use the nestable draggable-flatlist variant for nested drag
|
||||
* lists coordinated by a shared NestableScrollContainer.
|
||||
*/
|
||||
nestable?: boolean;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useMemo, useRef, type ReactElement } from "react";
|
||||
import { memo, useCallback, useMemo, useRef, type ReactElement } from "react";
|
||||
import { ScrollView, View } from "react-native";
|
||||
import {
|
||||
DndContext,
|
||||
@@ -17,12 +17,7 @@ import {
|
||||
useSortable,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import type {
|
||||
DraggableListHandleProps,
|
||||
DraggableListProps,
|
||||
DraggableListScrollViewProps,
|
||||
DraggableRenderItemInfo,
|
||||
} from "./draggable-list.types";
|
||||
import type { DraggableListProps, DraggableRenderItemInfo } from "./draggable-list.types";
|
||||
import { getDragActivationConstraints, useDragReorderState } from "./drag-reorder";
|
||||
|
||||
export type { DraggableListProps, DraggableRenderItemInfo };
|
||||
@@ -121,6 +116,18 @@ function SortableItemInner<T>({
|
||||
isDragging,
|
||||
} = useSortable({ id });
|
||||
|
||||
const dragRef = useRef<(() => void) | null>(null);
|
||||
|
||||
const drag = useCallback(() => {
|
||||
// dnd-kit handles drag initiation via listeners
|
||||
// This is a no-op but matches the mobile API
|
||||
}, []);
|
||||
|
||||
// Store listeners in ref so drag handle can access them
|
||||
dragRef.current = () => {
|
||||
// Trigger drag - handled by dnd-kit's listeners
|
||||
};
|
||||
|
||||
// dnd-kit can set `scaleX/scaleY` on the active item when dragging over a
|
||||
// differently-sized droppable. For variable-height rows this can look like
|
||||
// the "ghost" stretches. Keep the dragged item's size stable by zeroing
|
||||
@@ -159,6 +166,7 @@ function SortableItemInner<T>({
|
||||
const info: DraggableRenderItemInfo<T> = {
|
||||
item,
|
||||
index,
|
||||
drag,
|
||||
isActive: activeId === id,
|
||||
dragHandleProps,
|
||||
};
|
||||
@@ -176,44 +184,31 @@ function SortableItemInner<T>({
|
||||
|
||||
const SortableItem = memo(SortableItemInner) as typeof SortableItemInner;
|
||||
|
||||
export function DraggableListScrollView({
|
||||
children,
|
||||
style,
|
||||
contentContainerStyle,
|
||||
showsVerticalScrollIndicator,
|
||||
testID,
|
||||
}: DraggableListScrollViewProps) {
|
||||
return (
|
||||
<ScrollView
|
||||
style={style}
|
||||
contentContainerStyle={contentContainerStyle}
|
||||
showsVerticalScrollIndicator={showsVerticalScrollIndicator}
|
||||
testID={testID}
|
||||
>
|
||||
{children}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
// The handle element itself spreads `dragHandleProps` on web.
|
||||
export function DraggableListHandle({ children }: DraggableListHandleProps) {
|
||||
return children;
|
||||
}
|
||||
|
||||
export function DraggableList<T>({
|
||||
data,
|
||||
keyExtractor,
|
||||
renderItem,
|
||||
onDragEnd,
|
||||
style,
|
||||
containerStyle,
|
||||
// Native-only, as before.
|
||||
testID: _testID,
|
||||
contentContainerStyle,
|
||||
testID,
|
||||
ListFooterComponent,
|
||||
ListHeaderComponent,
|
||||
ListEmptyComponent,
|
||||
showsVerticalScrollIndicator = true,
|
||||
scrollEnabled = true,
|
||||
extraData: _extraData,
|
||||
useDragHandle = false,
|
||||
// simultaneousGestureRef is native-only, ignored on web
|
||||
onDragBegin,
|
||||
nestable: _nestable = false,
|
||||
}: DraggableListProps<T>) {
|
||||
const { activeId, items, handlers } = useDragReorderState({
|
||||
data,
|
||||
keyExtractor,
|
||||
onDragEnd,
|
||||
onDragBegin,
|
||||
});
|
||||
const activationConstraints = getDragActivationConstraints(useDragHandle, DRAG_ACTIVATION_CONFIG);
|
||||
|
||||
@@ -229,39 +224,89 @@ export function DraggableList<T>({
|
||||
}),
|
||||
);
|
||||
|
||||
const ids = useMemo(() => items.map((item) => keyExtractor(item)), [items, keyExtractor]);
|
||||
const ids = useMemo(
|
||||
() => items.map((item, index) => keyExtractor(item, index)),
|
||||
[items, keyExtractor],
|
||||
);
|
||||
const wrapperStyle = useMemo(
|
||||
() => [{ position: "relative" as const }, containerStyle],
|
||||
[containerStyle],
|
||||
() => [
|
||||
{ position: "relative" as const },
|
||||
scrollEnabled ? { flex: 1, minHeight: 0 } : null,
|
||||
containerStyle,
|
||||
],
|
||||
[scrollEnabled, containerStyle],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={wrapperStyle}>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
modifiers={DND_MODIFIERS}
|
||||
onDragStart={handlers.onDragStart}
|
||||
onDragCancel={handlers.onDragCancel}
|
||||
onDragEnd={handlers.onDragEnd}
|
||||
>
|
||||
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
|
||||
{items.map((item, index) => {
|
||||
const id = keyExtractor(item);
|
||||
return (
|
||||
<SortableItem
|
||||
key={id}
|
||||
id={id}
|
||||
item={item}
|
||||
index={index}
|
||||
renderItem={renderItem}
|
||||
activeId={activeId}
|
||||
useDragHandle={useDragHandle}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
{scrollEnabled ? (
|
||||
<ScrollView
|
||||
testID={testID}
|
||||
style={style}
|
||||
contentContainerStyle={contentContainerStyle}
|
||||
showsVerticalScrollIndicator={showsVerticalScrollIndicator}
|
||||
>
|
||||
{ListHeaderComponent}
|
||||
{items.length === 0 && ListEmptyComponent}
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
modifiers={DND_MODIFIERS}
|
||||
onDragStart={handlers.onDragStart}
|
||||
onDragCancel={handlers.onDragCancel}
|
||||
onDragEnd={handlers.onDragEnd}
|
||||
>
|
||||
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
|
||||
{items.map((item, index) => {
|
||||
const id = keyExtractor(item, index);
|
||||
return (
|
||||
<SortableItem
|
||||
key={id}
|
||||
id={id}
|
||||
item={item}
|
||||
index={index}
|
||||
renderItem={renderItem}
|
||||
activeId={activeId}
|
||||
useDragHandle={useDragHandle}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
{ListFooterComponent}
|
||||
</ScrollView>
|
||||
) : (
|
||||
<>
|
||||
{ListHeaderComponent}
|
||||
{items.length === 0 && ListEmptyComponent}
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
modifiers={DND_MODIFIERS}
|
||||
onDragStart={handlers.onDragStart}
|
||||
onDragCancel={handlers.onDragCancel}
|
||||
onDragEnd={handlers.onDragEnd}
|
||||
>
|
||||
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
|
||||
{items.map((item, index) => {
|
||||
const id = keyExtractor(item, index);
|
||||
return (
|
||||
<SortableItem
|
||||
key={id}
|
||||
id={id}
|
||||
item={item}
|
||||
index={index}
|
||||
renderItem={renderItem}
|
||||
activeId={activeId}
|
||||
useDragHandle={useDragHandle}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
{ListFooterComponent}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { SidebarViewSlot } from "@/plugins/sidebar-view";
|
||||
import { useAddHost } from "@/hosts/add-host";
|
||||
import { router } from "expo-router";
|
||||
import {
|
||||
CircleGauge,
|
||||
FolderPlus,
|
||||
@@ -84,9 +85,8 @@ import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop
|
||||
import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
|
||||
import { useLeftPanelWidth } from "@/mobile-panels/provider";
|
||||
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
|
||||
import { buildSettingsHostSectionRoute } from "@/utils/host-routes";
|
||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import { openSettingsFromSidebar } from "@/navigation/settings-navigation";
|
||||
import { buildSettingsRoute } from "@/utils/host-routes";
|
||||
import { openHostOverview } from "@/navigation/settings-navigation";
|
||||
import { UsageSidebarItem, UsageSidebarRoot, useUsageSummary, useOpenSidebarUsage } from "@/usage";
|
||||
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
|
||||
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
|
||||
@@ -157,7 +157,6 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const isCompactLayout = useIsCompactFormFactor();
|
||||
const activeWorkspace = useActiveWorkspaceSelection();
|
||||
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
||||
|
||||
const {
|
||||
@@ -194,26 +193,25 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
const openProjectPicker = useOpenAddProject();
|
||||
const { open: openImportSession, sheet: importSessionSheet } = useImportSession();
|
||||
|
||||
// Workspace destinations close the overlay; Settings retains it for the return journey.
|
||||
// Each destination closes the navigation overlay first. Closing is idempotent, so a docked
|
||||
// sidebar runs the same handler.
|
||||
const handleOpenProject = useCallback(() => {
|
||||
void openProjectPicker();
|
||||
}, [openProjectPicker]);
|
||||
|
||||
const handleSettings = useCallback(() => {
|
||||
openSettingsFromSidebar({ workspace: activeWorkspace, isCompact: isCompactLayout });
|
||||
}, [activeWorkspace, isCompactLayout]);
|
||||
showMobileAgent();
|
||||
router.push(buildSettingsRoute());
|
||||
}, [showMobileAgent]);
|
||||
|
||||
const handleAddHost = useAddHost();
|
||||
|
||||
const handleOpenHostSettings = useCallback(
|
||||
(serverId: string) => {
|
||||
openSettingsFromSidebar({
|
||||
workspace: activeWorkspace,
|
||||
isCompact: isCompactLayout,
|
||||
route: buildSettingsHostSectionRoute(serverId, "host"),
|
||||
});
|
||||
showMobileAgent();
|
||||
openHostOverview(serverId);
|
||||
},
|
||||
[activeWorkspace, isCompactLayout],
|
||||
[showMobileAgent],
|
||||
);
|
||||
|
||||
const labels = useMemo(
|
||||
@@ -669,6 +667,7 @@ function MobileSidebar({
|
||||
onAddProject={handleOpenProject}
|
||||
onImportSession={handleImportSession}
|
||||
parentGestureRef={closeGestureRef}
|
||||
dragGestureHostActive={active}
|
||||
listHeaderComponent={workspacesSectionHeaderElement}
|
||||
/>
|
||||
)}
|
||||
@@ -870,6 +869,7 @@ function DesktopSidebar({
|
||||
onImportSession={handleImportSession}
|
||||
onWorkspacePress={closeSidebar}
|
||||
parentGestureRef={closeGestureRef}
|
||||
dragGestureHostActive={active}
|
||||
listHeaderComponent={workspacesSectionHeaderElement}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -106,76 +106,29 @@ function ProviderSettingsAction({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* An icon button that explains itself in a tooltip on desktop hover. Tooltips
|
||||
* never open on mobile, so native renders the shared gesture-aware pressable
|
||||
* instead of a plain Pressable: inside the model list's independent scroll
|
||||
* viewport (Android, wide windows) the native scroll gesture cancels a plain
|
||||
* Pressable's touches and the button would never fire.
|
||||
*/
|
||||
function IconActionButton({
|
||||
accessibilityLabel,
|
||||
testID,
|
||||
onPress,
|
||||
style,
|
||||
children,
|
||||
}: {
|
||||
accessibilityLabel: string;
|
||||
testID: string;
|
||||
onPress: () => void;
|
||||
style?:
|
||||
| StyleProp<ViewStyle>
|
||||
| ((state: PressableStateCallbackType & { hovered?: boolean }) => StyleProp<ViewStyle>);
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
if (isNative) {
|
||||
return (
|
||||
<ModelBrowserPressable
|
||||
onPress={onPress}
|
||||
hitSlop={8}
|
||||
style={style}
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
testID={testID}
|
||||
>
|
||||
{children}
|
||||
</ModelBrowserPressable>
|
||||
);
|
||||
}
|
||||
function AgentProfilesEditAction({ onPress }: { onPress: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Tooltip delayDuration={250} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild>
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
hitSlop={8}
|
||||
style={style}
|
||||
style={iconButtonStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
testID={testID}
|
||||
accessibilityLabel={t("modelSelector.editProfilesLabel")}
|
||||
testID="model-profiles-edit"
|
||||
>
|
||||
{children}
|
||||
<ThemedPencil size={ICON_SIZE.xs} uniProps={foregroundExtraMutedMapping} />
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<Text style={styles.tooltipText}>{accessibilityLabel}</Text>
|
||||
<Text style={styles.tooltipText}>{t("modelSelector.editProfilesLabel")}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentProfilesEditAction({ onPress }: { onPress: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<IconActionButton
|
||||
onPress={onPress}
|
||||
style={iconButtonStyle}
|
||||
accessibilityLabel={t("modelSelector.editProfilesLabel")}
|
||||
testID="model-profiles-edit"
|
||||
>
|
||||
<ThemedPencil size={ICON_SIZE.xs} uniProps={foregroundExtraMutedMapping} />
|
||||
</IconActionButton>
|
||||
);
|
||||
}
|
||||
|
||||
const IndependentScrollGestureContext = createContext<ReturnType<typeof Gesture.Native> | null>(
|
||||
null,
|
||||
);
|
||||
@@ -684,14 +637,24 @@ function ModelRowProfileAction({
|
||||
[visible],
|
||||
);
|
||||
return (
|
||||
<IconActionButton
|
||||
onPress={onPress}
|
||||
style={pressableStyle}
|
||||
accessibilityLabel={label}
|
||||
testID={testID}
|
||||
>
|
||||
{children}
|
||||
</IconActionButton>
|
||||
<Tooltip delayDuration={250} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild>
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
hitSlop={8}
|
||||
style={pressableStyle}
|
||||
pointerEvents={visible ? "auto" : "none"}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={label}
|
||||
testID={testID}
|
||||
>
|
||||
{children}
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<Text style={styles.tooltipText}>{label}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1727,10 +1690,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
rowIconButtonPressed: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
// Hidden in style so every pressable branch that applies the style also stops taking taps.
|
||||
profileActionHidden: {
|
||||
opacity: 0,
|
||||
pointerEvents: "none",
|
||||
},
|
||||
emptyState: {
|
||||
padding: theme.spacing[4],
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { AppearanceText as Text } from "@/appearance/text";
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import { Platform, ScrollView, View } from "react-native";
|
||||
import { Platform, ScrollView, Text, View } from "react-native";
|
||||
import { KeyboardAwareScrollView } from "react-native-keyboard-controller";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { AppearanceText as Text } from "@/appearance/text";
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import { View, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { Text, View, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { ExternalLink, type ExternalLinkProps } from "@/components/ui/external-link";
|
||||
import { SettingsInfoTip } from "./settings-info-tip";
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { PressHighlight as Pressable } from "@/components/ui/press-highlight";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
type GestureResponderEvent,
|
||||
type PressableStateCallbackType,
|
||||
type StyleProp,
|
||||
@@ -38,12 +39,8 @@ import { type GestureType } from "react-native-gesture-handler";
|
||||
import { useSidebarWorkspaceRename } from "@/workspace-rename";
|
||||
import { useWorkspaceClipboardActions } from "@/hooks/use-workspace-clipboard-actions";
|
||||
import { ExternalLink, Settings, MoreVertical, Plus, Trash2 } from "lucide-react-native";
|
||||
import {
|
||||
DraggableList,
|
||||
DraggableListHandle,
|
||||
DraggableListScrollView,
|
||||
type DraggableRenderItemInfo,
|
||||
} from "./draggable-list";
|
||||
import { NestableScrollContainer } from "react-native-draggable-flatlist";
|
||||
import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list";
|
||||
import type { DraggableListDragHandleProps } from "./draggable-list.types";
|
||||
import {
|
||||
getHostRuntimeStore,
|
||||
@@ -79,6 +76,7 @@ import {
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
useContextMenu,
|
||||
} from "@/components/ui/context-menu";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -99,7 +97,7 @@ import {
|
||||
SidebarWorkspaceContextMenu,
|
||||
SidebarWorkspaceMenu,
|
||||
} from "@/components/sidebar/sidebar-workspace-menu";
|
||||
import { useRowPressGuard } from "@/components/sidebar/use-row-press-guard";
|
||||
import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction";
|
||||
import { SidebarSectionHeader } from "@/components/sidebar/sidebar-section-header";
|
||||
import { SidebarGroupToggleRow } from "@/components/sidebar/sidebar-group-toggle-row";
|
||||
import { createWorkspaceVisibilityPolicy } from "@/components/sidebar/sidebar-group-visibility";
|
||||
@@ -242,6 +240,7 @@ interface SidebarWorkspaceListProps {
|
||||
listHeaderComponent?: ReactElement | null;
|
||||
/** Gesture ref for coordinating with parent gestures (e.g., sidebar close) */
|
||||
parentGestureRef?: MutableRefObject<GestureType | undefined>;
|
||||
dragGestureHostActive?: boolean;
|
||||
}
|
||||
|
||||
interface ProjectHeaderRowProps {
|
||||
@@ -258,8 +257,10 @@ interface ProjectHeaderRowProps {
|
||||
onWorktreeCreated?: (workspaceId: string) => void;
|
||||
shortcutNumber?: number | null;
|
||||
showShortcutBadge?: boolean;
|
||||
drag: () => void;
|
||||
isDragging: boolean;
|
||||
isArchiving?: boolean;
|
||||
menuController: ReturnType<typeof useContextMenu> | null;
|
||||
onRemoveProject?: () => void;
|
||||
removeProjectStatus?: "idle" | "pending";
|
||||
dragHandleProps?: DraggableListDragHandleProps;
|
||||
@@ -274,10 +275,12 @@ interface WorkspaceRowInnerProps {
|
||||
shortcutNumber: number | null;
|
||||
showShortcutBadge: boolean;
|
||||
onPress: () => void;
|
||||
drag?: () => void;
|
||||
isDragging: boolean;
|
||||
isArchiving: boolean;
|
||||
isCreating?: boolean;
|
||||
dragHandleProps?: DraggableListDragHandleProps;
|
||||
menuController: ReturnType<typeof useContextMenu> | null;
|
||||
archiveLabel?: string;
|
||||
archiveStatus?: "idle" | "pending" | "success";
|
||||
archivePendingLabel?: string;
|
||||
@@ -671,11 +674,7 @@ function WorkspaceRowRightGroup({
|
||||
{renderSlot ? (
|
||||
<SidebarWorkspaceTrailingActionSlot reserveWidth={reserveSlotWidth}>
|
||||
<SidebarWorkspaceTrailingActionBase presentation={trailingPresentation}>
|
||||
<SidebarWorkspaceTrailingContent
|
||||
workspace={workspace}
|
||||
trailing={trailing}
|
||||
backdrop={backdrop}
|
||||
/>
|
||||
<SidebarWorkspaceTrailingContent workspace={workspace} trailing={trailing} />
|
||||
</SidebarWorkspaceTrailingActionBase>
|
||||
<SidebarWorkspaceTrailingActionOverlay
|
||||
visible={kebab.showKebab}
|
||||
@@ -871,8 +870,10 @@ function ProjectHeaderRow({
|
||||
onWorktreeCreated: _onWorktreeCreated,
|
||||
shortcutNumber = null,
|
||||
showShortcutBadge = false,
|
||||
drag,
|
||||
isDragging,
|
||||
isArchiving = false,
|
||||
menuController,
|
||||
onRemoveProject,
|
||||
removeProjectStatus = "idle",
|
||||
dragHandleProps,
|
||||
@@ -898,14 +899,24 @@ function ProjectHeaderRow({
|
||||
}) as Href,
|
||||
);
|
||||
}, [displayName, onWorkspacePress, worktreeTarget]);
|
||||
const interaction = useRowPressGuard();
|
||||
const interaction = useLongPressDragInteraction({
|
||||
drag,
|
||||
menuController,
|
||||
});
|
||||
const {
|
||||
role: _dragRole,
|
||||
tabIndex: _dragTabIndex,
|
||||
"aria-roledescription": _dragRoleDescription,
|
||||
...dragAttributes
|
||||
} = dragHandleProps?.attributes ?? {};
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
if (interaction.consumeMovedPress()) {
|
||||
if (interaction.didLongPressRef.current) {
|
||||
interaction.didLongPressRef.current = false;
|
||||
return;
|
||||
}
|
||||
onPress();
|
||||
}, [interaction, onPress]);
|
||||
}, [interaction.didLongPressRef, onPress]);
|
||||
|
||||
const handlePointerEnter = useCallback(() => {
|
||||
if (!contextMenuOpen) setIsHovered(true);
|
||||
@@ -981,8 +992,10 @@ function ProjectHeaderRow({
|
||||
|
||||
if (!onRemoveProject) {
|
||||
return (
|
||||
<ProjectHeaderDragHandle
|
||||
dragHandleProps={dragHandleProps}
|
||||
<View
|
||||
{...dragAttributes}
|
||||
{...dragHandleProps?.listeners}
|
||||
ref={dragHandleProps?.setActivatorNodeRef as unknown as Ref<View>}
|
||||
onPointerEnter={handlePointerEnter}
|
||||
onPointerLeave={handlePointerLeave}
|
||||
>
|
||||
@@ -998,14 +1011,16 @@ function ProjectHeaderRow({
|
||||
>
|
||||
{rowChildren}
|
||||
</PressHighlight>
|
||||
</ProjectHeaderDragHandle>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ContextMenu open={contextMenuOpen} onOpenChange={handleContextMenuOpenChange}>
|
||||
<ProjectHeaderDragHandle
|
||||
dragHandleProps={dragHandleProps}
|
||||
<View
|
||||
{...dragAttributes}
|
||||
{...dragHandleProps?.listeners}
|
||||
ref={dragHandleProps?.setActivatorNodeRef as unknown as Ref<View>}
|
||||
onPointerEnter={handlePointerEnter}
|
||||
onPointerLeave={handlePointerLeave}
|
||||
>
|
||||
@@ -1022,7 +1037,7 @@ function ProjectHeaderRow({
|
||||
>
|
||||
{rowChildren}
|
||||
</ContextMenuTrigger>
|
||||
</ProjectHeaderDragHandle>
|
||||
</View>
|
||||
<ContextMenuContent
|
||||
align="start"
|
||||
width={220}
|
||||
@@ -1041,38 +1056,6 @@ function ProjectHeaderRow({
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectHeaderDragHandle({
|
||||
children,
|
||||
dragHandleProps,
|
||||
onPointerEnter,
|
||||
onPointerLeave,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
dragHandleProps: DraggableListDragHandleProps | undefined;
|
||||
onPointerEnter: () => void;
|
||||
onPointerLeave: () => void;
|
||||
}) {
|
||||
const {
|
||||
role: _dragRole,
|
||||
tabIndex: _dragTabIndex,
|
||||
"aria-roledescription": _dragRoleDescription,
|
||||
...dragAttributes
|
||||
} = dragHandleProps?.attributes ?? {};
|
||||
return (
|
||||
<DraggableListHandle dragHandleProps={dragHandleProps}>
|
||||
<View
|
||||
{...dragAttributes}
|
||||
{...dragHandleProps?.listeners}
|
||||
ref={dragHandleProps?.setActivatorNodeRef as unknown as Ref<View>}
|
||||
onPointerEnter={onPointerEnter}
|
||||
onPointerLeave={onPointerLeave}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
</DraggableListHandle>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceRowInner({
|
||||
workspace,
|
||||
hostBadge,
|
||||
@@ -1082,10 +1065,12 @@ function WorkspaceRowInner({
|
||||
shortcutNumber,
|
||||
showShortcutBadge,
|
||||
onPress,
|
||||
drag,
|
||||
isDragging,
|
||||
isArchiving,
|
||||
isCreating = false,
|
||||
dragHandleProps,
|
||||
menuController,
|
||||
archiveLabel,
|
||||
archiveStatus = "idle",
|
||||
archivePendingLabel,
|
||||
@@ -1104,7 +1089,10 @@ function WorkspaceRowInner({
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const [isPressed, setIsPressed] = useState(false);
|
||||
const isTouchPlatform = platformIsNative || isCompact;
|
||||
const interaction = useRowPressGuard();
|
||||
const interaction = useLongPressDragInteraction({
|
||||
drag,
|
||||
menuController,
|
||||
});
|
||||
const {
|
||||
role: _dragRole,
|
||||
tabIndex: _dragTabIndex,
|
||||
@@ -1113,11 +1101,12 @@ function WorkspaceRowInner({
|
||||
} = dragHandleProps?.attributes ?? {};
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
if (interaction.consumeMovedPress()) {
|
||||
if (interaction.didLongPressRef.current) {
|
||||
interaction.didLongPressRef.current = false;
|
||||
return;
|
||||
}
|
||||
onPress();
|
||||
}, [interaction, onPress]);
|
||||
}, [interaction.didLongPressRef, onPress]);
|
||||
const handleWorkspacePressIn = useCallback(
|
||||
(event: GestureResponderEvent) => {
|
||||
setIsPressed(true);
|
||||
@@ -1150,87 +1139,85 @@ function WorkspaceRowInner({
|
||||
});
|
||||
const backdrop = getSidebarRowBackdrop({ isDragging, isPressed, selected, isHovered });
|
||||
return (
|
||||
<DraggableListHandle dragHandleProps={isEditingTitle ? undefined : dragHandleProps}>
|
||||
<View
|
||||
{...dragAttributes}
|
||||
{...(isEditingTitle ? null : dragHandleProps?.listeners)}
|
||||
ref={dragHandleProps?.setActivatorNodeRef as unknown as Ref<View>}
|
||||
style={styles.workspaceRowContainer}
|
||||
{...hoverHandlers}
|
||||
<View
|
||||
{...dragAttributes}
|
||||
{...(isEditingTitle ? null : dragHandleProps?.listeners)}
|
||||
ref={dragHandleProps?.setActivatorNodeRef as unknown as Ref<View>}
|
||||
style={styles.workspaceRowContainer}
|
||||
{...hoverHandlers}
|
||||
>
|
||||
<SidebarWorkspaceContextMenu
|
||||
contextMenuOpen={contextMenuOpen}
|
||||
onContextMenuOpenChange={onContextMenuOpenChange}
|
||||
workspace={workspace}
|
||||
leadingProjectName={leadingProjectName}
|
||||
hostBadgeLabel={hostBadge?.label}
|
||||
workspaceKey={workspace.workspaceKey}
|
||||
onCopyPath={onCopyPath}
|
||||
onCopyBranchName={onCopyBranchName}
|
||||
onRename={onRename}
|
||||
onMarkAsRead={onMarkAsRead}
|
||||
onMarkAsUnread={onMarkAsUnread}
|
||||
onArchive={onArchive}
|
||||
archiveLabel={archiveLabel}
|
||||
archiveStatus={archiveStatus}
|
||||
archivePendingLabel={archivePendingLabel}
|
||||
archiveShortcutKeys={archiveShortcutKeys}
|
||||
isPinned={isPinned}
|
||||
onTogglePin={onTogglePin}
|
||||
openInFileManagerPath={workspace.workspaceDirectory}
|
||||
disabled={isArchiving}
|
||||
enabled={!isEditingTitle}
|
||||
aria-selected={selected}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={accessibilityState}
|
||||
style={workspaceRowStyle}
|
||||
highlightStyle={styles.workspaceRowPressed}
|
||||
onPressIn={isEditingTitle ? undefined : handleWorkspacePressIn}
|
||||
onTouchMove={isEditingTitle ? undefined : interaction.handleTouchMove}
|
||||
onPressOut={isEditingTitle ? undefined : handleWorkspacePressOut}
|
||||
onPress={isEditingTitle ? undefined : handlePress}
|
||||
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
|
||||
>
|
||||
<SidebarWorkspaceContextMenu
|
||||
contextMenuOpen={contextMenuOpen}
|
||||
onContextMenuOpenChange={onContextMenuOpenChange}
|
||||
<SidebarWorkspaceRowContent
|
||||
workspace={workspace}
|
||||
hostBadge={hostBadge}
|
||||
leadingProjectName={leadingProjectName}
|
||||
hostBadgeLabel={hostBadge?.label}
|
||||
workspaceKey={workspace.workspaceKey}
|
||||
onCopyPath={onCopyPath}
|
||||
onCopyBranchName={onCopyBranchName}
|
||||
onRename={onRename}
|
||||
onMarkAsRead={onMarkAsRead}
|
||||
onMarkAsUnread={onMarkAsUnread}
|
||||
onArchive={onArchive}
|
||||
archiveLabel={archiveLabel}
|
||||
archiveStatus={archiveStatus}
|
||||
archivePendingLabel={archivePendingLabel}
|
||||
archiveShortcutKeys={archiveShortcutKeys}
|
||||
isPinned={isPinned}
|
||||
onTogglePin={onTogglePin}
|
||||
openInFileManagerPath={workspace.workspaceDirectory}
|
||||
disabled={isArchiving}
|
||||
enabled={!isEditingTitle}
|
||||
aria-selected={selected}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={accessibilityState}
|
||||
style={workspaceRowStyle}
|
||||
highlightStyle={styles.workspaceRowPressed}
|
||||
onPressIn={isEditingTitle ? undefined : handleWorkspacePressIn}
|
||||
onTouchMove={isEditingTitle ? undefined : interaction.handleTouchMove}
|
||||
onPressOut={isEditingTitle ? undefined : handleWorkspacePressOut}
|
||||
onPress={isEditingTitle ? undefined : handlePress}
|
||||
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
|
||||
leadingProjectIconDataUri={leadingProjectIconDataUri}
|
||||
serviceSummary={serviceSummary}
|
||||
backdrop={backdrop}
|
||||
isHovered={isHovered}
|
||||
isLoading={isArchiving || isCreating}
|
||||
isCreating={isCreating}
|
||||
shortcutNumber={shortcutNumber}
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace}
|
||||
titleEditor={titleEditor}
|
||||
>
|
||||
<SidebarWorkspaceRowContent
|
||||
<WorkspaceRowRightGroup
|
||||
workspace={workspace}
|
||||
hostBadge={hostBadge}
|
||||
leadingProjectName={leadingProjectName}
|
||||
leadingProjectIconDataUri={leadingProjectIconDataUri}
|
||||
serviceSummary={serviceSummary}
|
||||
backdrop={backdrop}
|
||||
isHovered={isHovered}
|
||||
isLoading={isArchiving || isCreating}
|
||||
isTouchPlatform={isTouchPlatform}
|
||||
isCreating={isCreating}
|
||||
shortcutNumber={shortcutNumber}
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace}
|
||||
titleEditor={titleEditor}
|
||||
>
|
||||
<WorkspaceRowRightGroup
|
||||
workspace={workspace}
|
||||
backdrop={backdrop}
|
||||
isHovered={isHovered}
|
||||
isTouchPlatform={isTouchPlatform}
|
||||
isCreating={isCreating}
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
shortcutNumber={shortcutNumber}
|
||||
archiveLabel={archiveLabel}
|
||||
archiveStatus={archiveStatus}
|
||||
archivePendingLabel={archivePendingLabel}
|
||||
archiveShortcutKeys={archiveShortcutKeys}
|
||||
onArchive={onArchive}
|
||||
onCopyBranchName={onCopyBranchName}
|
||||
onCopyPath={onCopyPath}
|
||||
onRename={onRename}
|
||||
onMarkAsRead={onMarkAsRead}
|
||||
onMarkAsUnread={onMarkAsUnread}
|
||||
isPinned={isPinned}
|
||||
onTogglePin={onTogglePin}
|
||||
/>
|
||||
</SidebarWorkspaceRowContent>
|
||||
</SidebarWorkspaceContextMenu>
|
||||
</View>
|
||||
</DraggableListHandle>
|
||||
shortcutNumber={shortcutNumber}
|
||||
archiveLabel={archiveLabel}
|
||||
archiveStatus={archiveStatus}
|
||||
archivePendingLabel={archivePendingLabel}
|
||||
archiveShortcutKeys={archiveShortcutKeys}
|
||||
onArchive={onArchive}
|
||||
onCopyBranchName={onCopyBranchName}
|
||||
onCopyPath={onCopyPath}
|
||||
onRename={onRename}
|
||||
onMarkAsRead={onMarkAsRead}
|
||||
onMarkAsUnread={onMarkAsUnread}
|
||||
isPinned={isPinned}
|
||||
onTogglePin={onTogglePin}
|
||||
/>
|
||||
</SidebarWorkspaceRowContent>
|
||||
</SidebarWorkspaceContextMenu>
|
||||
</View>
|
||||
);
|
||||
}}
|
||||
</SidebarWorkspaceRowFrame>
|
||||
@@ -1246,6 +1233,7 @@ function WorkspaceRowWithMenu({
|
||||
shortcutNumber,
|
||||
showShortcutBadge,
|
||||
onPress,
|
||||
drag,
|
||||
isDragging,
|
||||
dragHandleProps,
|
||||
canCopyBranchName,
|
||||
@@ -1262,6 +1250,7 @@ function WorkspaceRowWithMenu({
|
||||
shortcutNumber: number | null;
|
||||
showShortcutBadge: boolean;
|
||||
onPress: () => void;
|
||||
drag?: () => void;
|
||||
isDragging: boolean;
|
||||
dragHandleProps?: DraggableListDragHandleProps;
|
||||
canCopyBranchName: boolean;
|
||||
@@ -1354,10 +1343,12 @@ function WorkspaceRowWithMenu({
|
||||
shortcutNumber={shortcutNumber}
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
onPress={onPress}
|
||||
drag={drag}
|
||||
isDragging={isDragging}
|
||||
isArchiving={isArchiving}
|
||||
isCreating={isCreating}
|
||||
dragHandleProps={dragHandleProps}
|
||||
menuController={null}
|
||||
archiveLabel={t("sidebar.workspace.actions.archive")}
|
||||
archiveStatus={isArchiving ? "pending" : "idle"}
|
||||
archivePendingLabel={t("sidebar.workspace.actions.archiving")}
|
||||
@@ -1394,6 +1385,7 @@ interface WorkspaceRowItemProps {
|
||||
selectionEnabled: boolean;
|
||||
activeWorkspaceSelection: ActiveWorkspaceSelection | null;
|
||||
onWorkspacePress?: () => void;
|
||||
drag?: () => void;
|
||||
isDragging?: boolean;
|
||||
dragHandleProps?: DraggableListDragHandleProps;
|
||||
}
|
||||
@@ -1413,6 +1405,7 @@ function WorkspaceRowItem({
|
||||
isCreating = false,
|
||||
selectionEnabled,
|
||||
activeWorkspaceSelection,
|
||||
drag,
|
||||
isDragging = false,
|
||||
dragHandleProps,
|
||||
}: WorkspaceRowItemProps) {
|
||||
@@ -1443,6 +1436,7 @@ function WorkspaceRowItem({
|
||||
enabled: selectionEnabled,
|
||||
})}
|
||||
onPress={handlePress}
|
||||
drag={drag}
|
||||
isDragging={isDragging}
|
||||
dragHandleProps={dragHandleProps}
|
||||
/>
|
||||
@@ -1479,6 +1473,7 @@ function areWorkspaceRowItemPropsEqual(
|
||||
previous.reserveIdleStatusIndicatorSpace === next.reserveIdleStatusIndicatorSpace &&
|
||||
previous.isCreating === next.isCreating &&
|
||||
previous.onWorkspacePress === next.onWorkspacePress &&
|
||||
previous.drag === next.drag &&
|
||||
previous.isDragging === next.isDragging &&
|
||||
previous.dragHandleProps === next.dragHandleProps &&
|
||||
previousSelected === nextSelected
|
||||
@@ -1495,6 +1490,7 @@ function WorkspaceRow({
|
||||
shortcutNumber,
|
||||
showShortcutBadge,
|
||||
onPress,
|
||||
drag,
|
||||
isDragging,
|
||||
dragHandleProps,
|
||||
canCopyBranchName,
|
||||
@@ -1511,6 +1507,7 @@ function WorkspaceRow({
|
||||
shortcutNumber: number | null;
|
||||
showShortcutBadge: boolean;
|
||||
onPress: () => void;
|
||||
drag?: () => void;
|
||||
isDragging: boolean;
|
||||
dragHandleProps?: DraggableListDragHandleProps;
|
||||
canCopyBranchName: boolean;
|
||||
@@ -1534,6 +1531,7 @@ function WorkspaceRow({
|
||||
shortcutNumber={shortcutNumber}
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
onPress={onPress}
|
||||
drag={drag}
|
||||
isDragging={isDragging}
|
||||
dragHandleProps={dragHandleProps}
|
||||
canCopyBranchName={canCopyBranchName}
|
||||
@@ -1556,9 +1554,13 @@ function ProjectBlock({
|
||||
shortcutIndexByWorkspaceKey,
|
||||
onToggleCollapsed,
|
||||
onWorkspacePress,
|
||||
parentGestureRef,
|
||||
onWorkspaceReorder,
|
||||
useNestable,
|
||||
dragGestureHostActive,
|
||||
manualSort,
|
||||
onWorktreeCreated,
|
||||
drag,
|
||||
isDragging,
|
||||
dragHandleProps,
|
||||
creatingWorkspaceIds,
|
||||
@@ -1578,9 +1580,13 @@ function ProjectBlock({
|
||||
shortcutIndexByWorkspaceKey: Map<string, number>;
|
||||
onToggleCollapsed: (projectViewKey: string) => void;
|
||||
onWorkspacePress?: () => void;
|
||||
parentGestureRef?: MutableRefObject<GestureType | undefined>;
|
||||
onWorkspaceReorder: (projectViewKey: string, workspaces: SidebarWorkspacePlacement[]) => void;
|
||||
useNestable: boolean;
|
||||
dragGestureHostActive?: boolean;
|
||||
manualSort: boolean;
|
||||
onWorktreeCreated?: (workspaceId: string) => void;
|
||||
drag: () => void;
|
||||
isDragging: boolean;
|
||||
dragHandleProps?: DraggableListDragHandleProps;
|
||||
creatingWorkspaceIds: ReadonlySet<string>;
|
||||
@@ -1636,6 +1642,7 @@ function ProjectBlock({
|
||||
(
|
||||
item: SidebarWorkspacePlacement,
|
||||
input?: {
|
||||
drag?: () => void;
|
||||
isDragging?: boolean;
|
||||
dragHandleProps?: DraggableListDragHandleProps;
|
||||
},
|
||||
@@ -1655,6 +1662,7 @@ function ProjectBlock({
|
||||
selectionEnabled={selectionEnabled}
|
||||
activeWorkspaceSelection={activeWorkspaceSelection}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
drag={input?.drag}
|
||||
isDragging={input?.isDragging}
|
||||
dragHandleProps={input?.dragHandleProps}
|
||||
/>
|
||||
@@ -1678,10 +1686,12 @@ function ProjectBlock({
|
||||
const renderWorkspace = useCallback(
|
||||
({
|
||||
item,
|
||||
drag: workspaceDrag,
|
||||
isActive,
|
||||
dragHandleProps: workspaceDragHandleProps,
|
||||
}: DraggableRenderItemInfo<SidebarWorkspacePlacement>) => {
|
||||
return renderWorkspaceRow(item, {
|
||||
drag: workspaceDrag,
|
||||
isDragging: isActive,
|
||||
dragHandleProps: workspaceDragHandleProps,
|
||||
});
|
||||
@@ -1771,7 +1781,12 @@ function ProjectBlock({
|
||||
keyExtractor={workspaceKeyExtractor}
|
||||
renderItem={renderWorkspace}
|
||||
onDragEnd={handleWorkspaceDragEnd}
|
||||
extraData={activeWorkspaceSelectionKey(activeWorkspaceSelection)}
|
||||
scrollEnabled={false}
|
||||
useDragHandle
|
||||
nestable={useNestable}
|
||||
simultaneousGestureRef={parentGestureRef}
|
||||
gestureHostPresented={dragGestureHostActive}
|
||||
containerStyle={styles.workspaceListContainer}
|
||||
/>
|
||||
</>
|
||||
@@ -1825,8 +1840,10 @@ function ProjectBlock({
|
||||
isProjectActive={active}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
onWorktreeCreated={onWorktreeCreated}
|
||||
drag={drag}
|
||||
isDragging={isDragging}
|
||||
isArchiving={isRemovingProject}
|
||||
menuController={null}
|
||||
onRemoveProject={handleRemoveProject}
|
||||
removeProjectStatus={isRemovingProject ? "pending" : "idle"}
|
||||
dragHandleProps={dragHandleProps}
|
||||
@@ -1856,9 +1873,13 @@ function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlo
|
||||
previous.onToggleWorkspacePin === next.onToggleWorkspacePin &&
|
||||
previous.onToggleCollapsed === next.onToggleCollapsed &&
|
||||
previous.onWorkspacePress === next.onWorkspacePress &&
|
||||
previous.parentGestureRef === next.parentGestureRef &&
|
||||
previous.onWorkspaceReorder === next.onWorkspaceReorder &&
|
||||
previous.useNestable === next.useNestable &&
|
||||
previous.dragGestureHostActive === next.dragGestureHostActive &&
|
||||
previous.manualSort === next.manualSort &&
|
||||
previous.onWorktreeCreated === next.onWorktreeCreated &&
|
||||
previous.drag === next.drag &&
|
||||
previous.isDragging === next.isDragging &&
|
||||
previous.dragHandleProps === next.dragHandleProps &&
|
||||
previous.creatingWorkspaceIds === next.creatingWorkspaceIds &&
|
||||
@@ -1940,6 +1961,7 @@ export function SidebarWorkspaceList({
|
||||
listFooterComponent,
|
||||
listHeaderComponent,
|
||||
parentGestureRef,
|
||||
dragGestureHostActive,
|
||||
}: SidebarWorkspaceListProps) {
|
||||
const pathname = usePathname();
|
||||
const hosts = useHosts();
|
||||
@@ -2011,6 +2033,8 @@ export function SidebarWorkspaceList({
|
||||
onPinnedWorkspaceReorder={handlePinnedWorkspaceReorder}
|
||||
listHeaderComponent={listHeaderComponent}
|
||||
sidebarFilterEmpty={sidebarFilterEmpty}
|
||||
parentGestureRef={parentGestureRef}
|
||||
dragGestureHostActive={dragGestureHostActive}
|
||||
/>
|
||||
) : (
|
||||
<ProjectModeList
|
||||
@@ -2029,6 +2053,7 @@ export function SidebarWorkspaceList({
|
||||
sidebarFilterEmpty={sidebarFilterEmpty}
|
||||
hasActiveProjectFilter={hasActiveProjectFilter}
|
||||
parentGestureRef={parentGestureRef}
|
||||
dragGestureHostActive={dragGestureHostActive}
|
||||
pathname={pathname}
|
||||
hostBadgeByServerId={hostBadgeByServerId}
|
||||
supportsMultiplicityByServerId={supportsMultiplicityByServerId}
|
||||
@@ -2060,6 +2085,8 @@ function SidebarGroupedModeList({
|
||||
onPinnedWorkspaceReorder,
|
||||
listHeaderComponent,
|
||||
sidebarFilterEmpty,
|
||||
parentGestureRef,
|
||||
dragGestureHostActive,
|
||||
}: {
|
||||
workspaceGroups: SidebarWorkspaceGroup[];
|
||||
pinnedGroups: PinnedSidebarGroups;
|
||||
@@ -2073,6 +2100,8 @@ function SidebarGroupedModeList({
|
||||
onPinnedWorkspaceReorder: (workspaces: SidebarWorkspacePlacement[]) => void;
|
||||
listHeaderComponent?: ReactElement | null;
|
||||
sidebarFilterEmpty: boolean;
|
||||
parentGestureRef?: MutableRefObject<GestureType | undefined>;
|
||||
dragGestureHostActive?: boolean;
|
||||
}) {
|
||||
const showShortcutBadges = useShowShortcutBadges();
|
||||
const pinnedWorkspaces = useMemo(
|
||||
@@ -2098,6 +2127,8 @@ function SidebarGroupedModeList({
|
||||
onPinnedWorkspaceReorder={onPinnedWorkspaceReorder}
|
||||
listHeaderComponent={listHeaderComponent}
|
||||
sidebarFilterEmpty={sidebarFilterEmpty}
|
||||
parentGestureRef={parentGestureRef}
|
||||
dragGestureHostActive={dragGestureHostActive}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -2118,6 +2149,7 @@ function ProjectModeList({
|
||||
sidebarFilterEmpty,
|
||||
hasActiveProjectFilter,
|
||||
parentGestureRef,
|
||||
dragGestureHostActive,
|
||||
pathname,
|
||||
hostBadgeByServerId,
|
||||
supportsMultiplicityByServerId,
|
||||
@@ -2178,6 +2210,19 @@ function ProjectModeList({
|
||||
items: pinnedChats,
|
||||
selection: selectionEnabled ? activeWorkspaceSelection : null,
|
||||
});
|
||||
const nativeScrollGestureProps = useMemo(
|
||||
() =>
|
||||
parentGestureRef
|
||||
? ({
|
||||
// NestableScrollContainer forwards props to RNGH ScrollView. Keep
|
||||
// vertical scroll and sidebar close pan simultaneous: vertical
|
||||
// intent scrolls immediately, clear horizontal intent can still
|
||||
// activate close from inside the list.
|
||||
simultaneousHandlers: parentGestureRef,
|
||||
} as object)
|
||||
: undefined,
|
||||
[parentGestureRef],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const timeouts = creatingWorkspaceTimeoutsRef.current;
|
||||
@@ -2302,6 +2347,7 @@ function ProjectModeList({
|
||||
(
|
||||
item: SidebarProjectEntry,
|
||||
dragState: {
|
||||
drag: () => void;
|
||||
isDragging: boolean;
|
||||
dragHandleProps?: DraggableRenderItemInfo<SidebarProjectEntry>["dragHandleProps"];
|
||||
},
|
||||
@@ -2320,8 +2366,12 @@ function ProjectModeList({
|
||||
onToggleCollapsed={onToggleProjectCollapsed}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
manualSort={manualSort}
|
||||
parentGestureRef={parentGestureRef}
|
||||
onWorkspaceReorder={handleWorkspaceReorder}
|
||||
useNestable={platformIsNative}
|
||||
dragGestureHostActive={dragGestureHostActive}
|
||||
onWorktreeCreated={handleWorktreeCreated}
|
||||
drag={dragState.drag}
|
||||
isDragging={dragState.isDragging}
|
||||
dragHandleProps={dragState.dragHandleProps}
|
||||
creatingWorkspaceIds={creatingWorkspaceIds}
|
||||
@@ -2338,6 +2388,8 @@ function ProjectModeList({
|
||||
activeWorkspaceSelection,
|
||||
handleWorktreeCreated,
|
||||
manualSort,
|
||||
parentGestureRef,
|
||||
dragGestureHostActive,
|
||||
handleWorkspaceReorder,
|
||||
hostBadgeByServerId,
|
||||
supportsMultiplicityByServerId,
|
||||
@@ -2355,8 +2407,8 @@ function ProjectModeList({
|
||||
);
|
||||
|
||||
const renderProject = useCallback(
|
||||
({ item, isActive, dragHandleProps }: DraggableRenderItemInfo<SidebarProjectEntry>) =>
|
||||
renderProjectBlock(item, { isDragging: isActive, dragHandleProps }),
|
||||
({ item, drag, isActive, dragHandleProps }: DraggableRenderItemInfo<SidebarProjectEntry>) =>
|
||||
renderProjectBlock(item, { drag, isDragging: isActive, dragHandleProps }),
|
||||
[renderProjectBlock],
|
||||
);
|
||||
|
||||
@@ -2364,6 +2416,7 @@ function ProjectModeList({
|
||||
(
|
||||
workspace: SidebarWorkspacePlacement,
|
||||
input?: {
|
||||
drag?: () => void;
|
||||
isDragging?: boolean;
|
||||
dragHandleProps?: DraggableListDragHandleProps;
|
||||
},
|
||||
@@ -2386,6 +2439,7 @@ function ProjectModeList({
|
||||
selectionEnabled={selectionEnabled}
|
||||
activeWorkspaceSelection={activeWorkspaceSelection}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
drag={input?.drag}
|
||||
isDragging={input?.isDragging}
|
||||
dragHandleProps={input?.dragHandleProps}
|
||||
/>
|
||||
@@ -2408,10 +2462,12 @@ function ProjectModeList({
|
||||
const renderPinnedChat = useCallback(
|
||||
({
|
||||
item: workspace,
|
||||
drag,
|
||||
isActive,
|
||||
dragHandleProps,
|
||||
}: DraggableRenderItemInfo<SidebarWorkspacePlacement>) => {
|
||||
return renderPinnedChatRow(workspace, {
|
||||
drag,
|
||||
isDragging: isActive,
|
||||
dragHandleProps,
|
||||
});
|
||||
@@ -2429,7 +2485,12 @@ function ProjectModeList({
|
||||
keyExtractor={projectViewKeyExtractor}
|
||||
renderItem={renderProject}
|
||||
onDragEnd={handleProjectDragEnd}
|
||||
extraData={activeWorkspaceSelectionKey(activeWorkspaceSelection)}
|
||||
scrollEnabled={false}
|
||||
useDragHandle
|
||||
nestable={platformIsNative}
|
||||
simultaneousGestureRef={parentGestureRef}
|
||||
gestureHostPresented={dragGestureHostActive}
|
||||
containerStyle={styles.projectListContainer}
|
||||
/>
|
||||
);
|
||||
@@ -2453,7 +2514,12 @@ function ProjectModeList({
|
||||
keyExtractor={workspaceKeyExtractor}
|
||||
renderItem={renderPinnedChat}
|
||||
onDragEnd={onPinnedWorkspaceReorder}
|
||||
extraData={activeWorkspaceSelectionKey(activeWorkspaceSelection)}
|
||||
scrollEnabled={false}
|
||||
useDragHandle
|
||||
nestable={platformIsNative}
|
||||
simultaneousGestureRef={parentGestureRef}
|
||||
gestureHostPresented={dragGestureHostActive}
|
||||
containerStyle={styles.workspaceListContainer}
|
||||
/>
|
||||
{canTogglePinnedChats ? (
|
||||
@@ -2486,17 +2552,26 @@ function ProjectModeList({
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* Vertical scroll and the sidebar close pan recognize together: vertical intent
|
||||
scrolls immediately, and clear horizontal intent still closes from inside the list. */}
|
||||
<DraggableListScrollView
|
||||
simultaneousGestureRef={parentGestureRef}
|
||||
style={styles.list}
|
||||
contentContainerStyle={styles.listContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
testID="sidebar-project-workspace-list-scroll"
|
||||
>
|
||||
{content}
|
||||
</DraggableListScrollView>
|
||||
{platformIsNative ? (
|
||||
<NestableScrollContainer
|
||||
{...nativeScrollGestureProps}
|
||||
style={styles.list}
|
||||
contentContainerStyle={styles.listContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
testID="sidebar-project-workspace-list-scroll"
|
||||
>
|
||||
{content}
|
||||
</NestableScrollContainer>
|
||||
) : (
|
||||
<ScrollView
|
||||
style={styles.list}
|
||||
contentContainerStyle={styles.listContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
testID="sidebar-project-workspace-list-scroll"
|
||||
>
|
||||
{content}
|
||||
</ScrollView>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -2513,9 +2588,7 @@ 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],
|
||||
paddingBottom: theme.spacing[4],
|
||||
},
|
||||
projectListContainer: {
|
||||
width: "100%",
|
||||
|
||||
@@ -58,8 +58,6 @@ import { SIDEBAR_ROW_ITEMS, type SidebarRowItem } from "./row-items";
|
||||
import { useWorkspaceLabelProjection } from "@/workspace-labels";
|
||||
import { WorkspaceLabelDot } from "@/workspace-labels/swatch";
|
||||
import { WorkspaceLabelManagerModal } from "@/workspace-labels/manager-modal";
|
||||
import { PluginIconView } from "@/plugins/icons";
|
||||
import type { PluginWorkspaceRowBadgeItemGroup } from "@/plugins/workspace-row-badge-items";
|
||||
|
||||
const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
@@ -67,7 +65,6 @@ const ThemedSettings2 = withUnistyles(Settings2);
|
||||
/** CI's mark: the subject of the checks row, and the shape the icon-only option leaves behind. */
|
||||
const ThemedCircleCheck = withUnistyles(CircleCheck);
|
||||
const ThemedCircle = withUnistyles(Circle);
|
||||
const ThemedPluginIconView = withUnistyles(PluginIconView);
|
||||
|
||||
/** Fits the item's 16pt leading slot with a hair of room, matching the trailing check. */
|
||||
const OPTION_ICON_SIZE = 14;
|
||||
@@ -610,9 +607,9 @@ function OptionList<Value extends string>({
|
||||
}
|
||||
|
||||
/**
|
||||
* Three groups: Paseo's own row items, plugin badge items, then the one thing the slot to the
|
||||
* right of the title holds. Picking the trailing choice already showing empties the slot and
|
||||
* gives the width back to the title.
|
||||
* Two groups, split by the separator. Above it, what a row may say about a workspace — each one
|
||||
* independent. Below it, the one thing the slot to the right of the title holds, so picking the
|
||||
* one already showing empties the slot and gives the width back to the title.
|
||||
*
|
||||
* CI is the one item above the separator with three answers rather than two, so it opens a page
|
||||
* instead of ticking, and it goes last: a row that navigates does not belong in the middle of a
|
||||
@@ -635,12 +632,6 @@ function ShowPage({ preferences }: { preferences: Preferences }): ReactElement {
|
||||
/>
|
||||
))}
|
||||
<ChecksSubTrigger />
|
||||
{preferences.pluginRowBadgeItems.length > 0 ? (
|
||||
<MenuSeparator testID="sidebar-plugin-row-badge-items-separator" />
|
||||
) : null}
|
||||
{preferences.pluginRowBadgeItems.map((item) => (
|
||||
<PluginRowBadgeItemOption key={item.key} item={item} preferences={preferences} />
|
||||
))}
|
||||
<MenuSeparator />
|
||||
{TRAILING_CHOICES.map((choice) => (
|
||||
<OptionItem
|
||||
@@ -658,37 +649,6 @@ function ShowPage({ preferences }: { preferences: Preferences }): ReactElement {
|
||||
);
|
||||
}
|
||||
|
||||
/** A plugin's workspace row badge item, toggled like the row's own. */
|
||||
function PluginRowBadgeItemOption({
|
||||
item,
|
||||
preferences,
|
||||
}: {
|
||||
item: PluginWorkspaceRowBadgeItemGroup;
|
||||
preferences: Preferences;
|
||||
}): ReactElement {
|
||||
const handleSelect = useCallback(
|
||||
() => preferences.togglePluginRowBadgeItem(item.key),
|
||||
[item.key, preferences],
|
||||
);
|
||||
const leading = useMemo(
|
||||
() => (
|
||||
<ThemedPluginIconView icon={item.icon} size={OPTION_ICON_SIZE} uniProps={mutedIconMapping} />
|
||||
),
|
||||
[item.icon],
|
||||
);
|
||||
return (
|
||||
<MenuItem
|
||||
selected={preferences.isPluginRowBadgeItemVisible(item.key)}
|
||||
leading={leading}
|
||||
closeOnSelect={false}
|
||||
onSelect={handleSelect}
|
||||
testID={`sidebar-row-badge-item-plugin-${item.pluginId}-${item.itemId}`}
|
||||
>
|
||||
{item.title}
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* No value on the row, only the chevron. The three answers are all long enough that the value
|
||||
* fills the row and stops reading as an answer sitting at the right edge — it turns into a second
|
||||
|
||||
@@ -10,12 +10,6 @@ import {
|
||||
type SidebarGroupMode,
|
||||
type SidebarLabelFilter,
|
||||
} from "@/stores/sidebar-view-store";
|
||||
import { useInstalledPlugins } from "@/plugins/registry";
|
||||
import {
|
||||
groupPluginWorkspaceRowBadgeItems,
|
||||
isWorkspaceRowBadgeItemVisible,
|
||||
type PluginWorkspaceRowBadgeItemGroup,
|
||||
} from "@/plugins/workspace-row-badge-items";
|
||||
import { DEFAULT_SIDEBAR_CHECKS_DISPLAY, type SidebarChecksDisplay } from "./checks-display";
|
||||
import { DEFAULT_SIDEBAR_ROW_ITEMS, type SidebarRowItem, type SidebarRowItems } from "./row-items";
|
||||
|
||||
@@ -33,10 +27,6 @@ export interface SidebarDisplayPreferences {
|
||||
setTitleSource: (source: WorkspaceTitleSource) => void;
|
||||
rowItems: SidebarRowItems;
|
||||
toggleRowItem: (item: SidebarRowItem) => void;
|
||||
/** Workspace row badge items contributed by installed plugins, one per plugin item across hosts. */
|
||||
pluginRowBadgeItems: readonly PluginWorkspaceRowBadgeItemGroup[];
|
||||
isPluginRowBadgeItemVisible: (key: string) => boolean;
|
||||
togglePluginRowBadgeItem: (key: string) => void;
|
||||
checksDisplay: SidebarChecksDisplay;
|
||||
setChecksDisplay: (display: SidebarChecksDisplay) => void;
|
||||
trailing: SidebarWorkspaceTrailing;
|
||||
@@ -85,30 +75,9 @@ export function useSidebarDisplayPreferences(): SidebarDisplayPreferences {
|
||||
sidebarWorkspaceTrailing,
|
||||
sidebarRowItems,
|
||||
sidebarChecksDisplay,
|
||||
sidebarPluginRowBadgeItems,
|
||||
},
|
||||
updateSettings,
|
||||
} = useAppSettings();
|
||||
const installedPlugins = useInstalledPlugins();
|
||||
const pluginRowBadgeItems = useMemo(
|
||||
() => groupPluginWorkspaceRowBadgeItems(installedPlugins),
|
||||
[installedPlugins],
|
||||
);
|
||||
const isPluginRowBadgeItemVisible = useCallback(
|
||||
(key: string) => isWorkspaceRowBadgeItemVisible(sidebarPluginRowBadgeItems, key),
|
||||
[sidebarPluginRowBadgeItems],
|
||||
);
|
||||
const togglePluginRowBadgeItem = useCallback(
|
||||
(key: string) => {
|
||||
void updateSettings({
|
||||
sidebarPluginRowBadgeItems: {
|
||||
...sidebarPluginRowBadgeItems,
|
||||
[key]: !isWorkspaceRowBadgeItemVisible(sidebarPluginRowBadgeItems, key),
|
||||
},
|
||||
});
|
||||
},
|
||||
[updateSettings, sidebarPluginRowBadgeItems],
|
||||
);
|
||||
|
||||
const setTitleSource = useCallback(
|
||||
(source: WorkspaceTitleSource) => {
|
||||
@@ -154,9 +123,6 @@ export function useSidebarDisplayPreferences(): SidebarDisplayPreferences {
|
||||
setTitleSource,
|
||||
rowItems: sidebarRowItems,
|
||||
toggleRowItem,
|
||||
pluginRowBadgeItems,
|
||||
isPluginRowBadgeItemVisible,
|
||||
togglePluginRowBadgeItem,
|
||||
checksDisplay: sidebarChecksDisplay,
|
||||
setChecksDisplay,
|
||||
trailing: sidebarWorkspaceTrailing,
|
||||
@@ -182,9 +148,6 @@ export function useSidebarDisplayPreferences(): SidebarDisplayPreferences {
|
||||
setTitleSource,
|
||||
sidebarRowItems,
|
||||
toggleRowItem,
|
||||
pluginRowBadgeItems,
|
||||
isPluginRowBadgeItemVisible,
|
||||
togglePluginRowBadgeItem,
|
||||
sidebarChecksDisplay,
|
||||
setChecksDisplay,
|
||||
sidebarWorkspaceTrailing,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { PressHighlight as Pressable } from "@/components/ui/press-highlight";
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { PressHighlight as Pressable } from "@/components/ui/press-highlight";
|
||||
import { useCallback, useState, type ComponentType, type ReactNode, type Ref } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from "@/constants/layout";
|
||||
import { DensityIcon } from "@/components/ui/density-icon";
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { PressHighlight as Pressable } from "@/components/ui/press-highlight";
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react-native";
|
||||
import { Text, View } from "react-native";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { DensityIcon } from "@/components/ui/density-icon";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
import { PressHighlight as Pressable } from "@/components/ui/press-highlight";
|
||||
import { memo, useCallback, useMemo, useState, type ReactNode, type Ref } from "react";
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useState,
|
||||
type MutableRefObject,
|
||||
type ReactNode,
|
||||
type Ref,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
type GestureResponderEvent,
|
||||
type PressableStateCallbackType,
|
||||
} 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";
|
||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import { type SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||
@@ -66,14 +77,9 @@ import { SidebarSectionHeader } from "@/components/sidebar/sidebar-section-heade
|
||||
import { SidebarGroupToggleRow } from "@/components/sidebar/sidebar-group-toggle-row";
|
||||
import { useLimitedSidebarGroup } from "@/components/sidebar/use-limited-sidebar-group";
|
||||
import type { ToggleSidebarWorkspacePin } from "@/hooks/use-sidebar-workspace-pin";
|
||||
import {
|
||||
DraggableList,
|
||||
DraggableListHandle,
|
||||
DraggableListScrollView,
|
||||
type DraggableRenderItemInfo,
|
||||
} from "@/components/draggable-list";
|
||||
import { DraggableList, type DraggableRenderItemInfo } from "@/components/draggable-list";
|
||||
import type { DraggableListDragHandleProps } from "@/components/draggable-list.types";
|
||||
import { useRowPressGuard } from "@/components/sidebar/use-row-press-guard";
|
||||
import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction";
|
||||
|
||||
// Themed icon wrappers
|
||||
const mutedGroupIconProps = densityIconProps("sm", (theme) => ({
|
||||
@@ -120,6 +126,8 @@ interface StatusWorkspaceListProps {
|
||||
listHeaderComponent?: ReactNode;
|
||||
/** Swaps the group list for the label filter's empty state. Never the header above it. */
|
||||
sidebarFilterEmpty?: boolean;
|
||||
parentGestureRef?: MutableRefObject<GestureType | undefined>;
|
||||
dragGestureHostActive?: boolean;
|
||||
}
|
||||
|
||||
export function SidebarStatusWorkspaceList({
|
||||
@@ -135,6 +143,8 @@ export function SidebarStatusWorkspaceList({
|
||||
onPinnedWorkspaceReorder,
|
||||
listHeaderComponent,
|
||||
sidebarFilterEmpty = false,
|
||||
parentGestureRef,
|
||||
dragGestureHostActive,
|
||||
}: StatusWorkspaceListProps) {
|
||||
const { t } = useTranslation();
|
||||
const activeWorkspaceSelection = useActiveWorkspaceSelection();
|
||||
@@ -160,6 +170,7 @@ export function SidebarStatusWorkspaceList({
|
||||
(
|
||||
workspace: SidebarWorkspaceEntry,
|
||||
input?: {
|
||||
drag?: () => void;
|
||||
isDragging?: boolean;
|
||||
dragHandleProps?: DraggableListDragHandleProps;
|
||||
},
|
||||
@@ -178,6 +189,7 @@ export function SidebarStatusWorkspaceList({
|
||||
canPin={supportsPinningByServerId.get(workspace.serverId) === true}
|
||||
onToggleWorkspacePin={onToggleWorkspacePin}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
drag={input?.drag}
|
||||
isDragging={input?.isDragging}
|
||||
dragHandleProps={input?.dragHandleProps}
|
||||
/>
|
||||
@@ -195,10 +207,12 @@ export function SidebarStatusWorkspaceList({
|
||||
const renderPinnedWorkspace = useCallback(
|
||||
({
|
||||
item: workspace,
|
||||
drag,
|
||||
isActive,
|
||||
dragHandleProps,
|
||||
}: DraggableRenderItemInfo<SidebarWorkspaceEntry>) =>
|
||||
renderPinnedWorkspaceRow(workspace, {
|
||||
drag,
|
||||
isDragging: isActive,
|
||||
dragHandleProps,
|
||||
}),
|
||||
@@ -223,7 +237,11 @@ export function SidebarStatusWorkspaceList({
|
||||
keyExtractor={statusWorkspaceKeyExtractor}
|
||||
renderItem={renderPinnedWorkspace}
|
||||
onDragEnd={onPinnedWorkspaceReorder}
|
||||
scrollEnabled={false}
|
||||
useDragHandle
|
||||
nestable={platformIsNative}
|
||||
simultaneousGestureRef={parentGestureRef}
|
||||
gestureHostPresented={dragGestureHostActive}
|
||||
/>
|
||||
{canTogglePinnedWorkspaces ? (
|
||||
<SidebarGroupToggleRow
|
||||
@@ -257,14 +275,25 @@ export function SidebarStatusWorkspaceList({
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<DraggableListScrollView
|
||||
style={styles.list}
|
||||
contentContainerStyle={styles.listContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
testID="sidebar-status-list-scroll"
|
||||
>
|
||||
{content}
|
||||
</DraggableListScrollView>
|
||||
{platformIsNative ? (
|
||||
<NestableScrollContainer
|
||||
style={styles.list}
|
||||
contentContainerStyle={styles.listContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
testID="sidebar-status-list-scroll"
|
||||
>
|
||||
{content}
|
||||
</NestableScrollContainer>
|
||||
) : (
|
||||
<ScrollView
|
||||
style={styles.list}
|
||||
contentContainerStyle={styles.listContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
testID="sidebar-status-list-scroll"
|
||||
>
|
||||
{content}
|
||||
</ScrollView>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -514,6 +543,7 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
|
||||
onToggleWorkspacePin,
|
||||
reserveIdleStatusIndicatorSpace = true,
|
||||
inStatusGroup = true,
|
||||
drag,
|
||||
isDragging = false,
|
||||
dragHandleProps,
|
||||
}: {
|
||||
@@ -532,6 +562,7 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
|
||||
*/
|
||||
inStatusGroup?: boolean;
|
||||
onWorkspacePress?: () => void;
|
||||
drag?: () => void;
|
||||
isDragging?: boolean;
|
||||
dragHandleProps?: DraggableListDragHandleProps;
|
||||
}) {
|
||||
@@ -559,6 +590,7 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
|
||||
reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace}
|
||||
inStatusGroup={inStatusGroup}
|
||||
onPress={handlePress}
|
||||
drag={drag}
|
||||
isDragging={isDragging}
|
||||
dragHandleProps={dragHandleProps}
|
||||
/>
|
||||
@@ -578,6 +610,7 @@ function StatusWorkspaceRowWithMenu({
|
||||
reserveIdleStatusIndicatorSpace = true,
|
||||
inStatusGroup = true,
|
||||
onPress,
|
||||
drag,
|
||||
isDragging = false,
|
||||
dragHandleProps,
|
||||
}: {
|
||||
@@ -597,6 +630,7 @@ function StatusWorkspaceRowWithMenu({
|
||||
*/
|
||||
inStatusGroup?: boolean;
|
||||
onPress: () => void;
|
||||
drag?: () => void;
|
||||
isDragging?: boolean;
|
||||
dragHandleProps?: DraggableListDragHandleProps;
|
||||
}) {
|
||||
@@ -701,6 +735,7 @@ function StatusWorkspaceRowWithMenu({
|
||||
onTogglePin={onTogglePin}
|
||||
reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace}
|
||||
inStatusGroup={inStatusGroup}
|
||||
drag={drag}
|
||||
isDragging={isDragging}
|
||||
dragHandleProps={dragHandleProps}
|
||||
/>
|
||||
@@ -736,20 +771,26 @@ interface StatusWorkspaceRowInnerProps {
|
||||
reserveIdleStatusIndicatorSpace?: boolean;
|
||||
/** Pinned rows are flat under their own header; status-group rows indent from theirs. */
|
||||
inStatusGroup?: boolean;
|
||||
drag?: () => void;
|
||||
isDragging?: boolean;
|
||||
dragHandleProps?: DraggableListDragHandleProps;
|
||||
}
|
||||
|
||||
function StatusWorkspaceRowInner(props: StatusWorkspaceRowInnerProps) {
|
||||
if (props.dragHandleProps) {
|
||||
return <DraggableStatusWorkspaceRowInner {...props} />;
|
||||
if (props.drag) {
|
||||
return <DraggableStatusWorkspaceRowInner {...props} drag={props.drag} />;
|
||||
}
|
||||
return <StatusWorkspaceRowInnerContent {...props} />;
|
||||
}
|
||||
|
||||
function DraggableStatusWorkspaceRowInner(props: StatusWorkspaceRowInnerProps) {
|
||||
const pressGuard = useRowPressGuard();
|
||||
return <StatusWorkspaceRowInnerContent {...props} pressGuard={pressGuard} />;
|
||||
function DraggableStatusWorkspaceRowInner(
|
||||
props: StatusWorkspaceRowInnerProps & { drag: () => void },
|
||||
) {
|
||||
const dragInteraction = useLongPressDragInteraction({
|
||||
drag: props.drag,
|
||||
menuController: null,
|
||||
});
|
||||
return <StatusWorkspaceRowInnerContent {...props} dragInteraction={dragInteraction} />;
|
||||
}
|
||||
|
||||
function StatusWorkspaceRowInnerContent({
|
||||
@@ -779,9 +820,9 @@ function StatusWorkspaceRowInnerContent({
|
||||
inStatusGroup = true,
|
||||
isDragging = false,
|
||||
dragHandleProps,
|
||||
pressGuard,
|
||||
dragInteraction,
|
||||
}: StatusWorkspaceRowInnerProps & {
|
||||
pressGuard?: ReturnType<typeof useRowPressGuard>;
|
||||
dragInteraction?: ReturnType<typeof useLongPressDragInteraction>;
|
||||
}) {
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const isTouchPlatform = platformIsNative || isCompact;
|
||||
@@ -799,16 +840,17 @@ function StatusWorkspaceRowInnerContent({
|
||||
|
||||
const accessibilityState = useMemo(() => ({ selected }), [selected]);
|
||||
const isEditingTitle = titleEditor !== null;
|
||||
const consumeMovedPress = pressGuard?.consumeMovedPress;
|
||||
const startDragPress = pressGuard?.handlePressIn;
|
||||
const moveDragPress = pressGuard?.handleTouchMove;
|
||||
const endDragPress = pressGuard?.handlePressOut;
|
||||
const didLongPressRef = dragInteraction?.didLongPressRef;
|
||||
const startDragPress = dragInteraction?.handlePressIn;
|
||||
const moveDragPress = dragInteraction?.handleTouchMove;
|
||||
const endDragPress = dragInteraction?.handlePressOut;
|
||||
const handlePress = useCallback(() => {
|
||||
if (consumeMovedPress?.()) {
|
||||
if (didLongPressRef?.current) {
|
||||
didLongPressRef.current = false;
|
||||
return;
|
||||
}
|
||||
onPress();
|
||||
}, [consumeMovedPress, onPress]);
|
||||
}, [didLongPressRef, onPress]);
|
||||
const handlePressIn = useCallback(
|
||||
(event: GestureResponderEvent) => {
|
||||
setIsPressed(true);
|
||||
@@ -853,89 +895,87 @@ function StatusWorkspaceRowInnerContent({
|
||||
});
|
||||
const backdrop = getSidebarRowBackdrop({ isDragging, isPressed, selected, isHovered });
|
||||
return (
|
||||
<DraggableListHandle dragHandleProps={isEditingTitle ? undefined : dragHandleProps}>
|
||||
<View
|
||||
{...dragAttributes}
|
||||
{...(isEditingTitle ? null : dragHandleProps?.listeners)}
|
||||
ref={dragHandleProps?.setActivatorNodeRef as unknown as Ref<View>}
|
||||
style={styles.workspaceRowContainer}
|
||||
{...hoverHandlers}
|
||||
<View
|
||||
{...dragAttributes}
|
||||
{...(isEditingTitle ? null : dragHandleProps?.listeners)}
|
||||
ref={dragHandleProps?.setActivatorNodeRef as unknown as Ref<View>}
|
||||
style={styles.workspaceRowContainer}
|
||||
{...hoverHandlers}
|
||||
>
|
||||
<SidebarWorkspaceContextMenu
|
||||
contextMenuOpen={contextMenuOpen}
|
||||
onContextMenuOpenChange={onContextMenuOpenChange}
|
||||
workspace={workspace}
|
||||
leadingProjectName={projectName}
|
||||
hostBadgeLabel={hostBadge?.label}
|
||||
serviceSummary={serviceSummary}
|
||||
workspaceKey={workspace.workspaceKey}
|
||||
onCopyPath={onCopyPath}
|
||||
onCopyBranchName={onCopyBranchName}
|
||||
onRename={onRename}
|
||||
onMarkAsRead={onMarkAsRead}
|
||||
onMarkAsUnread={onMarkAsUnread}
|
||||
onArchive={onArchive}
|
||||
archiveLabel={archiveLabel}
|
||||
archiveStatus={archiveStatus}
|
||||
archivePendingLabel={archivePendingLabel}
|
||||
archiveShortcutKeys={archiveShortcutKeys}
|
||||
isPinned={isPinned}
|
||||
onTogglePin={onTogglePin}
|
||||
openInFileManagerPath={workspace.workspaceDirectory}
|
||||
disabled={isArchiving}
|
||||
enabled={!isEditingTitle}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={accessibilityState}
|
||||
style={workspaceRowStyle}
|
||||
highlightStyle={styles.workspaceRowPressed}
|
||||
onPressIn={isEditingTitle ? undefined : handlePressIn}
|
||||
onTouchMove={isEditingTitle ? undefined : moveDragPress}
|
||||
onPressOut={isEditingTitle ? undefined : handlePressOut}
|
||||
onPress={isEditingTitle ? undefined : handlePress}
|
||||
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
|
||||
>
|
||||
<SidebarWorkspaceContextMenu
|
||||
contextMenuOpen={contextMenuOpen}
|
||||
onContextMenuOpenChange={onContextMenuOpenChange}
|
||||
<SidebarWorkspaceRowContent
|
||||
workspace={workspace}
|
||||
hostBadge={hostBadge}
|
||||
leadingProjectName={projectName}
|
||||
hostBadgeLabel={hostBadge?.label}
|
||||
leadingProjectIconDataUri={projectIconDataUri}
|
||||
serviceSummary={serviceSummary}
|
||||
workspaceKey={workspace.workspaceKey}
|
||||
onCopyPath={onCopyPath}
|
||||
onCopyBranchName={onCopyBranchName}
|
||||
onRename={onRename}
|
||||
onMarkAsRead={onMarkAsRead}
|
||||
onMarkAsUnread={onMarkAsUnread}
|
||||
onArchive={onArchive}
|
||||
archiveLabel={archiveLabel}
|
||||
archiveStatus={archiveStatus}
|
||||
archivePendingLabel={archivePendingLabel}
|
||||
archiveShortcutKeys={archiveShortcutKeys}
|
||||
isPinned={isPinned}
|
||||
onTogglePin={onTogglePin}
|
||||
openInFileManagerPath={workspace.workspaceDirectory}
|
||||
disabled={isArchiving}
|
||||
enabled={!isEditingTitle}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={accessibilityState}
|
||||
style={workspaceRowStyle}
|
||||
highlightStyle={styles.workspaceRowPressed}
|
||||
onPressIn={isEditingTitle ? undefined : handlePressIn}
|
||||
onTouchMove={isEditingTitle ? undefined : moveDragPress}
|
||||
onPressOut={isEditingTitle ? undefined : handlePressOut}
|
||||
onPress={isEditingTitle ? undefined : handlePress}
|
||||
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
|
||||
backdrop={backdrop}
|
||||
isHovered={isHovered}
|
||||
isLoading={isArchiving}
|
||||
shortcutNumber={shortcutNumber}
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace}
|
||||
titleEditor={titleEditor}
|
||||
>
|
||||
<SidebarWorkspaceRowContent
|
||||
workspace={workspace}
|
||||
hostBadge={hostBadge}
|
||||
leadingProjectName={projectName}
|
||||
leadingProjectIconDataUri={projectIconDataUri}
|
||||
serviceSummary={serviceSummary}
|
||||
backdrop={backdrop}
|
||||
isHovered={isHovered}
|
||||
isLoading={isArchiving}
|
||||
shortcutNumber={shortcutNumber}
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace}
|
||||
titleEditor={titleEditor}
|
||||
>
|
||||
{renderSlot ? (
|
||||
<StatusWorkspaceActionSlot
|
||||
workspace={workspace}
|
||||
backdrop={backdrop}
|
||||
trailing={trailing}
|
||||
trailingPresentation={trailingPresentation}
|
||||
showKebab={showKebabInSlot}
|
||||
showArchiveButton={showArchiveButton}
|
||||
showScrim={showScrim}
|
||||
reserveSlotWidth={reserveSlotWidth}
|
||||
isPinned={isPinned}
|
||||
onTogglePin={onTogglePin}
|
||||
onCopyPath={onCopyPath}
|
||||
onCopyBranchName={onCopyBranchName}
|
||||
onRename={onRename}
|
||||
onMarkAsRead={onMarkAsRead}
|
||||
onMarkAsUnread={onMarkAsUnread}
|
||||
onArchive={onArchive}
|
||||
archiveLabel={archiveLabel}
|
||||
archiveStatus={archiveStatus}
|
||||
archivePendingLabel={archivePendingLabel}
|
||||
archiveShortcutKeys={archiveShortcutKeys}
|
||||
/>
|
||||
) : null}
|
||||
</SidebarWorkspaceRowContent>
|
||||
</SidebarWorkspaceContextMenu>
|
||||
</View>
|
||||
</DraggableListHandle>
|
||||
{renderSlot ? (
|
||||
<StatusWorkspaceActionSlot
|
||||
workspace={workspace}
|
||||
backdrop={backdrop}
|
||||
trailing={trailing}
|
||||
trailingPresentation={trailingPresentation}
|
||||
showKebab={showKebabInSlot}
|
||||
showArchiveButton={showArchiveButton}
|
||||
showScrim={showScrim}
|
||||
reserveSlotWidth={reserveSlotWidth}
|
||||
isPinned={isPinned}
|
||||
onTogglePin={onTogglePin}
|
||||
onCopyPath={onCopyPath}
|
||||
onCopyBranchName={onCopyBranchName}
|
||||
onRename={onRename}
|
||||
onMarkAsRead={onMarkAsRead}
|
||||
onMarkAsUnread={onMarkAsUnread}
|
||||
onArchive={onArchive}
|
||||
archiveLabel={archiveLabel}
|
||||
archiveStatus={archiveStatus}
|
||||
archivePendingLabel={archivePendingLabel}
|
||||
archiveShortcutKeys={archiveShortcutKeys}
|
||||
/>
|
||||
) : null}
|
||||
</SidebarWorkspaceRowContent>
|
||||
</SidebarWorkspaceContextMenu>
|
||||
</View>
|
||||
);
|
||||
}}
|
||||
</SidebarWorkspaceRowFrame>
|
||||
@@ -989,11 +1029,7 @@ function StatusWorkspaceActionSlot({
|
||||
return (
|
||||
<SidebarWorkspaceTrailingActionSlot reserveWidth={reserveSlotWidth}>
|
||||
<SidebarWorkspaceTrailingActionBase presentation={trailingPresentation}>
|
||||
<SidebarWorkspaceTrailingContent
|
||||
workspace={workspace}
|
||||
trailing={trailing}
|
||||
backdrop={backdrop}
|
||||
/>
|
||||
<SidebarWorkspaceTrailingContent workspace={workspace} trailing={trailing} />
|
||||
</SidebarWorkspaceTrailingActionBase>
|
||||
<SidebarWorkspaceTrailingActionOverlay
|
||||
visible={kebab.showKebab}
|
||||
@@ -1061,8 +1097,7 @@ 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],
|
||||
paddingBottom: theme.spacing[4],
|
||||
},
|
||||
pinnedSection: {
|
||||
marginBottom: theme.spacing[1],
|
||||
|
||||
@@ -180,14 +180,12 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
|
||||
<View style={sidebarWorkspaceRowStyles.rowRight}>{children}</View>
|
||||
</View>
|
||||
<WorkspaceMetaRow
|
||||
backdrop={backdrop}
|
||||
currentBranch={workspace.currentBranch}
|
||||
projectName={leadingProjectName}
|
||||
hostBadge={hostBadge ?? null}
|
||||
prHint={workspace.prHint}
|
||||
serviceSummary={serviceSummary}
|
||||
labels={labels}
|
||||
workspace={workspace}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import type { GestureResponderEvent } from "react-native";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { isWeb as platformIsWeb } from "@/constants/platform";
|
||||
import { decideLongPressMove } from "@/utils/sidebar-gesture-arbitration";
|
||||
import type { useContextMenu } from "@/components/ui/context-menu";
|
||||
|
||||
export function useLongPressDragInteraction(input: {
|
||||
drag?: () => void;
|
||||
menuController: ReturnType<typeof useContextMenu> | null;
|
||||
}) {
|
||||
const { drag, menuController } = input;
|
||||
const didLongPressRef = useRef(false);
|
||||
const dragArmedRef = useRef(false);
|
||||
const dragActivatedRef = useRef(false);
|
||||
const didStartDragRef = useRef(false);
|
||||
const scrollIntentRef = useRef(false);
|
||||
const menuOpenedRef = useRef(false);
|
||||
const touchStartRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const touchCurrentRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const dragArmTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const contextMenuTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const clearTimers = useCallback(() => {
|
||||
if (dragArmTimerRef.current) {
|
||||
clearTimeout(dragArmTimerRef.current);
|
||||
dragArmTimerRef.current = null;
|
||||
}
|
||||
if (contextMenuTimerRef.current) {
|
||||
clearTimeout(contextMenuTimerRef.current);
|
||||
contextMenuTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const openContextMenuAtStartPoint = useCallback(() => {
|
||||
if (!menuController || !touchStartRef.current) {
|
||||
return;
|
||||
}
|
||||
menuController.setAnchorRect({
|
||||
x: touchStartRef.current.x,
|
||||
y: touchStartRef.current.y,
|
||||
width: 0,
|
||||
height: 0,
|
||||
});
|
||||
menuController.setOpen(true);
|
||||
menuOpenedRef.current = true;
|
||||
didLongPressRef.current = true;
|
||||
}, [menuController]);
|
||||
|
||||
const handleLongPress = useCallback(() => {
|
||||
// Manual timers own long-press behavior on mobile.
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearTimers();
|
||||
};
|
||||
}, [clearTimers]);
|
||||
|
||||
const armTimers = useCallback(() => {
|
||||
clearTimers();
|
||||
if (platformIsWeb || !drag) {
|
||||
return;
|
||||
}
|
||||
|
||||
const DRAG_ARM_DELAY_MS = 180;
|
||||
const DRAG_ARM_STATIONARY_SLOP_PX = 4;
|
||||
const CONTEXT_MENU_DELAY_MS = 450;
|
||||
const CONTEXT_MENU_STATIONARY_SLOP_PX = 6;
|
||||
|
||||
dragArmTimerRef.current = setTimeout(() => {
|
||||
if (scrollIntentRef.current || didStartDragRef.current || menuOpenedRef.current) {
|
||||
return;
|
||||
}
|
||||
const start = touchStartRef.current;
|
||||
const current = touchCurrentRef.current ?? start;
|
||||
if (!start || !current) {
|
||||
return;
|
||||
}
|
||||
const dx = current.x - start.x;
|
||||
const dy = current.y - start.y;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
if (distance > DRAG_ARM_STATIONARY_SLOP_PX) {
|
||||
return;
|
||||
}
|
||||
dragArmedRef.current = true;
|
||||
dragActivatedRef.current = true;
|
||||
didLongPressRef.current = true;
|
||||
void Haptics.selectionAsync().catch(() => {});
|
||||
drag();
|
||||
}, DRAG_ARM_DELAY_MS);
|
||||
|
||||
if (!menuController) {
|
||||
return;
|
||||
}
|
||||
|
||||
contextMenuTimerRef.current = setTimeout(() => {
|
||||
if (scrollIntentRef.current || didStartDragRef.current || menuOpenedRef.current) {
|
||||
return;
|
||||
}
|
||||
const start = touchStartRef.current;
|
||||
const current = touchCurrentRef.current ?? start;
|
||||
if (!start || !current) {
|
||||
return;
|
||||
}
|
||||
const dx = current.x - start.x;
|
||||
const dy = current.y - start.y;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
if (distance > CONTEXT_MENU_STATIONARY_SLOP_PX) {
|
||||
return;
|
||||
}
|
||||
void Haptics.selectionAsync().catch(() => {});
|
||||
openContextMenuAtStartPoint();
|
||||
}, CONTEXT_MENU_DELAY_MS);
|
||||
}, [clearTimers, drag, menuController, openContextMenuAtStartPoint]);
|
||||
|
||||
const handleDragIntent = useCallback(
|
||||
(_details: { dx: number; dy: number; distance: number }) => {
|
||||
if (!dragActivatedRef.current) {
|
||||
return;
|
||||
}
|
||||
didStartDragRef.current = true;
|
||||
didLongPressRef.current = true;
|
||||
clearTimers();
|
||||
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {});
|
||||
},
|
||||
[clearTimers],
|
||||
);
|
||||
|
||||
const handleScrollIntent = useCallback(
|
||||
(_details: { dx: number; dy: number; distance: number }) => {
|
||||
scrollIntentRef.current = true;
|
||||
didLongPressRef.current = true;
|
||||
clearTimers();
|
||||
},
|
||||
[clearTimers],
|
||||
);
|
||||
|
||||
const handleSwipeIntent = useCallback(
|
||||
(_details: { dx: number; dy: number; distance: number }) => {
|
||||
didLongPressRef.current = true;
|
||||
clearTimers();
|
||||
},
|
||||
[clearTimers],
|
||||
);
|
||||
|
||||
const handlePressIn = useCallback(
|
||||
(event: GestureResponderEvent) => {
|
||||
didLongPressRef.current = false;
|
||||
dragArmedRef.current = false;
|
||||
dragActivatedRef.current = false;
|
||||
didStartDragRef.current = false;
|
||||
scrollIntentRef.current = false;
|
||||
menuOpenedRef.current = false;
|
||||
touchStartRef.current = {
|
||||
x: event.nativeEvent.pageX,
|
||||
y: event.nativeEvent.pageY,
|
||||
};
|
||||
touchCurrentRef.current = {
|
||||
x: event.nativeEvent.pageX,
|
||||
y: event.nativeEvent.pageY,
|
||||
};
|
||||
armTimers();
|
||||
},
|
||||
[armTimers],
|
||||
);
|
||||
|
||||
const handleTouchMove = useCallback(
|
||||
(event: GestureResponderEvent) => {
|
||||
const start = touchStartRef.current;
|
||||
if (!start || didStartDragRef.current || menuOpenedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const touch = event?.nativeEvent?.touches?.[0] ?? event?.nativeEvent;
|
||||
const x = touch?.pageX;
|
||||
const y = touch?.pageY;
|
||||
if (typeof x !== "number" || typeof y !== "number") {
|
||||
return;
|
||||
}
|
||||
|
||||
const current = { x, y };
|
||||
touchCurrentRef.current = current;
|
||||
const dx = current.x - start.x;
|
||||
const dy = current.y - start.y;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
const decision = decideLongPressMove({
|
||||
dragArmed: dragArmedRef.current,
|
||||
didStartDrag: didStartDragRef.current,
|
||||
startPoint: start,
|
||||
currentPoint: current,
|
||||
});
|
||||
|
||||
if (decision === "vertical_scroll") {
|
||||
handleScrollIntent({ dx, dy, distance });
|
||||
return;
|
||||
}
|
||||
|
||||
if (decision === "horizontal_swipe" || decision === "cancel_long_press") {
|
||||
handleSwipeIntent({ dx, dy, distance });
|
||||
return;
|
||||
}
|
||||
|
||||
if (decision === "start_drag") {
|
||||
handleDragIntent({ dx, dy, distance });
|
||||
}
|
||||
},
|
||||
[handleDragIntent, handleScrollIntent, handleSwipeIntent],
|
||||
);
|
||||
|
||||
const handlePressOut = useCallback(() => {
|
||||
clearTimers();
|
||||
dragArmedRef.current = false;
|
||||
dragActivatedRef.current = false;
|
||||
touchStartRef.current = null;
|
||||
touchCurrentRef.current = null;
|
||||
}, [clearTimers]);
|
||||
|
||||
return {
|
||||
didLongPressRef,
|
||||
handleLongPress,
|
||||
handlePressIn,
|
||||
handleTouchMove,
|
||||
handlePressOut,
|
||||
};
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { useCallback, useMemo, useRef } from "react";
|
||||
import type { GestureResponderEvent } from "react-native";
|
||||
import { didPressMove } from "@/utils/sidebar-gesture-arbitration";
|
||||
|
||||
/**
|
||||
* Tracks a row press so a touch that scrolled, swiped, or drifted does not navigate when it is
|
||||
* released. Wire the handlers to the row's Pressable and ask `consumeMovedPress` in `onPress`.
|
||||
*/
|
||||
export function useRowPressGuard() {
|
||||
const movedRef = useRef(false);
|
||||
const startPointRef = useRef<{ x: number; y: number } | null>(null);
|
||||
|
||||
const handlePressIn = useCallback((event: GestureResponderEvent) => {
|
||||
movedRef.current = false;
|
||||
startPointRef.current = { x: event.nativeEvent.pageX, y: event.nativeEvent.pageY };
|
||||
}, []);
|
||||
|
||||
const handleTouchMove = useCallback((event: GestureResponderEvent) => {
|
||||
const startPoint = startPointRef.current;
|
||||
if (!startPoint || movedRef.current) {
|
||||
return;
|
||||
}
|
||||
const touch = event?.nativeEvent?.touches?.[0] ?? event?.nativeEvent;
|
||||
const x = touch?.pageX;
|
||||
const y = touch?.pageY;
|
||||
if (typeof x !== "number" || typeof y !== "number") {
|
||||
return;
|
||||
}
|
||||
movedRef.current = didPressMove({ startPoint, currentPoint: { x, y } });
|
||||
}, []);
|
||||
|
||||
const handlePressOut = useCallback(() => {
|
||||
startPointRef.current = null;
|
||||
}, []);
|
||||
|
||||
const consumeMovedPress = useCallback(() => {
|
||||
const moved = movedRef.current;
|
||||
movedRef.current = false;
|
||||
return moved;
|
||||
}, []);
|
||||
|
||||
return useMemo(
|
||||
() => ({ handlePressIn, handleTouchMove, handlePressOut, consumeMovedPress }),
|
||||
[consumeMovedPress, handlePressIn, handlePressOut, handleTouchMove],
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { PressHighlight as Pressable } from "@/components/ui/press-highlight";
|
||||
import { Fragment, useCallback, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text, View, type GestureResponderEvent } from "react-native";
|
||||
import { Pressable, Text, View, type GestureResponderEvent } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { ExternalLink, Folder, GitBranch, Globe } from "lucide-react-native";
|
||||
import {
|
||||
@@ -16,12 +15,7 @@ import { getForgePresentation, normalizeForge } from "@/git/forge";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { useSidebarMetaPreferences } from "@/components/sidebar/display-preferences/model";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
|
||||
import { PullRequestStateIcon } from "@/git/pull-request-state-icon";
|
||||
import {
|
||||
PluginWorkspaceRowBadgeItems,
|
||||
usePluginWorkspaceRowBadgeItems,
|
||||
} from "@/plugins/workspace-row-badge-items";
|
||||
import { CheckIndicator } from "./check-indicator";
|
||||
import type { CheckSummary, CheckSummaryState } from "./check-summary";
|
||||
import { selectMetaRowItems, type MetaRowItem } from "./meta-items";
|
||||
@@ -65,9 +59,6 @@ const dangerMapping = (theme: Theme) => ({ color: theme.colors.statusDanger });
|
||||
* state has its own colors — a label is a name a person chose, so it carries its own identity
|
||||
* color on a tint instead (see `WorkspaceLabelChip`). It sits last so the reported facts are
|
||||
* read first, and it stays the same height as the rest of the line.
|
||||
*
|
||||
* Plugin items follow everything the row knows itself. A plugin renders its slot as it likes:
|
||||
* the row adds no separator, since only the plugin knows whether it has anything to say.
|
||||
*/
|
||||
export function WorkspaceMetaRow({
|
||||
currentBranch,
|
||||
@@ -76,8 +67,6 @@ export function WorkspaceMetaRow({
|
||||
prHint,
|
||||
serviceSummary,
|
||||
labels = EMPTY_LABELS,
|
||||
workspace = null,
|
||||
backdrop,
|
||||
}: {
|
||||
currentBranch: string | null;
|
||||
projectName: string | null;
|
||||
@@ -85,9 +74,6 @@ export function WorkspaceMetaRow({
|
||||
prHint: PrHint | null;
|
||||
serviceSummary: WorkspaceServiceSummary | null;
|
||||
labels?: readonly WorkspaceLabelDefinition[];
|
||||
/** The real workspace behind the row; previews leave it out and get no plugin items. */
|
||||
workspace?: { serverId: string; workspaceId: string } | null;
|
||||
backdrop: SidebarSurfaceBackdrop;
|
||||
}) {
|
||||
const { rowItems, checksDisplay } = useSidebarMetaPreferences();
|
||||
const items = selectMetaRowItems({
|
||||
@@ -100,31 +86,17 @@ export function WorkspaceMetaRow({
|
||||
visible: rowItems,
|
||||
checksDisplay,
|
||||
});
|
||||
const pluginItems = usePluginWorkspaceRowBadgeItems(workspace?.serverId ?? null);
|
||||
|
||||
if (items.length === 0 && (!workspace || pluginItems.length === 0)) return null;
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
{items.map((item, index) => (
|
||||
<Fragment key={item.kind}>
|
||||
{index > 0 ? (
|
||||
<Text
|
||||
style={[
|
||||
styles.separator,
|
||||
backdrop === "surfaceSidebarHover" && styles.separatorHovered,
|
||||
backdrop === "surfaceSidebarSelected" && styles.separatorSelected,
|
||||
]}
|
||||
>
|
||||
·
|
||||
</Text>
|
||||
) : null}
|
||||
{index > 0 ? <Text style={styles.separator}>·</Text> : null}
|
||||
<MetaItemNode item={item} hostBadge={hostBadge} leading={index === 0} />
|
||||
</Fragment>
|
||||
))}
|
||||
{workspace ? (
|
||||
<PluginWorkspaceRowBadgeItems targets={pluginItems} workspaceId={workspace.workspaceId} />
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -376,8 +348,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
lineHeight: 16,
|
||||
flexShrink: 0,
|
||||
},
|
||||
separatorHovered: { color: theme.colors.foregroundSidebarHover },
|
||||
separatorSelected: { color: theme.colors.foregroundSidebarSelected },
|
||||
// Tighter than the line's own gap so a run of chips reads as one item — see `LabelsItem`.
|
||||
// Shrinks at the same weight as the service name: both are arbitrary text somebody chose, so
|
||||
// a crowded line takes from the two of them in proportion rather than emptying one of them.
|
||||
|
||||
@@ -5,7 +5,6 @@ import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list"
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import type { SidebarWorkspaceTrailing } from "@/hooks/use-settings";
|
||||
import { useCompactTimeAgo } from "@/hooks/use-time-ago";
|
||||
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
|
||||
|
||||
export type { SidebarWorkspaceTrailing };
|
||||
|
||||
@@ -40,11 +39,9 @@ export function hasSidebarWorkspaceTrailing({
|
||||
export function SidebarWorkspaceTrailingContent({
|
||||
workspace,
|
||||
trailing,
|
||||
backdrop,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
trailing: SidebarWorkspaceTrailing;
|
||||
backdrop: SidebarSurfaceBackdrop;
|
||||
}) {
|
||||
if (trailing === "diff" && workspace.diffStat) {
|
||||
return (
|
||||
@@ -52,7 +49,7 @@ export function SidebarWorkspaceTrailingContent({
|
||||
);
|
||||
}
|
||||
if (trailing === "timestamp" && workspace.statusEnteredAt) {
|
||||
return <WorkspaceTimestamp enteredAt={workspace.statusEnteredAt} backdrop={backdrop} />;
|
||||
return <WorkspaceTimestamp enteredAt={workspace.statusEnteredAt} />;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -62,24 +59,10 @@ export function SidebarWorkspaceTrailingContent({
|
||||
* re-renders the component that owns it — keeping that component down to a single `<Text>` is
|
||||
* what stops a minute tick from reaching the row, the list, or the diff stat next door.
|
||||
*/
|
||||
function WorkspaceTimestamp({
|
||||
enteredAt,
|
||||
backdrop,
|
||||
}: {
|
||||
enteredAt: Date;
|
||||
backdrop: SidebarSurfaceBackdrop;
|
||||
}) {
|
||||
function WorkspaceTimestamp({ enteredAt }: { enteredAt: Date }) {
|
||||
const label = useCompactTimeAgo(enteredAt);
|
||||
return (
|
||||
<Text
|
||||
style={[
|
||||
styles.timestamp,
|
||||
backdrop === "surfaceSidebarHover" && styles.hovered,
|
||||
backdrop === "surfaceSidebarSelected" && styles.selected,
|
||||
]}
|
||||
numberOfLines={1}
|
||||
testID="sidebar-workspace-timestamp"
|
||||
>
|
||||
<Text style={styles.timestamp} numberOfLines={1} testID="sidebar-workspace-timestamp">
|
||||
{label}
|
||||
</Text>
|
||||
);
|
||||
@@ -97,6 +80,4 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
flexShrink: 0,
|
||||
},
|
||||
hovered: { color: theme.colors.foregroundSidebarHover },
|
||||
selected: { color: theme.colors.foregroundSidebarSelected },
|
||||
}));
|
||||
|
||||
@@ -40,6 +40,7 @@ function SortableInlineListItem<T>({
|
||||
const info: DraggableRenderItemInfo<T> = {
|
||||
item,
|
||||
index,
|
||||
drag: () => {},
|
||||
isActive: false,
|
||||
};
|
||||
return renderItem(info);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, type ReactElement } from "react";
|
||||
import { useCallback, useMemo, type ReactElement } from "react";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
@@ -61,6 +61,11 @@ function SortableItem<T>({
|
||||
isDragging,
|
||||
} = useSortable({ id, disabled, data: itemData });
|
||||
|
||||
const drag = useCallback(() => {
|
||||
// dnd-kit handles drag initiation via listeners
|
||||
// This is a no-op but matches the mobile API
|
||||
}, []);
|
||||
|
||||
// External DnD contexts render their own insertion affordance, so keep the
|
||||
// tab row static and let the DragOverlay carry the moving chip.
|
||||
const baseTransform = externalDndContext
|
||||
@@ -84,6 +89,7 @@ function SortableItem<T>({
|
||||
const info: DraggableRenderItemInfo<T> = {
|
||||
item,
|
||||
index,
|
||||
drag,
|
||||
isActive: activeId === id,
|
||||
dragHandleProps: useDragHandle
|
||||
? {
|
||||
|
||||
@@ -32,32 +32,4 @@ describe("useToastHost", () => {
|
||||
act(() => dismissUploading());
|
||||
expect(result.current.toast?.content).toBe("Image uploaded");
|
||||
});
|
||||
|
||||
it("ends each toast's undo opportunity once, including replacement", () => {
|
||||
const { result } = renderHook(() => useToastHost());
|
||||
const dismissed: string[] = [];
|
||||
let dismissFirst = () => {};
|
||||
let dismissSecond = () => {};
|
||||
act(() => {
|
||||
dismissFirst = result.current.api.show("First archive", {
|
||||
onDismiss: () => dismissed.push("first"),
|
||||
});
|
||||
});
|
||||
act(() => {
|
||||
dismissSecond = result.current.api.show("Second archive", {
|
||||
onDismiss: () => dismissed.push("second"),
|
||||
});
|
||||
});
|
||||
expect(dismissed).toEqual(["first"]);
|
||||
act(() => dismissFirst());
|
||||
expect(result.current.toast?.content).toBe("Second archive");
|
||||
expect(dismissed).toEqual(["first"]);
|
||||
act(() => {
|
||||
dismissSecond();
|
||||
dismissSecond();
|
||||
result.current.dismiss();
|
||||
});
|
||||
expect(result.current.toast).toBeNull();
|
||||
expect(dismissed).toEqual(["first", "second"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Platform, StyleSheet as NativeStyleSheet, Text, ToastAndroid, View } fr
|
||||
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { SPACING, type Theme } from "@/styles/theme";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
@@ -212,18 +212,10 @@ export function ToastViewport({
|
||||
const topOffset =
|
||||
placement === "app-shell" ? insets.top + headerTopPadding + headerHeight : panelClearance;
|
||||
|
||||
// Keep the bottom anchor and its contents together while the native exit retains the view.
|
||||
const content = toast ? (
|
||||
<Animated.View
|
||||
entering={toastEntering}
|
||||
exiting={toastExiting}
|
||||
const content = (
|
||||
<View
|
||||
style={
|
||||
placement === "sidebar"
|
||||
? animatedStyles.sidebarContainer
|
||||
: [
|
||||
animatedStyles.container,
|
||||
{ top: topOffset + (placement === "app-shell" ? SPACING[2] : SPACING[3]) },
|
||||
]
|
||||
placement === "sidebar" ? styles.sidebarContainer : styles.container(placement, topOffset)
|
||||
}
|
||||
pointerEvents="box-none"
|
||||
collapsable={false}
|
||||
@@ -233,16 +225,20 @@ export function ToastViewport({
|
||||
pointerEvents="box-none"
|
||||
collapsable={false}
|
||||
>
|
||||
<View
|
||||
style={animatedStyles.surface}
|
||||
testID={toast.testID ?? "app-toast"}
|
||||
accessibilityRole="alert"
|
||||
>
|
||||
<ToastCard toast={toast} onPointerEnter={pauseDismiss} onPointerLeave={resumeDismiss} />
|
||||
</View>
|
||||
{toast ? (
|
||||
<Animated.View
|
||||
entering={toastEntering}
|
||||
exiting={toastExiting}
|
||||
style={animatedStyles.surface}
|
||||
testID={toast.testID ?? "app-toast"}
|
||||
accessibilityRole="alert"
|
||||
>
|
||||
<ToastCard toast={toast} onPointerEnter={pauseDismiss} onPointerLeave={resumeDismiss} />
|
||||
</Animated.View>
|
||||
) : null}
|
||||
</View>
|
||||
</Animated.View>
|
||||
) : null;
|
||||
</View>
|
||||
);
|
||||
|
||||
if (placement === "app-shell" && isWeb && typeof document !== "undefined") {
|
||||
return createPortal(content, getOverlayRoot());
|
||||
@@ -298,25 +294,26 @@ function ToastCard({
|
||||
}
|
||||
|
||||
const animatedStyles = NativeStyleSheet.create({
|
||||
container: {
|
||||
position: "absolute",
|
||||
left: SPACING[4],
|
||||
right: SPACING[4],
|
||||
zIndex: OVERLAY_Z.toast,
|
||||
alignItems: "center",
|
||||
},
|
||||
sidebarContainer: {
|
||||
position: "absolute",
|
||||
left: SPACING[2],
|
||||
right: SPACING[2],
|
||||
bottom: SPACING[2],
|
||||
zIndex: OVERLAY_Z.toast,
|
||||
alignItems: "center",
|
||||
},
|
||||
surface: { alignSelf: "center", maxWidth: "100%" },
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: (placement: Exclude<ToastViewportPlacement, "sidebar">, topOffset: number) => ({
|
||||
position: "absolute",
|
||||
left: theme.spacing[4],
|
||||
right: theme.spacing[4],
|
||||
top: topOffset + (placement === "app-shell" ? theme.spacing[2] : theme.spacing[3]),
|
||||
zIndex: OVERLAY_Z.toast,
|
||||
alignItems: "center",
|
||||
}),
|
||||
sidebarContainer: {
|
||||
position: "absolute",
|
||||
left: theme.spacing[2],
|
||||
right: theme.spacing[2],
|
||||
bottom: theme.spacing[2],
|
||||
zIndex: OVERLAY_Z.toast,
|
||||
alignItems: "center",
|
||||
},
|
||||
widthBoundary: {
|
||||
width: "92%",
|
||||
maxWidth: TOAST_MAX_WIDTH,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { AppearanceText as Text } from "@/appearance/text";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { useContainedControlSize } from "@/components/ui/control-size";
|
||||
import {
|
||||
@@ -11,7 +10,7 @@ import {
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { Pressable, View } from "react-native";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import type {
|
||||
PressableProps,
|
||||
PressableStateCallbackType,
|
||||
|
||||
@@ -42,7 +42,7 @@ vi.mock("@/components/ui/press-highlight", () => ({
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe("ContextMenuTrigger", () => {
|
||||
it("preserves a static trigger style for the native pressable", () => {
|
||||
it("preserves a static trigger style so the native press highlight can inherit its corners", () => {
|
||||
vi.stubGlobal("React", React);
|
||||
const rowStyle = [{ borderRadius: 8 }, { backgroundColor: "#111" }];
|
||||
|
||||
|
||||
@@ -144,20 +144,11 @@ describe("toolbar glyphs and spacing", () => {
|
||||
expect(toolbarGlyphsFor("touch").label).toEqual({
|
||||
size: 20,
|
||||
strokeWidth: 1.5,
|
||||
tone: "muted",
|
||||
tone: "foreground",
|
||||
});
|
||||
expect(TOOLBAR_GLYPH_SIZES.label).toEqual({ xs: 20, md: 16 });
|
||||
});
|
||||
|
||||
it("mutes a touch glyph paired with a label while keeping an icon-only touch glyph foreground", () => {
|
||||
const { icon, label } = toolbarGlyphsFor("touch");
|
||||
const colors = { foreground: "#fafafa", foregroundMuted: "#a1a5a4" };
|
||||
const palette = { colors } as unknown as Theme;
|
||||
|
||||
expect(toolbarGlyphColor(palette, label.tone)).toBe(colors.foregroundMuted);
|
||||
expect(toolbarGlyphColor(palette, icon.tone)).toBe(colors.foreground);
|
||||
});
|
||||
|
||||
it("sizes the touch context ring to the toolbar glyph's ink and keeps the pointer ring", () => {
|
||||
expect(TOOLBAR_RINGS).toEqual({
|
||||
touch: { size: 18, strokeWidth: 2 },
|
||||
|
||||
@@ -132,10 +132,9 @@ export function iconSizeFor(size: IconSize): SizingValue {
|
||||
}
|
||||
|
||||
/**
|
||||
* The glyph a toolbar control paints. Touch-sized every toolbar glyph is 20pt at the app's 1.5px
|
||||
* stroke. An icon-only control's glyph matches the header icons and rests at `foreground`; a glyph
|
||||
* paired with a label rests muted so the control reads as one quiet unit with its label.
|
||||
* Pointer-sized it is the `md` glyph at the same stroke and rests muted until hovered.
|
||||
* The glyph a toolbar control paints. Touch-sized every toolbar glyph matches the header icons:
|
||||
* 20pt at the app's 1.5px stroke, resting at `foreground`. Pointer-sized it is the `md` glyph at
|
||||
* the same stroke and rests muted until hovered.
|
||||
*/
|
||||
export type ToolbarGlyphTone = "foreground" | "muted";
|
||||
|
||||
@@ -161,7 +160,7 @@ const POINTER_TOOLBAR_GLYPH: ToolbarGlyph = {
|
||||
const TOOLBAR_GLYPHS: Record<ControlSizing, ToolbarGlyphs> = {
|
||||
touch: {
|
||||
icon: { size: TOUCH_ICON_SIZE.md, strokeWidth: ICON_STROKE_WIDTH, tone: "foreground" },
|
||||
label: { size: TOUCH_ICON_SIZE.md, strokeWidth: ICON_STROKE_WIDTH, tone: "muted" },
|
||||
label: { size: TOUCH_ICON_SIZE.md, strokeWidth: ICON_STROKE_WIDTH, tone: "foreground" },
|
||||
},
|
||||
pointer: { icon: POINTER_TOOLBAR_GLYPH, label: POINTER_TOOLBAR_GLYPH },
|
||||
};
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { AppearanceText as Text } from "@/appearance/text";
|
||||
import { useCallback, useMemo, type ReactElement, type ReactNode } from "react";
|
||||
import { View } from "react-native";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { ChevronDown } from "lucide-react-native";
|
||||
import {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { forwardRef, useCallback, useMemo } from "react";
|
||||
import { Text, type PressableStateCallbackType, type View } from "react-native";
|
||||
import { forwardRef, useMemo } from "react";
|
||||
import { Text, type View } from "react-native";
|
||||
import type { LucideIcon } from "lucide-react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { PressHighlight } from "@/components/ui/press-highlight";
|
||||
@@ -35,11 +35,10 @@ export const FloatingActionButton = forwardRef<View, FloatingActionButtonProps>(
|
||||
ref,
|
||||
) {
|
||||
const accessibilityState = useMemo(() => ({ disabled }), [disabled]);
|
||||
const buttonStyle = useCallback(
|
||||
({ pressed }: PressableStateCallbackType) => [
|
||||
const buttonStyle = useMemo(
|
||||
() => [
|
||||
styles.button,
|
||||
label && styles.labeled,
|
||||
pressed && styles.pressed,
|
||||
disabled && styles.disabled,
|
||||
{ bottom: FLOATING_ACTION_BUTTON_INSET + bottomInset },
|
||||
],
|
||||
@@ -54,6 +53,7 @@ export const FloatingActionButton = forwardRef<View, FloatingActionButtonProps>(
|
||||
disabled={disabled}
|
||||
onPress={onPress}
|
||||
style={buttonStyle}
|
||||
highlightStyle={styles.pressed}
|
||||
testID={testID}
|
||||
>
|
||||
<Icon size={ICON_SIZE.lg} color={styles.glyph.color} />
|
||||
@@ -86,7 +86,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
label: { color: theme.colors.foreground, fontSize: theme.fontSize.base },
|
||||
disabled: { opacity: theme.opacity[50] },
|
||||
pressed: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
backgroundColor: theme.colors.interactionHighlight,
|
||||
},
|
||||
glyph: {
|
||||
color: theme.colors.foreground,
|
||||
|
||||
@@ -12,7 +12,14 @@ import {
|
||||
type ReactNode,
|
||||
type RefObject,
|
||||
} from "react";
|
||||
import { Dimensions, View, type Role, type StyleProp, type ViewStyle } from "react-native";
|
||||
import {
|
||||
Dimensions,
|
||||
View,
|
||||
type PointerEvent as ViewPointerEvent,
|
||||
type Role,
|
||||
type StyleProp,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { FadeIn, FadeOut } from "react-native-reanimated";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Portal } from "@gorhom/portal";
|
||||
@@ -35,7 +42,7 @@ interface HoverCardContextValue {
|
||||
setTriggerRef: (node: View | null) => void;
|
||||
triggerRef: RefObject<View | null>;
|
||||
contentRef: RefObject<View | null>;
|
||||
openNow: () => void;
|
||||
pointerEntered: (event: ViewPointerEvent) => void;
|
||||
scheduleClose: () => void;
|
||||
}
|
||||
|
||||
@@ -55,21 +62,34 @@ const HoverCardContext = createContext<HoverCardContextValue | null>(null);
|
||||
*/
|
||||
export function HoverCard({
|
||||
disabled = false,
|
||||
ignoreTouch = false,
|
||||
children,
|
||||
}: {
|
||||
/** Closes the card and keeps it closed, e.g. while its trigger is dragged. */
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* The trigger handles a tap itself, so a touch does not open the card: neither the touch's
|
||||
* pointerenter nor the focus the tap leaves on the trigger. Opening during a tap changes the
|
||||
* page under the finger, and iPad Safari then drops the tap.
|
||||
*/
|
||||
ignoreTouch?: boolean;
|
||||
children: ReactNode;
|
||||
}): ReactNode {
|
||||
if (!isWeb) return children;
|
||||
return <WebHoverCard disabled={disabled}>{children}</WebHoverCard>;
|
||||
return (
|
||||
<WebHoverCard disabled={disabled} ignoreTouch={ignoreTouch}>
|
||||
{children}
|
||||
</WebHoverCard>
|
||||
);
|
||||
}
|
||||
|
||||
function WebHoverCard({
|
||||
disabled,
|
||||
ignoreTouch,
|
||||
children,
|
||||
}: {
|
||||
disabled: boolean;
|
||||
ignoreTouch: boolean;
|
||||
children: ReactNode;
|
||||
}): ReactElement {
|
||||
const layer = useOverlayLayer("floating");
|
||||
@@ -79,6 +99,8 @@ function WebHoverCard({
|
||||
const graceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// Read when focus arrives: by focusout the element losing focus no longer matches :focus-visible.
|
||||
const keyboardFocusedRef = useRef(false);
|
||||
// Whether the latest press was a touch, so with ignoreTouch the focus that tap leaves is ignored.
|
||||
const touchPressedRef = useRef(false);
|
||||
|
||||
const clearGraceTimer = useCallback(() => {
|
||||
if (graceTimerRef.current) {
|
||||
@@ -125,6 +147,13 @@ function WebHoverCard({
|
||||
setOpen(true);
|
||||
}, [clearGraceTimer, closeNow, disabled, layer]);
|
||||
|
||||
const pointerEntered = useCallback(
|
||||
(event: ViewPointerEvent) => {
|
||||
if (!(ignoreTouch && event.nativeEvent.pointerType === "touch")) openNow();
|
||||
},
|
||||
[ignoreTouch, openNow],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
return () => {
|
||||
@@ -191,7 +220,7 @@ function WebHoverCard({
|
||||
const target = event.target as Element;
|
||||
keyboardFocusedRef.current = target.matches(":focus-visible");
|
||||
if (trigger.contains(target)) {
|
||||
openNow();
|
||||
if (!(ignoreTouch && touchPressedRef.current)) openNow();
|
||||
} else if ((contentRef.current as unknown as HTMLElement | null)?.contains(target)) {
|
||||
clearGraceTimer();
|
||||
}
|
||||
@@ -208,6 +237,12 @@ function WebHoverCard({
|
||||
scheduleClose();
|
||||
}
|
||||
};
|
||||
const pointerPressed = (event: PointerEvent) => {
|
||||
touchPressedRef.current = event.pointerType === "touch";
|
||||
};
|
||||
const keyPressedAnywhere = () => {
|
||||
touchPressedRef.current = false;
|
||||
};
|
||||
const activateClosedTrigger = (event: KeyboardEvent) => {
|
||||
if (
|
||||
!open &&
|
||||
@@ -221,19 +256,23 @@ function WebHoverCard({
|
||||
const scrolled = (event: Event) => {
|
||||
if ((event.target as Node).contains(trigger)) close();
|
||||
};
|
||||
document.addEventListener("pointerdown", pointerPressed, true);
|
||||
document.addEventListener("keydown", keyPressedAnywhere, true);
|
||||
document.addEventListener("focusin", focusEntered);
|
||||
document.addEventListener("focusout", focusLeft);
|
||||
trigger.addEventListener("keydown", activateClosedTrigger);
|
||||
document.addEventListener("scroll", scrolled, true);
|
||||
window.addEventListener("resize", close);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", pointerPressed, true);
|
||||
document.removeEventListener("keydown", keyPressedAnywhere, true);
|
||||
document.removeEventListener("focusin", focusEntered);
|
||||
document.removeEventListener("focusout", focusLeft);
|
||||
trigger.removeEventListener("keydown", activateClosedTrigger);
|
||||
document.removeEventListener("scroll", scrolled, true);
|
||||
window.removeEventListener("resize", close);
|
||||
};
|
||||
}, [clearGraceTimer, keyPressed, open, openNow, scheduleClose]);
|
||||
}, [clearGraceTimer, ignoreTouch, keyPressed, open, openNow, scheduleClose]);
|
||||
|
||||
// While open, the safe zone covers trigger + content + the bridge between
|
||||
// them. Close only fires when the pointer leaves the safe zone; re-entering
|
||||
@@ -256,8 +295,16 @@ function WebHoverCard({
|
||||
useEffect(() => clearGraceTimer, [clearGraceTimer]);
|
||||
|
||||
const value = useMemo<HoverCardContextValue>(
|
||||
() => ({ open, layer, setTriggerRef, triggerRef, contentRef, openNow, scheduleClose }),
|
||||
[open, layer, setTriggerRef, openNow, scheduleClose],
|
||||
() => ({
|
||||
open,
|
||||
layer,
|
||||
setTriggerRef,
|
||||
triggerRef,
|
||||
contentRef,
|
||||
pointerEntered,
|
||||
scheduleClose,
|
||||
}),
|
||||
[open, layer, setTriggerRef, pointerEntered, scheduleClose],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -285,7 +332,7 @@ export function HoverCardTrigger({
|
||||
accessibilityRole={focusable ? "button" : undefined}
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
collapsable={false}
|
||||
onPointerEnter={ctx.openNow}
|
||||
onPointerEnter={ctx.pointerEntered}
|
||||
onPointerLeave={ctx.scheduleClose}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -88,18 +88,6 @@ describe("bottom sheet visibility tracker", () => {
|
||||
expect(sheet.events).toEqual([{ type: "present" }, { type: "dismiss" }]);
|
||||
});
|
||||
|
||||
it("dismisses again when a sheet hidden while opening finishes opening anyway", () => {
|
||||
const { sheet, tracker } = setup();
|
||||
tracker.attachController(sheet);
|
||||
tracker.syncDesired({ visible: true });
|
||||
|
||||
// The owner hides the sheet before its open animation reports in; the sheet still lands open.
|
||||
tracker.syncDesired({ visible: false });
|
||||
tracker.handleSheetIndexChange(0);
|
||||
|
||||
expect(sheet.events).toEqual([{ type: "present" }, { type: "dismiss" }, { type: "dismiss" }]);
|
||||
});
|
||||
|
||||
it("waits to present until the sheet controller becomes available", () => {
|
||||
const { sheet, tracker } = setup();
|
||||
tracker.syncDesired({ visible: true });
|
||||
|
||||
@@ -120,11 +120,6 @@ export function createBottomSheetVisibilityTracker(opts: {
|
||||
if (phase === "presenting" || phase === "dismissing") {
|
||||
setPhase("presented");
|
||||
}
|
||||
// A dismiss sent before the sheet measured its layout is dropped, and the sheet still
|
||||
// finishes opening. The owner wants it hidden, so it goes again.
|
||||
if (!visible && isEnabled !== false) {
|
||||
dismiss();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (phase === "presenting" || phase === "presented") {
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import React from "react";
|
||||
import { cleanup, fireEvent, render, waitFor } from "@testing-library/react";
|
||||
import { ArrowUp } from "lucide-react-native";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
import { PressHighlight } from "./press-highlight.native";
|
||||
import { FloatingActionButton } from "./floating-action-button";
|
||||
|
||||
const rowStyle = { backgroundColor: "rgb(255, 255, 255)", borderRadius: 8 };
|
||||
const highlightStyle = { backgroundColor: "rgb(20, 30, 40)" };
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function onPress() {}
|
||||
|
||||
it("keeps a floating action opaque while showing its held-press feedback", async () => {
|
||||
const { getByRole } = render(
|
||||
<FloatingActionButton icon={ArrowUp} accessibilityLabel="Jump to file" onPress={onPress} />,
|
||||
);
|
||||
const button = getByRole("button", { name: "Jump to file" });
|
||||
const restingFill = getComputedStyle(button).backgroundColor;
|
||||
|
||||
fireEvent.mouseDown(button, { button: 0 });
|
||||
await waitFor(() => {
|
||||
const pressedFill = getComputedStyle(button).backgroundColor;
|
||||
expect(pressedFill).not.toBe(restingFill);
|
||||
expect(pressedFill).toMatch(/^rgb\(/);
|
||||
});
|
||||
|
||||
fireEvent.mouseUp(button, { button: 0 });
|
||||
await waitFor(() => expect(getComputedStyle(button).backgroundColor).toBe(restingFill));
|
||||
});
|
||||
|
||||
it("uses the pressable's pressed state for the native row fill and its existing corners", () => {
|
||||
const { getByTestId, rerender } = render(
|
||||
<GestureHandlerRootView>
|
||||
<PressHighlight
|
||||
testID="row"
|
||||
testOnly_pressed
|
||||
style={rowStyle}
|
||||
highlightStyle={highlightStyle}
|
||||
/>
|
||||
</GestureHandlerRootView>,
|
||||
);
|
||||
|
||||
expect(getComputedStyle(getByTestId("row")).backgroundColor).toBe("rgb(20, 30, 40)");
|
||||
expect(getComputedStyle(getByTestId("row")).borderRadius).toBe("8px");
|
||||
|
||||
rerender(
|
||||
<GestureHandlerRootView>
|
||||
<PressHighlight
|
||||
testID="row"
|
||||
testOnly_pressed={false}
|
||||
style={rowStyle}
|
||||
highlightStyle={highlightStyle}
|
||||
/>
|
||||
</GestureHandlerRootView>,
|
||||
);
|
||||
expect(getComputedStyle(getByTestId("row")).backgroundColor).toBe("rgb(255, 255, 255)");
|
||||
});
|
||||
@@ -1,21 +1,74 @@
|
||||
import { forwardRef, useCallback } from "react";
|
||||
import { Pressable, type PressableStateCallbackType, type View } from "react-native";
|
||||
import { forwardRef, useMemo } from "react";
|
||||
import { Pressable, StyleSheet, View } from "react-native";
|
||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
import Animated, { useAnimatedStyle, useSharedValue } from "react-native-reanimated";
|
||||
import { resolveHighlightCorners } from "./press-highlight.shape";
|
||||
import type { PressHighlightProps } from "./press-highlight.types";
|
||||
|
||||
export const PressHighlight = forwardRef<View, PressHighlightProps>(function PressHighlight(
|
||||
{ highlightStyle, style, unstable_pressDelay = 130, ...props },
|
||||
{ children, disabled, highlightStyle, ...props },
|
||||
ref,
|
||||
) {
|
||||
// Give the scroll view time to claim a pan before Pressable activates its feedback.
|
||||
const pressStyle = useCallback(
|
||||
(state: PressableStateCallbackType) => [
|
||||
typeof style === "function" ? style(state) : style,
|
||||
state.pressed && highlightStyle,
|
||||
],
|
||||
[style, highlightStyle],
|
||||
const highlighted = useSharedValue(0);
|
||||
const corners = useMemo(() => resolveHighlightCorners(props.style), [props.style]);
|
||||
const animatedHighlightStyle = useAnimatedStyle(() => ({ opacity: highlighted.value }));
|
||||
// This gesture owns visual acknowledgement only. Pressable remains the sole owner of
|
||||
// activation, cancellation, accessibility, and the caller's drag/long-press callbacks.
|
||||
const pressGesture = useMemo(
|
||||
() =>
|
||||
Gesture.Tap()
|
||||
.enabled(disabled !== true && highlightStyle != null)
|
||||
.maxDistance(8)
|
||||
.shouldCancelWhenOutside(true)
|
||||
.onBegin(() => {
|
||||
highlighted.value = 1;
|
||||
})
|
||||
.onFinalize(() => {
|
||||
highlighted.value = 0;
|
||||
}),
|
||||
[disabled, highlightStyle, highlighted],
|
||||
);
|
||||
|
||||
return (
|
||||
<Pressable {...props} ref={ref} unstable_pressDelay={unstable_pressDelay} style={pressStyle} />
|
||||
const highlight = highlightStyle ? (
|
||||
<Animated.View pointerEvents="none" style={[styles.highlight, animatedHighlightStyle]}>
|
||||
{/* Keep the themed Unistyles node separate from the node Reanimated patches. The
|
||||
pressable's corners come first so `highlightStyle` can still override them. */}
|
||||
<View style={[styles.highlightFill, corners, highlightStyle]} />
|
||||
</Animated.View>
|
||||
) : null;
|
||||
const renderedChildren =
|
||||
typeof children === "function" ? (
|
||||
(state: Parameters<typeof children>[0]) => (
|
||||
<>
|
||||
{highlight}
|
||||
{children(state)}
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
{highlight}
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
|
||||
const pressable = (
|
||||
<Pressable ref={ref} {...props} collapsable={false} disabled={disabled}>
|
||||
{renderedChildren}
|
||||
</Pressable>
|
||||
);
|
||||
|
||||
if (!highlightStyle) {
|
||||
return pressable;
|
||||
}
|
||||
|
||||
return <GestureDetector gesture={pressGesture}>{pressable}</GestureDetector>;
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
highlight: {
|
||||
...StyleSheet.absoluteFill,
|
||||
},
|
||||
highlightFill: {
|
||||
...StyleSheet.absoluteFill,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { StyleProp, ViewStyle } from "react-native";
|
||||
import { resolveHighlightCorners } from "./press-highlight.shape";
|
||||
|
||||
describe("resolveHighlightCorners", () => {
|
||||
it("takes the radius from the pressable's own style, the way a sidebar row declares it", () => {
|
||||
const row = { minHeight: 36, borderRadius: 8, backgroundColor: "#111" };
|
||||
expect(resolveHighlightCorners(row)).toEqual({ borderRadius: 8 });
|
||||
});
|
||||
|
||||
it("reads through the style array a row builds per interaction state", () => {
|
||||
const row: StyleProp<ViewStyle> = [{ borderRadius: 8 }, false, { backgroundColor: "#222" }];
|
||||
expect(resolveHighlightCorners(row)).toEqual({ borderRadius: 8 });
|
||||
});
|
||||
|
||||
it("keeps the last radius when a later entry in the array reshapes the row", () => {
|
||||
expect(resolveHighlightCorners([{ borderRadius: 8 }, { borderRadius: 999 }])).toEqual({
|
||||
borderRadius: 999,
|
||||
});
|
||||
});
|
||||
|
||||
it("carries every corner a row sets individually", () => {
|
||||
const row = { borderTopLeftRadius: 8, borderBottomRightRadius: 2, paddingLeft: 12 };
|
||||
expect(resolveHighlightCorners(row)).toEqual({
|
||||
borderTopLeftRadius: 8,
|
||||
borderBottomRightRadius: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns no corners for a square row, rather than a zero it never asked for", () => {
|
||||
expect(resolveHighlightCorners({ backgroundColor: "#111" })).toEqual({});
|
||||
expect(resolveHighlightCorners(undefined)).toEqual({});
|
||||
});
|
||||
|
||||
it("leaves a style resolved per press state alone", () => {
|
||||
expect(resolveHighlightCorners(() => ({ borderRadius: 8 }))).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { StyleSheet, type StyleProp, type ViewStyle } from "react-native";
|
||||
|
||||
/**
|
||||
* A press highlight is drawn as an overlay inside the pressable rather than as a background on
|
||||
* it, because the node Reanimated animates cannot also carry the caller's themed style. An
|
||||
* overlay inherits nothing, so a rounded row gets a square flash unless the corners are copied
|
||||
* across.
|
||||
*
|
||||
* They are copied from the row's own style, not asked for again: the row already declares its
|
||||
* shape once, and a highlight that had to restate it would be a second copy free to drift. That
|
||||
* leaves `highlightStyle` to mean only what the highlight is painted with.
|
||||
*/
|
||||
const CORNER_PROPERTIES = [
|
||||
"borderRadius",
|
||||
"borderTopLeftRadius",
|
||||
"borderTopRightRadius",
|
||||
"borderBottomLeftRadius",
|
||||
"borderBottomRightRadius",
|
||||
"borderStartStartRadius",
|
||||
"borderStartEndRadius",
|
||||
"borderEndStartRadius",
|
||||
"borderEndEndRadius",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* The corner radii a pressable's own style gives its highlight. A style resolved per press state
|
||||
* is left alone — its shape is not knowable before the press it describes, and every corner it
|
||||
* could name is one the caller can still set through `highlightStyle`.
|
||||
*/
|
||||
export function resolveHighlightCorners(
|
||||
style: StyleProp<ViewStyle> | ((state: never) => StyleProp<ViewStyle>) | undefined,
|
||||
): ViewStyle {
|
||||
if (typeof style === "function") {
|
||||
return {};
|
||||
}
|
||||
const flattened = StyleSheet.flatten(style);
|
||||
if (!flattened) {
|
||||
return {};
|
||||
}
|
||||
const corners: ViewStyle = {};
|
||||
for (const property of CORNER_PROPERTIES) {
|
||||
const value = flattened[property];
|
||||
if (value !== undefined) {
|
||||
corners[property] = value;
|
||||
}
|
||||
}
|
||||
return corners;
|
||||
}
|
||||
@@ -1,6 +1,13 @@
|
||||
import type { PressableProps, StyleProp, ViewStyle } from "react-native";
|
||||
|
||||
export interface PressHighlightProps extends PressableProps {
|
||||
/** Native pressed style. Feedback and cancellation follow Pressable's press lifecycle. */
|
||||
/**
|
||||
* What the native finger-down surface is painted with — a background colour, an opacity.
|
||||
* Desktop hover remains owned by the caller.
|
||||
*
|
||||
* Shape is not part of it: the highlight takes its corners from the pressable's own `style`, so
|
||||
* a rounded row gets a rounded flash without restating the radius. Set a corner here only to
|
||||
* depart from the row's shape on purpose.
|
||||
*/
|
||||
highlightStyle?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ export function WorkspaceHoverCard({
|
||||
}
|
||||
|
||||
return (
|
||||
<HoverCard disabled={isDragging || disabled}>
|
||||
<HoverCard disabled={isDragging || disabled} ignoreTouch>
|
||||
<HoverCardTrigger>{children}</HoverCardTrigger>
|
||||
<HoverCardContent
|
||||
placement="right"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user