Compare commits

..
Author SHA1 Message Date
Mohamed Boudra 1066ebb891 test(app): remove wheel listener race 2026-08-03 13:53:13 +00:00
Mohamed Boudra f821f1eca4 test(heartbeats): expand tracks idempotently 2026-08-03 13:20:43 +00:00
Mohamed Boudra b49abdc5dc test(heartbeats): start expiry after visibility 2026-08-03 13:20:42 +00:00
Mohamed Boudra 082d9c0e4a test(heartbeats): match active workspace label 2026-08-03 13:20:42 +00:00
Mohamed Boudra d84c0a8420 fix(schedules): preserve idempotent deletion 2026-08-03 13:20:42 +00:00
Mohamed Boudra 721b6a10dc fix(composer): isolate web hover handlers 2026-08-03 13:20:42 +00:00
Mohamed Boudra 40ae6447ba test(server): stub workspace activity dependencies
Give generic session schedule fixtures an explicit empty heartbeat activity result and declare both activity dependencies in the git-watch fixture. This preserves strict stubs while covering the workspace descriptor inputs introduced by heartbeat projection.
2026-08-03 13:20:42 +00:00
Mohamed Boudra c73b03a168 fix(heartbeats): gate workspace activity by host
Advertise heartbeat workspace projection as an optional daemon capability and gate the agent track at its feature boundary. Older hosts still run heartbeat prompts, but now show an update-host link instead of heartbeat rows that conflict with a done workspace.\n\nCover optional wire parsing, daemon advertisement, supported and unsupported track selection, and the update-host presentation.
2026-08-03 13:20:42 +00:00
Mohamed Boudra 20789bcd13 fix(composer): restore hover-gated track actions
Keep row actions mounted but hidden on wide web layouts until the plain row wrapper is hovered. Native and compact layouts keep controls visible, preserving the established subagent behavior in the shared track component.
2026-08-03 13:20:13 +00:00
Mohamed Boudra 7f4eb38fc0 fix(heartbeats): refresh mounted schedule state
Poll the shared schedules query while mounted and use each successful refresh timestamp when deriving heartbeat activity. This keeps externally created and expired heartbeats in sync without introducing a heartbeat-local timer.\n\nExtend the real-daemon browser flow to create heartbeats after the composer mounts and verify expiry removes the track and workspace activity.
2026-08-03 13:20:13 +00:00
Mohamed Boudra 644f5ee941 feat(agents): surface heartbeat activity
Keep idle babysitting agents visible through a dedicated heartbeat track while projecting active heartbeat schedules into workspace activity. Agent lifecycle stays unchanged so heartbeat targets do not gain interrupt semantics.
2026-08-03 13:20:13 +00:00
197 changed files with 6295 additions and 9169 deletions
-1
View File
@@ -34,7 +34,6 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
| [docs/hover.md](docs/hover.md) | Hover — the canonical pattern (plain View + onPointerEnter/Leave, separate inner Pressable) and the three ways agents break it |
| [docs/unistyles.md](docs/unistyles.md) | Unistyles gotchas — `useUnistyles()` is forbidden, alternatives in order |
| [docs/floating-panels.md](docs/floating-panels.md) | Anchored popovers — Portal/Modal escape for Android, lifecycle gates, keyboard-shared-value, status-bar offset, the flash |
| [docs/menus.md](docs/menus.md) | The menu engine — popover vs sheet, submenu pages, hover intent, when a decision earns a submenu |
| [docs/expo-router.md](docs/expo-router.md) | Expo Router route ownership, startup restore, and native blank-screen gotchas |
| [docs/file-icons.md](docs/file-icons.md) | Material icon theme integration for the file explorer |
| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end |
-1
View File
@@ -80,7 +80,6 @@ Paseo starts locally, then asks whether to enable the end-to-end encrypted relay
For full setup and configuration, see:
- [Docs](https://paseo.sh/docs)
- [Connectivity guide](https://paseo.sh/docs/connectivity)
- [Configuration reference](https://paseo.sh/docs/configuration)
### Docker
+1 -1
View File
@@ -111,7 +111,7 @@ Running provider-native subagents contribute `running` to the workspace owned by
## The subagents track
The collapsible track above the composer in an agent's pane (`packages/app/src/subagents/track.tsx`) combines two kinds of children:
One collapsible section of the composer track lane in an agent's pane (`packages/app/src/subagents/track.tsx`), stacked with any other track the agent has. It combines two kinds of children:
- **Paseo subagents** are full managed agents. Their membership rule (`packages/app/src/subagents/select.ts`) is:
+5 -12
View File
@@ -280,18 +280,11 @@ for machine-readable output.
## Worktree starting refs
A new worktree starts from the current branch's upstream, or the local branch when it has no
upstream. This keeps unpushed local commits out of new workspaces by default. The picker collapses
identical refs; divergent local or non-origin refs remain explicit, qualified choices.
The daemon sends the exact upstream ref because the remote and branch names cannot be inferred.
Worktrees retain that ref for comparisons and updates from base while exposing its branch name to
the UI. Merging into base requires a mutable local target: `origin/main` maps to local `main`, while
another remote fails closed until the worktree records an explicit local target. Older daemons omit
the optional field and retain the previous local-first behavior; older worktree metadata without the
exact ref also resolves through its stored branch name.
Worktrees inherit committed Git state only; uncommitted source-checkout changes are not copied.
The New workspace branch picker treats local and `origin/*` refs as distinct starting points when
they differ. Local `main` is sent as `refs/heads/main`; remote `origin/main` is sent as
`refs/remotes/origin/main`. Matching local/remote refs collapse to one local row. Unqualified refs
from older clients resolve local-first, then fall back to `origin`. Worktrees inherit committed Git
state only; uncommitted source-checkout changes are not copied.
## paseo.json service scripts
+12 -24
View File
@@ -2,32 +2,20 @@
The file explorer uses colored SVG icons from [`material-icon-theme`](https://github.com/material-extensions/vscode-material-icon-theme) (installed as a dev dependency in `packages/app`).
Icons are inlined as SVG strings in:
```
packages/app/src/components/material-file-icons.ts
```
This file is auto-generated. Do not edit it by hand.
## How it works
Two files:
- `packages/app/src/components/material-file-icons.ts` — the vendor table. `SVG_ICONS` maps icon
names to SVG strings copied verbatim from the theme, `EXTENSION_TO_ICON` maps extensions to icon
names, and `getRawFileIconSvg(fileName)` looks one up with a generic fallback. Keep it a faithful
copy so re-copying an icon stays a mechanical edit.
- `packages/app/src/components/file-icon-svg.ts` — `getFileIconSvg(fileName)`, the only thing
anyone should import. It desaturates the vendor colours and caches the result.
Render with `<MaterialFileIcon fileName size />` rather than reaching for `SvgXml` yourself.
## Why the colours are toned down
material-icon-theme picks its palette for VS Code's file tree, where the icon is the only colour on
the row. Ours share rows with status dots, diff stats, and check badges, and a column of them at
full chroma is the loudest thing in the panel.
Every hex in the SVG is pulled toward neutral at the same perceived lightness (`desaturateHexColor`
in `packages/app/src/utils/color.ts`, OKLab — scaling HSL saturation instead would darken the
yellows and barely touch the blues). Hue survives, which is the only part of an icon's colour that
carries meaning at 16pt.
`ICON_CHROMA` in `file-icon-svg.ts` is the single knob. Move it; do not add per-icon overrides, or
we are back to hand-picking 53 colours.
- `SVG_ICONS` maps icon names (e.g. `"typescript"`) to raw SVG strings
- `EXTENSION_TO_ICON` maps file extensions (e.g. `"ts"`) to icon names
- `getFileIconSvg(fileName)` returns the SVG string for a given filename, falling back to a generic file icon
- `packages/app/src/components/file-explorer-pane.tsx` is the only consumer; it renders the SVG with `SvgXml` from `react-native-svg`
## Adding a new icon
+1 -31
View File
@@ -124,13 +124,8 @@ quietly relying on:
renders inside its host, not necessarily at window origin. Position anchored
content relative to the host: `anchorRect - hostRect`. This is what
`measureFloatingPanelPortalHost()` is for.
- **React context.** `@gorhom/portal` is not a React portal — a real one keeps
context, this one does not. It stores the element and the host renders it, so
context resolves at the _host's_ position. Everything provided between
`PortalProvider` in `app/_layout.tsx` and your sheet is invisible inside it.
This is why app-wide providers wrap `PortalProvider` rather than the reverse.
The fix for transforms is Gotcha 3. The fix for context is Gotcha 7.
The fix for transforms is Gotcha 3.
## Gotcha 3 — Reanimated transforms vs `measureInWindow`
@@ -241,28 +236,6 @@ Do not treat `onChange(-1)` as a close by itself. In a stacked
another pushed sheet. Close React state from `onDismiss`; use `onChange` only to
track phase.
## Gotcha 7 — A sheet cannot read context from its call site
React cannot copy contexts reflectively, so the only way across the teleport in
Gotcha 2 is to render the providers a second time, with values captured on the
near side where they are still readable. `IsolatedBottomSheetModal` takes a
`contextBridge` for exactly that:
```tsx
const contextBridge = useCallback<ContextBridge>(
(content) => <ThingContext.Provider value={thing}>{content}</ThingContext.Provider>,
[thing],
);
```
The prop is **required**, and `null` is a real answer. A sheet whose content
needs nothing local should have to say so, because the failure mode is silent
until someone adds a `useContext` deep inside and it throws on device only —
never on web, where the desktop path uses a real portal. `menu-surface.tsx`
bridges the menu's two contexts; the rest pass `null`.
Wrapping providers _around_ the modal does nothing. They land on the wrong side.
## Recipe for a new anchored panel
Before you write a new one, ask:
@@ -285,6 +258,3 @@ Before you write a new one, ask:
bounded**. Verify before you commit.
Then copy the closest canonical file and trim.
Building a **menu** rather than a bare panel? Don't. Use the menu engine — it already solves
everything above, plus submenus, sheets, and hover intent. See [menus.md](menus.md).
+3 -3
View File
@@ -10,7 +10,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Agent** — See **Agent session**. UI still says "Agent" / "New Agent" in places, but moving toward **Agent session** as the canonical term. Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`). Forbidden: "Task", "Job", "Run".
- **Daemon** — Local Paseo server process; identified by `serverId`. UI: "Daemon" (system contexts only). Code: `serverId` in `ServerInfoStatusPayloadSchema` (`packages/protocol/src/messages.ts:1936`), `DaemonClient` (`packages/client/src/daemon-client.ts`).
- **Host** — Client-side connection profile pointing at a daemon; bundles one or more `HostConnection`s. UI: "Host" / "Add host" / "Switch host". Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Forbidden: "Connection" (means `HostConnection`, not host).
- **Host badge** — The item on a sidebar workspace row's meta row naming which host that workspace lives on. Three things gate it, and they compose as a lattice rather than competing: the sidebar must span more than one host (automatic), **Show → Host** in the sidebar's display preferences must be on (global, all hosts at once), and then that host's own display mode decides _how_ it draws — **Name** / **Icon only** / **Hidden**. Name, color, and display mode are per-host, per-device, and live client-side on `HostProfile.appearance`; nothing about it reaches the daemon. Never add a fourth way to hide it. UI: the "Appearance" group on a host's settings page, and the "Show" submenu in the sidebar's display preferences. Code: `selectHostBadges` (`packages/app/src/hosts/appearance.ts`) takes the first two as its `enabled` input; `WorkspaceMetaRow` (`packages/app/src/components/sidebar/workspace-meta-row/index.tsx`) draws it. Don't confuse with **Status pill**, which reports state rather than identity.
- **Host badge** — The sidebar pill naming which host a workspace lives on. Only drawn once the visible sidebar spans more than one host. Its name, color, and display mode (**Name** / **Icon only** / **Hidden**) are per-host, per-device, and live client-side on `HostProfile.appearance`; nothing about it reaches the daemon. UI: the "Appearance" group on a host's settings page. Code: `HostBadge` (`packages/app/src/components/sidebar/host-badge.tsx`), `selectHostBadges` (`packages/app/src/hosts/appearance.ts`). Don't confuse with **Status pill**, which reports state rather than identity.
- **Project host entry** — One row in a project for a single (project, daemon) pair, aggregating that daemon's workspaces in the project. Internal. Code: `ProjectHostEntry` (`packages/app/src/utils/projects.ts:11`). Don't introduce "Checkout" as a synonym.
- **Placement** — One workspace's stable foreign-key relationship to its project plus its git checkout snapshot. Internal. An explicit creation `projectId` is authoritative when active.
- **Branch** — Plain git branch. UI: "Switch branch". Code: `currentBranch` in `WorkspaceGitRuntimePayloadSchema` (`packages/protocol/src/messages.ts:2136`); `BranchSwitcher` (`packages/app/src/components/branch-switcher.tsx`).
@@ -30,7 +30,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Tab** — UI surface representing one session inside a workspace. Not a conceptual unit; use **Agent session** when talking about the model. Code: `WorkspaceTabDescriptor` (`packages/app/src/screens/workspace/workspace-tabs-types.ts`).
- **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/protocol/src/terminal-stream-protocol.ts`).
- **Schedule** — Cron-style trigger that creates new agents. UI: CLI/MCP (`paseo schedule`, `create_schedule`). Don't confuse with: Heartbeat (cron prompt back into the same agent) or Loop (iterative re-execution of one agent).
- **Heartbeat** — Ephemeral cron prompt sent back into the same agent/conversation. Agent surfaces expose create, update cron, and delete only. Use for reminders and babysitting where status should return inline.
- **Heartbeat** — Ephemeral cron prompt sent back into the same agent/conversation. Agent surfaces expose create, update cron, and delete only. Use for reminders and babysitting where status should return inline. The agent's **Heartbeats track** lists the ones still on a cadence for that agent on that host; opening a row goes to it on Schedules (`/schedules?serverId=…&scheduleId=…`, keyed by both because schedule ids are host-local). Paused, completed, and expired heartbeats stay on Schedules and leave the track. A host without `heartbeatWorkspaceActivity` still runs the prompts but does not report them as workspace activity, so the track links to host settings instead of contradicting the workspace status.
- **Mode** — Provider-specific operational mode (plan, default, full-access, …). UI: icon-only. Code: `modeId` in `AgentSessionConfig` (`packages/protocol/src/messages.ts:257`).
- **Attachment** — External or local context bound to an agent prompt: forge issue/change request, review context, uploaded file, text, or image. UI: "Attach issue or PR/MR". Code: `AgentAttachment` (`packages/protocol/src/messages.ts:782`).
- **Composer** — The whole prompt surface for sending work to an agent. Code: `Composer` (`packages/app/src/composer/index.tsx`). Don't call this "message input" except for the text-entry subcomponent.
@@ -38,7 +38,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Composer toolbar** — The bottom control row inside the composer input. Contains agent controls, attachment button, voice controls, and stop/send controls. Code: `leftContent`, `beforeVoiceContent`, and `rightContent` slots in `MessageInput` (`packages/app/src/composer/input/input.tsx`). Forbidden: "Status bar".
- **Agent controls** — Provider, model, mode, thinking, and provider-feature controls for an agent or draft agent. Code: `AgentControls` / `DraftAgentControls` (`packages/app/src/composer/agent-controls/index.tsx`). Forbidden: "Agent status bar".
- **Composer footer** — Optional area rendered below the composer input but still inside the keyboard-shifted composer layout. Code: `Composer.footer` (`packages/app/src/composer/index.tsx`).
- **Composer track** — A contextual lane above the composer input. Specific tracks use the `<thing> track` form: **Queue track**, **Subagents track**. Code: queue track inside `Composer` (`packages/app/src/composer/index.tsx`), `SubagentsTrack` (`packages/app/src/subagents/track.tsx`).
- **Composer track** — A contextual lane above the composer input. Specific tracks use the `<thing> track` form: **Queue track**, **Heartbeats track**, **Subagents track**. Tracks above the input are sections of one card so several can stack without either one owning the seam with the composer. Code: `ComposerTracks` / `ComposerTrackSection` (`packages/app/src/composer/tracks.tsx`), queue track inside `Composer` (`packages/app/src/composer/index.tsx`), `HeartbeatsTrack` (`packages/app/src/heartbeats/track.tsx`), `SubagentsTrack` (`packages/app/src/subagents/track.tsx`).
- **Subagent** — User-facing term for an agent session related to a parent agent session. Use **subagents** in UI copy and docs. Internal daemon/provider plumbing may say "child agent" or `child_session`, especially for provider-managed imports; do not surface "child agent" as a product term.
- **Attachment tray** — The selected-attachments row inside the composer input, above the text input. Code: `renderAttachmentTray` (`packages/app/src/composer/index.tsx`). Forbidden: "Attachment bar".
- **Fork** — Starting a NEW agent session seeded with a copy of another session's curated chat history. The history rides along as a `chat_history` composer attachment on a draft; the fork does **not** resume or branch the provider-side session, and the source session keeps running untouched. Two targets, one surface, one RPC (`agent.fork_context`, gated on the `agentForkContext` host feature). UI: the fork menu lives in the assistant turn footer and offers "Fork in a new tab" / "Fork in a new workspace". On a **completed** turn it sits beside the copy button and pins a boundary to that turn, so the fork stops where the turn ended. On the **in-flight** turn it sits beside the progress loader and elapsed timer, and omits the boundary entirely — `selectForkContextRows` projects the whole timeline when no boundary is given, so the fork captures everything **up to now**, including the response still streaming. That omission is what makes mid-run forking work; pinning a boundary there would silently drop the live response. Forbidden on provider-subagent (read-only) panes. Code: `useForkAgent` (`packages/app/src/hooks/use-fork-agent.ts`), `selectForkContextRows` (`packages/server/src/server/agent/activity-curator.ts`). Don't confuse with: git fork (unrelated; Paseo has no such concept), **Worktree** (filesystem isolation, not history copying).
-129
View File
@@ -1,129 +0,0 @@
# Menus
One engine, in `packages/app/src/components/ui/menu/`. `dropdown-menu.tsx` and `context-menu.tsx`
are wrappers over it and differ only in what opens them — a press, or a long press / right click.
Import the `DropdownMenu*` or `ContextMenu*` names as before; reach for `@/components/ui/menu`
directly only when you are building a third trigger shape.
Do not add a third menu implementation. The two that existed were byte-identical from
`computePosition` down, and the copies drifted: only one of them ever grew a sheet.
## Two presentations
`MenuRoot` picks one from form factor, never from platform — a tablet in a narrow split view
sheets the same way a phone does.
| Screen | Surface | Submenus |
| ------------------------------ | ------------------------------- | -------------------------------------------------- |
| Wide | Popover anchored to the trigger | Flyout overlapping the row, opened by hover intent |
| Compact, `compactMode="sheet"` | Bottom sheet | The page is replaced in place, with a back header |
| Compact, default | Popover | Same as wide |
`compactMode` defaults to `"popover"`, so adopting the sheet is per-menu. That is deliberate:
flipping every menu in the app to sheets at once is not a change anyone can review. Opt a menu in
when you have actually looked at it on a phone.
`ContextMenu` is the exception: it defaults to `compactMode="sheet"` and enables native long press.
Disable mobile triggering explicitly on draggable rows, where long press belongs to drag instead.
## Pages
A submenu is a page, declared as data on the surface and reached by a `MenuSubTrigger` whose `id`
matches:
```tsx
<MenuSurface pages={pages} sheetTitle="Display">
<MenuSubTrigger id="grouping" value="Project">
Grouping
</MenuSubTrigger>
</MenuSurface>
```
Pages are data rather than nested children because the popover renders them as _siblings_ of the
root surface — a flyout nested inside the root's box would be clipped by its `overflow: hidden`.
Declaring them separately is what lets one model drive both presentations.
`menu-navigation.ts` holds that model, and it is pure: the open flyout chain and the mobile push
stack are the same path, so the popover renders every entry in it and the sheet renders only the
last. Nothing else differs between the two.
Opening a submenu truncates the path to the depth of the trigger that opened it. Without that,
sliding the pointer across a row of triggers would stack up every flyout it passed instead of
swapping between them.
## Hover intent
A flyout **overlaps its parent by 5pt** rather than sitting beside it. With a gap there is a strip
of backdrop between the two that belongs to neither surface, and every pixel of it is a chance to
dismiss the menu you are reaching for. Overlapping deletes the strip; don't reintroduce the gap
and try to cover it with a longer timer.
On top of that, a flyout opens after the pointer rests ~90ms, closes ~260ms after it leaves, and
cancels its own pending close while the pointer is inside it. The grace still matters because the
pointer crosses sibling rows on the way down into the flyout.
Hover lives on a plain `View`, never on a `Pressable` — see [hover.md](hover.md), which owns that
rule. Hover only fires on web, which is exactly where flyouts exist; everywhere else the page
opens on press.
## Item states
`selected` and `active` are different questions and must not be merged.
| Prop | Means | Draws |
| ---------- | -------------------------- | ------------------------- |
| `selected` | This is the chosen value | A check, and nothing else |
| `active` | This row's submenu is open | The fill, and no check |
A selected row does **not** get a background. A check and a fill are two separate claims about the
same state, and showing both makes a chosen row compete with the row the pointer is actually on.
`showSelectedCheck` moves the check to a reserved leading column when a group needs to stay
aligned whether ticked or not; otherwise it sits at the trailing edge and the leading slot is free
for the option's icon.
Give options icons; leave the root rows without them. The root is labels and their current values,
and a column of icons there is decoration competing with the values you actually came to read.
## Surface details
- The hover fill is **inset by the same amount on every side and rounded** — a chip inside the
menu, not a band across it. The inset is taken _out of_ the row, never added to it: horizontally
padding gives up what margin takes so labels don't move, and vertically the fill is shorter by
the same amount so the row pitch is unchanged. Insetting by growing the row is how the menu ends
up taller than it started.
- The fill's height only holds because the label's `lineHeight` is pinned. Leave it to the platform
and content outgrows `minHeight`, which then does nothing.
- **The item's inset is the menu's only spacing.** Rows sit apart because each holds itself in, and
a separator gets clearance from its neighbours for free. Don't give the separator a margin of its
own — that is two numbers controlling one gap, and they will disagree.
- Separators use `borderAccent`, the same colour the surface outlines itself with. `border` sits
between `surface1` and `surface2`, which put it within a hair of the hover fill and made
separators disappear against a hovered row.
## When a decision earns a submenu
Put a decision behind a submenu when its options are not the point — the current _value_ is. The
root row then reads as the answer (`Grouping Project ›`) and costs one line instead of one line
per option. A menu whose every option is on screen at once does not survive its third decision.
Independent toggles stay on their page as a checkmark list. A pick-one group can share that page
below a `MenuSeparator`; make selecting the checked row clear it, so "none" doesn't need a row of
its own.
## Gotchas
- **Released height.** Reanimated's web entering animation leaves an inline height snapshot on
the surface. `AnchoredSurface` clears it, and a `revision` prop re-clears it when content
identity changes — a pushed page taller than the one it replaced is clipped without that.
- **Sheets size to content.** `enableDynamicSizing`, not fixed snap points. A pushed page is
rarely the height of the page before it.
- **The sheet's content is teleported out of the menu's subtree**, so `MenuSheetSurface` rebuilds
both menu contexts through the sheet's `contextBridge`. Providing them around the modal puts
them on the wrong side of the portal and every item inside throws. Gotcha 7 in
[floating-panels.md](floating-panels.md).
- **One overlay per menu.** Submenus render inside their parent's layer and paint no second
backdrop, so there is exactly one `Modal` on native no matter how deep the menu goes.
- Anchoring, flipping, and edge clamping live in `menu-anchor.ts` and are unit-tested. Fix
positioning bugs there, not at a call site.
- Everything else about floating surfaces on Android — Portal/Modal escape, lifecycle gates,
status-bar offset, the open flash — is in [floating-panels.md](floating-panels.md).
+8 -10
View File
@@ -58,8 +58,7 @@ The daemon validates that the epoch is current and the exact source sequence sti
## Resume behavior
Opening, reconnecting, or revisiting after a selective-delivery coverage gap fetches the latest tail
page.
Opening, reconnecting, or revisiting after selective-delivery grace fetches the latest tail page.
Focus alone does not mutate timeline state; the tail response is compared with the local
authoritative range first.
@@ -107,14 +106,13 @@ replica cache.
The app chooses one delivery policy from `server_info.features.selectiveAgentTimeline`:
- Selective daemons receive every agent visible in any pane plus the most recently viewed hidden
agents, up to five subscribed agents. Visible agents always win: if more than five are visible,
they all remain subscribed and no hidden agent does. Switching and app backgrounding preserve
this connection-scoped hot set, so returning to an agent still covered by it needs no catch-up.
Losing window keyboard focus does not make a selected pane invisible. Disconnecting clears hidden
hot agents; reconnect restores the currently visible set before authoritative catch-up. Revisiting
an evicted retained timeline displays its cached state immediately while authoritative catch-up
advances it to the current tail.
- Selective daemons receive the union of agents visible in every pane. Additions subscribe and
catch up immediately. Every visibility-driven removal, including app backgrounding, stays
subscribed for a 30-second grace period so brief tab, pane, route, and app switches do not repeatedly
unsubscribe and catch up. Losing window keyboard focus does not make a selected pane invisible.
Disconnecting and disposal clear pending grace because the subscription itself no longer exists.
After grace has expired, revisiting a retained timeline displays its cached state immediately and
authoritative catch-up advances it to the current tail.
- Legacy daemons keep globally streaming agent timelines. Visibility still triggers the existing
authoritative catch-up, but the app does not issue selective-subscription RPCs.
@@ -0,0 +1,71 @@
import { expect, test } from "../support/fixtures";
import { openAgentRoute } from "../support/helpers/mock-agent";
import {
captureHeartbeatEvidence,
deleteHeartbeat,
expectHeartbeatDetails,
expectHeartbeatVisible,
expectWorkspaceDone,
expectWorkspaceRunning,
openHeartbeat,
openHeartbeatAndSubagentTracks,
openHeartbeatTrack,
removeSeededSubagent,
returnToAgentHeartbeat,
seedAgentWithHeartbeat,
} from "../support/helpers/heartbeats";
test("an agent heartbeat stays visible and keeps its workspace running", async ({
page,
}, testInfo) => {
test.setTimeout(150_000);
const scenario = await seedAgentWithHeartbeat();
try {
await openAgentRoute(page, {
workspaceId: scenario.workspace.workspaceId,
agentId: scenario.parent.id,
});
await expectWorkspaceDone(page);
const heartbeat = await scenario.createHeartbeat();
await expectWorkspaceRunning(page);
await openHeartbeatAndSubagentTracks(page, heartbeat, scenario.child.id);
await expectHeartbeatVisible(page, heartbeat);
await captureHeartbeatEvidence(page, testInfo, "heartbeat-stacked-tracks");
await openHeartbeat(page, heartbeat);
await expectHeartbeatDetails(page, heartbeat);
await captureHeartbeatEvidence(page, testInfo, "heartbeat-schedule-details");
await removeSeededSubagent(scenario);
await returnToAgentHeartbeat(page);
await expectWorkspaceRunning(page);
await openHeartbeatTrack(page, heartbeat);
await deleteHeartbeat(page, heartbeat);
await expect
.poll(() => scenario.readHeartbeatExists(heartbeat.id), { timeout: 30_000 })
.toBe(false);
await expect(scenario.readAgentStatuses()).resolves.toMatchObject({
[scenario.parent.id]: "idle",
});
await expect.poll(scenario.readWorkspaceStatus, { timeout: 30_000 }).toBe("done");
await expectWorkspaceDone(page);
await captureHeartbeatEvidence(page, testInfo, "heartbeat-removed-workspace-done");
const expiringHeartbeat = await scenario.createHeartbeat({ name: "Temporary watch" });
await expectWorkspaceRunning(page);
await openHeartbeatTrack(page, expiringHeartbeat);
await expectHeartbeatVisible(page, expiringHeartbeat);
await scenario.setHeartbeatExpiry(
expiringHeartbeat.id,
new Date(Date.now() + 20_000).toISOString(),
);
await expect(page.getByRole("button", { name: "1 heartbeat" })).toHaveCount(0, {
timeout: 30_000,
});
await expect.poll(scenario.readWorkspaceStatus, { timeout: 30_000 }).toBe("done");
await expectWorkspaceDone(page);
} finally {
await scenario.cleanup().catch(() => undefined);
}
});
@@ -1,236 +0,0 @@
import { expect, type Page } from "@playwright/test";
import { test } from "../support/fixtures";
import {
readScrollMetrics,
scrollChatAwayFromBottom,
} from "../support/helpers/agent-bottom-anchor";
import { getServerId } from "../support/helpers/server-id";
import { seedMockAgentWorkspace, type MockAgentWorkspace } from "../support/helpers/mock-agent";
import {
continueToNextQuestion,
fillQuestionAnswer,
submitQuestionAnswers,
waitForQuestionPrompt,
} from "../support/helpers/questions";
import { openMobileAgentSidebar } from "../support/helpers/sidebar";
import {
expectTimelinePromptVisible,
openAgentTimeline,
seedLongMockAgentTimeline,
} from "../support/helpers/timeline-pagination";
import { switchWorkspaceViaSidebar } from "../support/helpers/workspace-ui";
async function expectChatAtBottom(page: Page): Promise<void> {
await expect
.poll(async () => (await readScrollMetrics(page)).distanceFromBottom, {
timeout: 10_000,
})
.toBeLessThanOrEqual(72);
}
async function openCompactWorkspace(
page: Page,
workspace: Pick<MockAgentWorkspace, "workspaceId">,
): Promise<void> {
await openMobileAgentSidebar(page);
await switchWorkspaceViaSidebar({
page,
serverId: getServerId(),
workspaceId: workspace.workspaceId,
});
}
function armDelayedScrollForHiddenChat(page: Page): Promise<void> {
return page
.locator('[data-testid="agent-chat-scroll"]:visible')
.first()
.evaluate((root) => {
const scroll = root as HTMLElement;
return new Promise<void>((resolve) => {
const observer = new ResizeObserver(() => {
if (scroll.getClientRects().length > 0) return;
observer.disconnect();
scroll.dispatchEvent(new Event("scroll"));
resolve();
});
observer.observe(scroll);
});
});
}
test("pointer jitter cannot turn geometry changes into scroll intent", async ({ page }) => {
test.setTimeout(240_000);
const longChat = await seedLongMockAgentTimeline({ turns: 30 });
const backgroundPrompt = "Continue after a pointer is released outside the chat";
try {
await page.setViewportSize({ width: 390, height: 844 });
await openAgentTimeline(page, longChat);
await expectTimelinePromptVisible(page, longChat.newestPrompt);
await expectChatAtBottom(page);
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
const bounds = await scroll.boundingBox();
expect(bounds, "Expected visible chat scroll bounds").not.toBeNull();
await page.mouse.move(bounds!.x + bounds!.width / 2, bounds!.y + bounds!.height / 2);
await page.mouse.down();
await page.mouse.move(bounds!.x + bounds!.width / 2, bounds!.y + bounds!.height / 2 - 3);
await page.setViewportSize({ width: 390, height: 1124 });
await page.setViewportSize({ width: 390, height: 844 });
await page.mouse.move(410, bounds!.y + bounds!.height / 2 - 3);
await page.mouse.up();
await longChat.client.sendAgentMessage(longChat.agentId, backgroundPrompt);
await longChat.client.waitForFinish(longChat.agentId, 20_000);
await expectTimelinePromptVisible(page, backgroundPrompt);
await expectChatAtBottom(page);
} finally {
await longChat.cleanup();
}
});
test("a retained chat ignores delayed scroll delivery while hidden", async ({ page }) => {
test.setTimeout(240_000);
const longChat = await seedLongMockAgentTimeline({ turns: 30 });
const otherWorkspace = await seedMockAgentWorkspace({
repoPrefix: "scroll-return-hidden-other-",
title: "Other workspace",
initialPrompt: "Open the other workspace",
});
const backgroundPrompt = "emit 500 coalesced agent stream updates";
try {
await page.setViewportSize({ width: 390, height: 844 });
await openAgentTimeline(page, longChat);
await expectTimelinePromptVisible(page, longChat.newestPrompt);
await expectChatAtBottom(page);
const delayedScrollDelivered = armDelayedScrollForHiddenChat(page);
await openCompactWorkspace(page, otherWorkspace);
await delayedScrollDelivered;
await longChat.client.sendAgentMessage(longChat.agentId, backgroundPrompt);
await longChat.client.waitForFinish(longChat.agentId, 20_000);
await openCompactWorkspace(page, longChat);
await expectTimelinePromptVisible(page, backgroundPrompt);
await expectChatAtBottom(page);
} finally {
await Promise.allSettled([longChat.cleanup(), otherWorkspace.cleanup()]);
}
});
test("a retained streaming chat returns to the bottom and continues following", async ({
page,
}) => {
test.setTimeout(240_000);
const longChat = await seedLongMockAgentTimeline({ turns: 30 });
const otherWorkspace = await seedMockAgentWorkspace({
repoPrefix: "scroll-return-streaming-other-",
title: "Other workspace",
initialPrompt: "Open the other workspace",
});
const streamingPrompt = "Continue streaming while this workspace is hidden";
try {
await page.setViewportSize({ width: 390, height: 844 });
await openAgentTimeline(page, longChat);
await expectTimelinePromptVisible(page, longChat.newestPrompt);
await expectChatAtBottom(page);
await longChat.client.sendAgentMessage(longChat.agentId, streamingPrompt);
await longChat.client.waitForAgentUpsert(
longChat.agentId,
(snapshot) => snapshot.status === "running",
15_000,
);
await openCompactWorkspace(page, otherWorkspace);
await openCompactWorkspace(page, longChat);
await expectTimelinePromptVisible(page, streamingPrompt);
await expectChatAtBottom(page);
await longChat.client.waitForFinish(longChat.agentId, 20_000);
await expectChatAtBottom(page);
} finally {
await Promise.allSettled([longChat.cleanup(), otherWorkspace.cleanup()]);
}
});
test("a retained chat preserves an intentional reading position", async ({ page }) => {
test.setTimeout(240_000);
const longChat = await seedLongMockAgentTimeline({ turns: 30 });
const otherWorkspace = await seedMockAgentWorkspace({
repoPrefix: "scroll-return-reading-position-other-",
title: "Other workspace",
initialPrompt: "Open the other workspace",
});
try {
await page.setViewportSize({ width: 390, height: 844 });
await openAgentTimeline(page, longChat);
await expectTimelinePromptVisible(page, longChat.newestPrompt);
const readingPosition = await scrollChatAwayFromBottom(page, {
deltaY: -900,
minDistanceFromBottom: 300,
});
await openCompactWorkspace(page, otherWorkspace);
await openCompactWorkspace(page, longChat);
await expect
.poll(async () => Math.abs((await readScrollMetrics(page)).offsetY - readingPosition.offsetY))
.toBeLessThanOrEqual(24);
} finally {
await Promise.allSettled([longChat.cleanup(), otherWorkspace.cleanup()]);
}
});
test("editing keys in an embedded question keep output following", async ({ page }) => {
test.setTimeout(240_000);
const longChat = await seedLongMockAgentTimeline({ turns: 30 });
const questionPrompt = "Emit synthetic questions: two free-write questions.";
const firstQuestion = "What is the GitHub private repo URL to push to?";
const secondQuestion = "What should the first commit message be?";
try {
await page.setViewportSize({ width: 390, height: 844 });
await openAgentTimeline(page, longChat);
await expectTimelinePromptVisible(page, longChat.newestPrompt);
await expectChatAtBottom(page);
await longChat.client.sendAgentMessage(longChat.agentId, questionPrompt);
await waitForQuestionPrompt(page, 30_000);
await fillQuestionAnswer(page, {
question: firstQuestion,
answer: "git@github.com:user/private-repo.git",
});
const firstInput = page
.getByTestId("question-form-card")
.first()
.getByRole("textbox", { name: firstQuestion });
const inputBounds = await firstInput.boundingBox();
expect(inputBounds, "Expected embedded question input bounds").not.toBeNull();
await page.mouse.move(
inputBounds!.x + inputBounds!.width / 2,
inputBounds!.y + inputBounds!.height / 2,
);
await page.mouse.down();
await page.mouse.move(
inputBounds!.x + inputBounds!.width / 2 + 20,
inputBounds!.y + inputBounds!.height / 2 - 3,
);
await page.mouse.up();
await firstInput.press("Home");
await firstInput.press("ArrowUp");
await continueToNextQuestion(page);
await fillQuestionAnswer(page, {
question: secondQuestion,
answer: "Initialize private repo",
});
await submitQuestionAnswers(page);
await longChat.client.waitForFinish(longChat.agentId, 20_000);
await expectChatAtBottom(page);
} finally {
await longChat.cleanup();
}
});
@@ -6,6 +6,7 @@ import {
expectActiveChatOutlinePrompt,
expectChatOutlinePreview,
expectChatOutlinePrompts,
expectChatOutlineAlignedWithActiveTabGlyph,
expectChatOutlinePromptToRemainBare,
expectLiveTurnPromptAboveFoldAndActive,
expectNoChatOutlinePreviewWhileCrossingToSidebar,
@@ -139,6 +140,10 @@ test.describe("desktop chat outline", () => {
await expectChatOutlinePromptToRemainBare(page, 4);
});
test("aligns the ticks with the active tab glyph rail", async ({ page }) => {
await expectChatOutlineAlignedWithActiveTabGlyph(page);
});
test("clamps the newest prompt at maximum scroll while keeping it visible", async ({
page,
}) => {
@@ -201,29 +201,41 @@ test.afterEach(async () => {
}
});
test("changes file actions open below the right-click without a reserved kebab", async ({
page,
}) => {
test("changes diff keeps code rows aligned with the gutter", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await useCodeFont(page, 9);
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await expectDiffCodeFontSize(page, 9);
await expectVisibleDiffRowsAligned(page);
await expectDiffCodeTextAlignedWithGutterText(page, [
{
codeText: "function createInitialMountedTabIds(input: UseMountedTabSetInput)",
lineNumber: "20",
},
{ codeText: "return next;", lineNumber: "55" },
{ codeText: "useLayoutEffect(() => {", lineNumber: "78" },
]);
await expectHoverCommentButtonAlignedWithCodeLine(page, {
codeText: "function createInitialMountedTabIds(input: UseMountedTabSetInput)",
lineNumber: "20",
});
});
test("changes file actions open from the kebab and right-click", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff({ includeDeletedFile: true });
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await expect(page.getByTestId("diff-file-1")).toContainText("zz-deleted.ts");
await expect(page.getByTestId(/diff-file-\d+-actions/)).toHaveCount(0);
await page.getByTestId("diff-file-1-toggle").click({ button: "right" });
await page.getByTestId("diff-file-1-actions").click();
await expect(page.getByText("Copy path")).toBeVisible();
await expect(page.getByTestId("diff-file-1-open-file")).toHaveCount(0);
await page.keyboard.press("Escape");
const fileRow = page.getByTestId("diff-file-0-toggle");
const fileRowBounds = await fileRow.boundingBox();
expect(fileRowBounds).not.toBeNull();
await fileRow.click({ button: "right", position: { x: 80, y: 10 } });
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
await expect(page.getByTestId("diff-file-0-open-file")).toBeVisible();
const menuBounds = await page.getByTestId("diff-file-0-context-menu").boundingBox();
expect(menuBounds).not.toBeNull();
expect(menuBounds!.x).toBeCloseTo(fileRowBounds!.x + 80, 0);
expect(menuBounds!.y).toBeGreaterThan(fileRowBounds!.y + 10);
await page.getByTestId("diff-file-0-open-file").click();
await expect(page.getByTestId("workspace-file-pane")).toBeVisible();
@@ -291,7 +303,7 @@ test("Changes switches between inline and full-tab navigation", async ({ page })
await page.getByTestId("explorer-content-area").getByTestId("diff-file-1-toggle").click();
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1);
await expect(visiblePanel.getByText("zz-deleted.ts", { exact: true })).toBeVisible();
await expect(visiblePanel.getByRole("img", { name: "Deleted" })).toBeVisible();
await expect(visiblePanel).toContainText("Deleted");
await changesTabToggle.click();
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(0);
@@ -328,9 +340,6 @@ test("changes diff switches between flat and tree file lists", async ({ page })
await page.getByTestId("changes-toggle-view-mode").click();
await expect(page.getByTestId("diff-folder-src")).toBeVisible();
await expect(page.getByTestId("diff-file-0")).toBeVisible();
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
await expect(page.getByTestId("diff-file-0-context-menu")).toBeVisible();
await page.keyboard.press("Escape");
await page.getByRole("button", { name: "Collapse all" }).click();
await expect(page.getByTestId("diff-file-0")).toHaveCount(0);
@@ -344,7 +353,56 @@ test("changes diff switches between flat and tree file lists", async ({ page })
await expectFlatFileList(page);
});
test("changes diff applies code size changes to gutter and code typography", async ({ page }) => {
test("workspace file panes keep their controls on shared alignment rails", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await openWorkspaceChanges(page, workspace);
await page.getByTestId("changes-toggle-view-mode").click();
await expect(page.getByTestId("diff-folder-src")).toBeVisible();
const changesRightRail = await Promise.all([
readSvgRight(page, "explorer-close"),
readSvgRight(page, "changes-options-menu"),
readSvgRight(page, "diff-file-0-actions"),
]);
expectAligned(changesRightRail);
const [folderStat, fileStat] = await Promise.all([
page.getByTestId("diff-folder-src-stat").boundingBox(),
page.getByTestId("diff-file-0-stat").boundingBox(),
]);
expect(folderStat).not.toBeNull();
expect(fileStat).not.toBeNull();
expect(folderStat!.x + folderStat!.width).toBeCloseTo(fileStat!.x + fileStat!.width, 0);
await page.getByTestId("explorer-tab-files").click();
await expect(page.getByTestId("file-explorer-row-0")).toBeVisible();
const filesRightRail = await Promise.all([
readSvgRight(page, "explorer-close"),
readSvgRight(page, "files-refresh"),
readSvgRight(page, "file-explorer-row-0-actions"),
]);
expectAligned(filesRightRail);
const [sortLabel, firstRowIcon, treeBounds, rowBounds] = await Promise.all([
page.getByTestId("files-sort-label").boundingBox(),
page.getByTestId("file-explorer-row-0").locator("svg").first().boundingBox(),
page.getByTestId("file-explorer-tree-scroll").boundingBox(),
page.getByTestId("file-explorer-row-0").boundingBox(),
]);
expect(sortLabel).not.toBeNull();
expect(firstRowIcon).not.toBeNull();
expect(treeBounds).not.toBeNull();
expect(rowBounds).not.toBeNull();
expect(sortLabel!.x).toBeCloseTo(firstRowIcon!.x, 0);
expect(rowBounds!.x).toBeCloseTo(treeBounds!.x, 0);
expect(rowBounds!.x + rowBounds!.width).toBeCloseTo(treeBounds!.x + treeBounds!.width, 0);
});
test("changes diff keeps unwrapped gutter and code rows aligned after code size changes", async ({
page,
}) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await useCodeFont(page, 12);
await useUnwrappedDiffLines(page);
@@ -357,6 +415,7 @@ test("changes diff applies code size changes to gutter and code typography", asy
await expectDiffCodeFontSize(page, 18);
await expectVisibleDiffRowsShareTypography(page);
await expectVisibleDiffRowsAligned(page);
});
async function useCodeFont(page: Page, codeFontSize: number): Promise<void> {
@@ -417,17 +476,24 @@ async function expectDiffCodeFontSize(page: Page, fontSize: number): Promise<voi
.toBe(fontSize);
}
async function expectVisibleDiffRowsAligned(page: Page): Promise<void> {
const geometry = await readVisibleDiffRowGeometry(page);
expect(geometry.maxDelta, JSON.stringify(geometry.rows, null, 2)).toBeLessThanOrEqual(1);
}
async function expectVisibleDiffRowsShareTypography(page: Page): Promise<void> {
const geometry = await readVisibleDiffRowGeometry(page);
expect(geometry.mismatchedTypography, JSON.stringify(geometry, null, 2)).toEqual([]);
}
async function readVisibleDiffRowGeometry(page: Page): Promise<{
maxDelta: number;
mismatchedTypography: { index: number; gutterLineHeight: number; codeLineHeight: number }[];
rows: {
index: number;
gutterTop: number;
codeTop: number;
delta: number;
gutterLineHeight: number;
codeLineHeight: number;
}[];
@@ -462,6 +528,7 @@ async function readVisibleDiffRowGeometry(page: Page): Promise<{
index: code.index,
gutterTop: gutter.top,
codeTop: code.top,
delta: Math.abs(code.top - gutter.top),
gutterLineHeight: gutter.lineHeight,
codeLineHeight: code.lineHeight,
};
@@ -469,6 +536,7 @@ async function readVisibleDiffRowGeometry(page: Page): Promise<{
.filter((row) => row.gutterTop >= 0 && row.codeTop >= 0);
return {
maxDelta: Math.max(...rows.map((row) => row.delta)),
mismatchedTypography: rows
.filter((row) => Math.abs(row.gutterLineHeight - row.codeLineHeight) > 0.5)
.map((row) => ({
@@ -488,7 +556,7 @@ async function createWorkspaceWithMountedTabDiff(
if (options.includeDeletedFile) {
files.push({ path: "src/zz-deleted.ts", content: "export const deleted = true;\n" });
}
const repo = await createTempGitRepo("changes-pane-", { files });
const repo = await createTempGitRepo("diff-row-alignment-", { files });
const client = await connectSeedClient();
cleanupTasks.push({
run: async () => {
@@ -525,6 +593,21 @@ async function openChangesInVisibleExplorer(page: Page): Promise<void> {
await expect(page.getByText("use-mounted-tab-set.ts")).toBeVisible({ timeout: 30_000 });
}
async function readSvgRight(page: Page, testID: string): Promise<number> {
const box = await page.getByTestId(testID).locator("svg").first().boundingBox();
if (!box) {
throw new Error(`Could not measure ${testID}`);
}
return box.x + box.width;
}
function expectAligned(values: number[]): void {
const [first, ...rest] = values;
for (const value of rest) {
expect(value).toBeCloseTo(first, 0);
}
}
async function expectExpandedMountedTabDiff(page: Page): Promise<void> {
await expect(page.getByTestId("diff-file-0-body")).toBeVisible({ timeout: 30_000 });
await expect(page.getByText("function createInitialMountedTabIds")).toBeVisible({
@@ -577,3 +660,86 @@ async function scrollToLowerUnwrappedDiffRows(page: Page): Promise<void> {
await page.getByTestId(`diff-code-row-${lastRowIndex}`).scrollIntoViewIfNeeded();
await expect(page.getByTestId(`diff-code-row-${lastRowIndex}`)).toBeVisible();
}
async function expectDiffCodeTextAlignedWithGutterText(
page: Page,
lines: { codeText: string; lineNumber: string }[],
): Promise<void> {
const geometries = await readDiffTextGeometry(page, lines);
for (const geometry of geometries) {
expect(geometry.codeTop, geometry.codeText).toBeCloseTo(geometry.gutterTop, 0);
}
}
async function expectHoverCommentButtonAlignedWithCodeLine(
page: Page,
line: { codeText: string; lineNumber: string },
): Promise<void> {
const target = await readDiffTextGeometry(page, [line]).then((rows) => rows[0]);
if (!target) {
throw new Error(`Could not find target line ${line.lineNumber}`);
}
await page.getByTestId(`diff-code-row-${target.index}`).hover();
const geometry = await page
.getByTestId(`diff-gutter-action-${target.index}`)
.evaluate((action, expectedCodeCenterY) => {
const rect = action.getBoundingClientRect();
return {
actionCenterY: rect.top + rect.height / 2,
codeCenterY: expectedCodeCenterY,
};
}, target.codeCenterY);
expect(geometry.actionCenterY).toBeCloseTo(geometry.codeCenterY, 0);
}
async function readDiffTextGeometry(
page: Page,
lines: { codeText: string; lineNumber: string }[],
): Promise<
{ index: number; codeText: string; codeTop: number; gutterTop: number; codeCenterY: number }[]
> {
return page.locator("body").evaluate(({ ownerDocument }, targets) => {
const root = ownerDocument.querySelector('[data-testid="explorer-content-area"]');
if (!root) {
throw new Error("Changes panel is not mounted");
}
const readIndexedElements = (prefix: string) =>
Array.from(root.querySelectorAll<HTMLElement>(`[data-testid^="${prefix}"]`)).map(
(element) => {
const testId = element.getAttribute("data-testid") ?? "";
return { index: Number(testId.slice(prefix.length)), element };
},
);
const gutterTexts = readIndexedElements("diff-gutter-text-");
const codeTexts = readIndexedElements("diff-code-text-");
return targets.map((target) => {
const gutter = gutterTexts.find(
({ element }) => (element.textContent ?? "").trim() === target.lineNumber,
);
if (!gutter) {
throw new Error(`Could not find gutter line ${target.lineNumber}`);
}
const code = codeTexts.find(
({ index, element }) =>
index === gutter.index && (element.textContent ?? "").includes(target.codeText),
);
if (!code) {
throw new Error(`Could not find code row ${target.codeText}`);
}
const codeRect = code.element.getBoundingClientRect();
const gutterRect = gutter.element.getBoundingClientRect();
return {
index: gutter.index,
codeText: target.codeText,
codeTop: codeRect.top,
gutterTop: gutterRect.top,
codeCenterY: codeRect.top + codeRect.height / 2,
};
});
}, lines);
}
@@ -99,8 +99,11 @@ test.describe("Host appearance", () => {
await expectHostBadgeName(page, { ...badge, hostName: "Build Box" });
});
test("host names use the available metadata width", async ({ page, twoHostSidebar }) => {
const hostName = "Developer MacBook Pro.local";
test("long host names leave room for the workspace title and actions", async ({
page,
twoHostSidebar,
}) => {
const hostName = "A very long remote build host name that must not take over the sidebar";
const workspaceKey = `${twoHostSidebar.secondaryServerId}:${twoHostSidebar.secondaryWorkspaceId}`;
await openHostAppearanceSettings(page, twoHostSidebar.secondaryServerId);
@@ -110,18 +113,25 @@ test.describe("Host appearance", () => {
const row = page.getByTestId(`sidebar-workspace-row-${workspaceKey}`);
const badge = row.getByTestId(`sidebar-host-badge-${twoHostSidebar.secondaryServerId}`);
await expect(badge).toHaveText(hostName);
const [badgeBox, rowBox] = await Promise.all([badge.boundingBox(), row.boundingBox()]);
await row.hover();
const title = row.getByText("Secondary workspace", { exact: true });
const kebab = page.getByTestId(`sidebar-workspace-kebab-${workspaceKey}`);
await expect(kebab).toBeVisible();
const [badgeBox, titleBox, rowBox, kebabBox] = await Promise.all([
badge.boundingBox(),
title.boundingBox(),
row.boundingBox(),
kebab.boundingBox(),
]);
expect(badgeBox).not.toBeNull();
expect(titleBox).not.toBeNull();
expect(rowBox).not.toBeNull();
await expect(badge).toHaveCSS("flex-shrink", "1");
expect(badgeBox!.width).toBeGreaterThan(96);
expect(badgeBox!.x + badgeBox!.width).toBeLessThanOrEqual(rowBox!.x + rowBox!.width);
const labelWidths = await badge.getByText(hostName, { exact: true }).evaluate((label) => ({
clientWidth: label.clientWidth,
scrollWidth: label.scrollWidth,
}));
expect(labelWidths.scrollWidth).toBeLessThanOrEqual(labelWidths.clientWidth);
expect(kebabBox).not.toBeNull();
await expect(badge).toHaveCSS("flex-shrink", "0");
expect(titleBox!.width).toBeGreaterThan(0);
expect(kebabBox!.x + kebabBox!.width).toBeLessThanOrEqual(rowBox!.x + rowBox!.width);
});
test("picking a color identifies the badge", async ({ page, twoHostSidebar }) => {
+1 -149
View File
@@ -24,10 +24,6 @@ import {
openProjectViaDaemon,
openStartingRefPicker,
pasteGithubPrUrl,
captureStartingRefPicker,
expectStartingRefRows,
startingRefRow,
submitNewWorkspaceEmpty,
searchAndSelectBranchInPicker,
selectBranchInPicker,
selectGitHubPrInPicker,
@@ -35,14 +31,7 @@ import {
selectWorkspaceIsolation,
submitNewWorkspacePrompt,
} from "../support/helpers/new-workspace";
import {
commitLocalOnly,
createTempGitRepo,
readRepoRef,
readWorktreeBaseMetadata,
readWorktreeBranchInfo,
trackForkUpstream,
} from "../support/helpers/workspace";
import { createTempGitRepo, readWorktreeBranchInfo } from "../support/helpers/workspace";
import {
createLocalGithubPrFixture,
cloneGithubRepoDefaultBranchOnly,
@@ -79,7 +68,6 @@ interface WorkspaceStatusGroupEvent {
async function switchSidebarToStatusGrouping(page: import("@playwright/test").Page) {
await page.getByTestId("sidebar-display-preferences-menu").click();
await page.getByTestId("sidebar-display-grouping").click();
await page.getByTestId("sidebar-grouping-status").click();
await expect(page.getByTestId("sidebar-status-group-done")).toBeVisible({ timeout: 30_000 });
}
@@ -755,142 +743,6 @@ test.describe("New workspace flow", () => {
}
});
// The starting ref the daemon actually cuts from is the thing that broke: the picker said
// one ref and the worktree was created from another. Every assertion here reads the
// created worktree's commits or its recorded base, never the trigger text alone.
test.describe("default starting ref", () => {
async function openWorktreeComposerForRepo(
page: import("@playwright/test").Page,
repoPath: string,
) {
const openedProject = await openProjectViaDaemon(client, repoPath);
localWorkspaceIds.add(openedProject.workspaceId);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openNewWorkspaceComposer(page, {
projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName,
});
await selectWorkspaceIsolation(page, "worktree");
return openedProject;
}
async function createWorktreeAndRead(
page: import("@playwright/test").Page,
openedProject: Awaited<ReturnType<typeof openProjectViaDaemon>>,
) {
await submitNewWorkspaceEmpty(page);
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
serverId: getServerId(),
client,
previousWorkspaceId: openedProject.workspaceId,
projectDisplayName: openedProject.projectDisplayName,
});
createdWorktreeDirectories.add(createdWorkspace.workspaceDirectory);
return {
...createdWorkspace,
branchInfo: await readWorktreeBranchInfo({
worktreePath: createdWorkspace.workspaceDirectory,
}),
};
}
test("branches off the upstream when the local branch is ahead and the picker is untouched", async ({
page,
}) => {
const tempRepo = await createTempGitRepo("ref-default-ahead-", { withRemote: true });
try {
const originHead = readRepoRef(tempRepo.path, "refs/remotes/origin/main");
commitLocalOnly(tempRepo.path, "one");
const localHead = commitLocalOnly(tempRepo.path, "two");
const openedProject = await openWorktreeComposerForRepo(page, tempRepo.path);
const created = await createWorktreeAndRead(page, openedProject);
expect(created.branchInfo.hasAncestor(originHead)).toBe(true);
expect(created.branchInfo.hasAncestor(localHead)).toBe(false);
} finally {
await tempRepo.cleanup();
}
});
test("branches off the local ref when the local row is chosen explicitly", async ({
page,
}, testInfo) => {
const tempRepo = await createTempGitRepo("ref-default-local-pick-", { withRemote: true });
try {
commitLocalOnly(tempRepo.path, "one");
const localHead = commitLocalOnly(tempRepo.path, "two");
const openedProject = await openWorktreeComposerForRepo(page, tempRepo.path);
await openStartingRefPicker(page);
await expectStartingRefRows(page, [
"main, origin branch",
"main, local branch, 2 commits ahead of origin main",
]);
const screenshotPath = testInfo.outputPath("ref-picker-local-ahead.png");
await captureStartingRefPicker(page, screenshotPath);
await testInfo.attach("Ref picker: local ahead of upstream", {
path: screenshotPath,
contentType: "image/png",
});
await startingRefRow(page, "main, local branch, 2 commits ahead of origin main").click();
await expectPickerSelected(page, "main (local)");
const created = await createWorktreeAndRead(page, openedProject);
expect(created.branchInfo.hasAncestor(localHead)).toBe(true);
} finally {
await tempRepo.cleanup();
}
});
test("branches off a fork's upstream remote and records the branch name", async ({
page,
}, testInfo) => {
const tempRepo = await createTempGitRepo("ref-default-fork-", { withRemote: true });
try {
const upstreamHead = await trackForkUpstream(tempRepo.path);
const originHead = readRepoRef(tempRepo.path, "refs/remotes/origin/main");
const openedProject = await openWorktreeComposerForRepo(page, tempRepo.path);
await openStartingRefPicker(page);
// Branch suggestions only know about origin, so the upstream the fork actually
// tracks gets its own row rather than silently sharing origin's. Two rows reading
// "main" is the ambiguity this whole change exists to remove.
await expectStartingRefRows(page, [
"main (upstream), upstream branch",
"main, origin branch",
]);
await expectPickerSelected(page, "main (upstream)");
const screenshotPath = testInfo.outputPath("ref-picker-fork.png");
await captureStartingRefPicker(page, screenshotPath);
await testInfo.attach("Ref picker: fork tracking upstream/main", {
path: screenshotPath,
contentType: "image/png",
});
await closeBranchPicker(page);
const created = await createWorktreeAndRead(page, openedProject);
expect(created.branchInfo.hasAncestor(upstreamHead)).toBe(true);
expect(upstreamHead).not.toBe(originHead);
// The name is what the UI shows; the ref is what resolves back to this commit.
expect(await readWorktreeBaseMetadata(created.workspaceDirectory)).toEqual({
baseRefName: "main",
baseRef: "refs/remotes/upstream/main",
});
} finally {
await tempRepo.cleanup();
}
});
});
test("branch picker opens via keyboard and selects the filtered option on Enter", async ({
page,
}) => {
@@ -4,7 +4,7 @@ import { gotoAppShell } from "../support/helpers/app";
import {
addOfflineHostAndReload,
expectHostFilterRow,
openSidebarHostFilter,
openSidebarDisplayPreferences,
selectAllHostsFilter,
toggleHostFilter,
} from "../support/helpers/hosts";
@@ -29,7 +29,7 @@ test.describe("Sidebar host filter (multi-select)", () => {
await addOfflineHostAndReload(page, { serverId: SECONDARY_HOST_ID, label: "Secondary Host" });
await expect(workspaceRow).toBeVisible({ timeout: 30_000 });
await openSidebarHostFilter(page);
await openSidebarDisplayPreferences(page);
await expectHostFilterRow(page, serverId);
await expectHostFilterRow(page, SECONDARY_HOST_ID);
@@ -1,49 +0,0 @@
import { expect, test, type Page } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { projectEquivalenceViewKey } from "../support/helpers/project-view-key";
import { seedWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
test.use({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true });
async function closeMenuSheet(page: Page): Promise<void> {
const backdrop = page.getByRole("button", { name: "Bottom sheet backdrop" }).first();
await backdrop.click({ position: { x: 12, y: 12 } });
await expect(backdrop).not.toBeVisible({ timeout: 10_000 });
}
test("project and workspace kebabs open action sheets on compact layouts", async ({ page }) => {
const seeded = await seedWorkspace({ repoPrefix: "sidebar-mobile-menu-sheet-" });
try {
await gotoAppShell(page);
await page.getByRole("button", { name: "Open menu", exact: true }).click();
await waitForSidebarHydration(page);
await page
.getByTestId(`sidebar-project-kebab-${projectEquivalenceViewKey(seeded.projectKey)}`)
.click();
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" }).first()).toBeVisible({
timeout: 10_000,
});
await expect(page.getByText("Project actions", { exact: true })).toBeVisible();
await closeMenuSheet(page);
const workspaceRow = page.getByTestId(
`sidebar-workspace-row-${getServerId()}:${seeded.workspaceId}`,
);
await workspaceRow.hover();
await workspaceRow
.getByTestId(`sidebar-workspace-kebab-${getServerId()}:${seeded.workspaceId}`)
.click();
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" }).first()).toBeVisible({
timeout: 10_000,
});
await expect(page.getByText("Workspace actions", { exact: true })).toBeVisible();
} finally {
await seeded.cleanup();
}
});
@@ -133,7 +133,6 @@ test.describe("Model B sidebar shape", () => {
// Switch to status grouping.
await page.getByTestId("sidebar-display-preferences-menu").click();
await page.getByTestId("sidebar-display-grouping").click();
await page.getByTestId("sidebar-grouping-status").click();
const sidebar = page.getByTestId("sidebar-sessions").filter({ visible: true }).first();
@@ -40,7 +40,6 @@ async function collapseProjectSection(page: Page, project: SeededWorkspace): Pro
async function switchToStatusGrouping(page: Page): Promise<void> {
await page.getByTestId("sidebar-display-preferences-menu").click();
await page.getByTestId("sidebar-display-grouping").click();
await page.getByTestId("sidebar-grouping-status").click();
await expect(page.getByTestId("sidebar-status-list-scroll")).toBeVisible({ timeout: 10_000 });
}
@@ -6,6 +6,7 @@ import {
expectMobileAgentSidebarHidden,
expectMobileAgentSidebarVisible,
openMobileAgentSidebar,
pinWorkspaceFromSidebar,
} from "../support/helpers/sidebar";
import { seedWorkspace } from "../support/helpers/seed-client";
import { expectWorkspaceHeader } from "../support/helpers/workspace-ui";
@@ -227,18 +228,19 @@ test.describe("Mobile sidebar panelState transition", () => {
const workspace = await seedWorkspace({ repoPrefix: "sidebar-retained-pin-" });
try {
// Pinning behavior has its own E2E suite. Seed this test's precondition directly so it only
// exercises whether the retained compact sidebar keeps the pinned row mounted while closed.
await workspace.client.setWorkspacePinned(workspace.workspaceId, true);
await gotoAppShell(page);
await openMobileAgentSidebar(page);
await expectMobileAgentSidebarVisible(page);
const row = page.getByTestId(getWorkspaceRowTestId(workspace.workspaceId));
await expect(row).toBeVisible({ timeout: 30_000 });
await pinWorkspaceFromSidebar(page, workspace.workspaceId);
await expect(page.getByTestId("sidebar-pinned-section")).toBeVisible();
await closeMobileAgentSidebar(page);
await expectMobileAgentSidebarHidden(page);
await expect(page.getByTestId(getWorkspaceRowTestId(workspace.workspaceId))).toHaveCount(1);
await expect(row).toHaveCount(1);
} finally {
await workspace.cleanup();
}
@@ -97,7 +97,7 @@ async function expectAgentConsistentlyIdle(page: Page, title: string): Promise<v
}
test.describe("Viewed agent timelines", () => {
test("a turn that finishes while hidden reopens with consistently idle chrome", async ({
test("a turn that finishes while unsubscribed reopens with consistently idle chrome", async ({
page,
}) => {
test.setTimeout(150_000);
@@ -105,9 +105,9 @@ test.describe("Viewed agent timelines", () => {
const scenario = await seedViewedTimelineScenario({ firstAgentModel: "one-minute-stream" });
try {
await openAgent(page, scenario, scenario.firstAgentId);
await startVisibleTurn(page, scenario, "Finish after this chat becomes hidden.");
await startVisibleTurn(page, scenario, "Finish after this chat becomes unsubscribed.");
await selectAgent(page, "Second viewed chat");
await subscriptions.waitForSubscribedAgents([scenario.firstAgentId, scenario.secondAgentId]);
await subscriptions.waitForSubscribedAgents([scenario.secondAgentId], { timeout: 45_000 });
const finish = await scenario.client.waitForFinish(scenario.firstAgentId, 90_000);
expect(finish.status).toBe("idle");
@@ -118,7 +118,33 @@ test.describe("Viewed agent timelines", () => {
}
});
test("a hidden hot chat stays current", async ({ page }) => {
test("an unsubscribed hidden chat catches up when shown", async ({ page }) => {
test.setTimeout(90_000);
const subscriptions = observeTimelineSubscriptions(page);
const scenario = await seedViewedTimelineScenario();
try {
await openAgent(page, scenario, scenario.firstAgentId);
await selectAgent(page, "Second viewed chat");
await subscriptions.waitForSubscribedAgents([scenario.secondAgentId], { timeout: 45_000 });
await commitMessage(
scenario,
scenario.firstAgentId,
"Committed after the first chat unsubscribed.",
);
await expect(
page.getByText("Committed after the first chat unsubscribed.", { exact: true }),
).toHaveCount(0);
await selectAgent(page, "First viewed chat");
await expect(
page.getByText("Committed after the first chat unsubscribed.", { exact: true }),
).toBeVisible();
await expect(page.getByText("(end of synthetic stream)", { exact: true })).toBeVisible();
} finally {
await scenario.cleanup();
}
});
test("a hidden retained chat stays current during unsubscribe grace", async ({ page }) => {
test.setTimeout(60_000);
const subscriptions = observeTimelineSubscriptions(page);
const scenario = await seedViewedTimelineScenario();
@@ -83,7 +83,6 @@ async function fetchAgentStatus(seeded: SeededWorkspace, agentId: string): Promi
async function switchSidebarToStatusGrouping(page: import("@playwright/test").Page) {
await page.getByTestId("sidebar-display-preferences-menu").click();
await page.getByTestId("sidebar-display-grouping").click();
await page.getByTestId("sidebar-grouping-status").click();
await expect(page.locator('[data-testid^="sidebar-status-group-"]').first()).toBeVisible({
timeout: 30_000,
@@ -398,7 +398,6 @@ async function expectWorkspaceRowInStatusBucket(
input: { serverId: string; workspaceId: string; bucket: string },
) {
await page.getByTestId("sidebar-display-preferences-menu").click();
await page.getByTestId("sidebar-display-grouping").click();
await page.getByTestId("sidebar-grouping-status").click();
await expect(
page
@@ -1,13 +0,0 @@
context platform=ios timeout=60000 retries=1
env APP_ID=sh.paseo.debug
open "${APP_ID}"
wait "id=\"workspace-header-menu-trigger\"" 20000
press "id=\"workspace-explorer-toggle\""
wait "id=\"explorer-tab-files\"" 10000
press "id=\"explorer-tab-files\""
wait "id=\"file-explorer-row-0\"" 10000
longpress "id=\"file-explorer-row-0\"" 600
wait "id=\"file-explorer-row-0-context-menu-content\"" 10000
is visible "text=\"Copy path\""
close
@@ -111,34 +111,27 @@ export async function scrollChatAwayFromBottom(
if (!box) {
throw new Error("Agent chat scroll container is not visible");
}
const wheelSettled = scroll.evaluate(
(root: Element) =>
new Promise<void>((resolve) => {
root.addEventListener(
"wheel",
() => {
const scrollElement = root as HTMLElement;
let previousOffset = scrollElement.scrollTop;
let stableFrames = 0;
const sample = () => {
const currentOffset = scrollElement.scrollTop;
stableFrames = Math.abs(currentOffset - previousOffset) <= 0.5 ? stableFrames + 1 : 0;
previousOffset = currentOffset;
if (stableFrames >= 3) {
resolve();
return;
}
requestAnimationFrame(sample);
};
requestAnimationFrame(sample);
},
{ once: true },
);
}),
);
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.wheel(0, input.deltaY);
await wheelSettled;
await scroll.evaluate(
(root: Element) =>
new Promise<void>((resolve) => {
const scrollElement = root as HTMLElement;
let previousOffset = scrollElement.scrollTop;
let stableFrames = 0;
const sample = () => {
const currentOffset = scrollElement.scrollTop;
stableFrames = Math.abs(currentOffset - previousOffset) <= 0.5 ? stableFrames + 1 : 0;
previousOffset = currentOffset;
if (stableFrames >= 3) {
resolve();
return;
}
requestAnimationFrame(sample);
};
requestAnimationFrame(sample);
}),
);
await expect
.poll(async () => {
@@ -125,6 +125,26 @@ export async function expectChatOutlinePromptToRemainBare(
await expect(chatOutlinePrompt(page, position)).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
}
export async function expectChatOutlineAlignedWithActiveTabGlyph(page: Page): Promise<void> {
const outlinePill = chatOutlinePrompt(page, 1).locator("div").first();
const activeTabGlyph = page
.getByTestId("workspace-tabs-row")
.filter({ visible: true })
.locator('[role="button"][aria-selected="true"]')
.locator("svg")
.first();
await expect
.poll(async () => {
const [pillBox, glyphBox] = await Promise.all([
requireBoundingBox(outlinePill),
requireBoundingBox(activeTabGlyph),
]);
return Math.abs(pillBox.x - glyphBox.x);
})
.toBeLessThanOrEqual(1);
}
/** Exactly one prompt is marked, without saying which — the reader always has a "you are here". */
export async function expectOneActiveChatOutlinePrompt(page: Page): Promise<void> {
await expect(chatOutlineRail(page).getByRole("tab", { selected: true })).toHaveCount(1);
@@ -0,0 +1,221 @@
import { expect, type Locator, type Page, type TestInfo } from "@playwright/test";
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
import { seedParentWithSubagent } from "./subagents";
import { seedWorkspace, type SeededWorkspace } from "./seed-client";
interface HeartbeatScheduleClient {
scheduleCreate(input: {
prompt: string;
name: string;
cadence: { type: "every"; everyMs: number };
target: { type: "agent"; agentId: string };
runOnCreate: false;
expiresAt?: string;
}): Promise<{ schedule: ScheduleSummary | null; error: string | null }>;
scheduleDelete(input: { id: string }): Promise<{ error: string | null }>;
scheduleList(): Promise<{ schedules: ScheduleSummary[]; error: string | null }>;
scheduleUpdate(input: {
id: string;
expiresAt: string;
}): Promise<{ schedule: ScheduleSummary | null; error: string | null }>;
}
export interface SeededHeartbeat {
id: string;
name: string;
cadence: string;
}
export interface AgentHeartbeatScenario {
workspace: SeededWorkspace;
parent: { id: string; title: string };
child: { id: string; title: string };
createHeartbeat(input?: { name?: string; expiresAt?: string }): Promise<SeededHeartbeat>;
setHeartbeatExpiry(id: string, expiresAt: string): Promise<void>;
readWorkspaceStatus(): Promise<string | null>;
readHeartbeatExists(id: string): Promise<boolean>;
readAgentStatuses(): Promise<Record<string, string>>;
cleanup(): Promise<void>;
}
export async function seedAgentWithHeartbeat(): Promise<AgentHeartbeatScenario> {
const workspace = await seedWorkspace({
repoPrefix: "agent-heartbeat-track-",
title: "Heartbeat workspace",
});
const scheduleClient = workspace.client as unknown as HeartbeatScheduleClient;
try {
const agents = await seedParentWithSubagent(workspace, {
parentTitle: "Build babysitter",
childTitle: "Build observer",
});
await workspace.client.waitForAgentUpsert(
agents.parent.id,
(snapshot) => snapshot.status === "idle",
30_000,
);
await workspace.client.waitForAgentUpsert(
agents.child.id,
(snapshot) => snapshot.status === "idle",
30_000,
);
const heartbeatIds = new Set<string>();
return {
workspace,
parent: agents.parent,
child: agents.child,
createHeartbeat: async (input = {}) => {
const name = input.name ?? "Watch the build";
const created = await scheduleClient.scheduleCreate({
prompt: "Check the build and report any failures.",
name,
cadence: { type: "every", everyMs: 15 * 60_000 },
target: { type: "agent", agentId: agents.parent.id },
runOnCreate: false,
...(input.expiresAt ? { expiresAt: input.expiresAt } : {}),
});
if (!created.schedule) {
throw new Error(created.error ?? "Failed to create heartbeat");
}
heartbeatIds.add(created.schedule.id);
return { id: created.schedule.id, name, cadence: "Every 15 minutes" };
},
setHeartbeatExpiry: async (id, expiresAt) => {
const updated = await scheduleClient.scheduleUpdate({ id, expiresAt });
if (!updated.schedule) {
throw new Error(updated.error ?? "Failed to update heartbeat expiry");
}
},
readWorkspaceStatus: async () => {
const result = await workspace.client.fetchWorkspaces();
return (
(
result.entries.find((entry) => entry.id === workspace.workspaceId) as
| ((typeof result.entries)[number] & { status?: string })
| undefined
)?.status ?? null
);
},
readHeartbeatExists: async (id) => {
const result = await scheduleClient.scheduleList();
return result.schedules.some((schedule) => schedule.id === id);
},
readAgentStatuses: async () => {
const result = await workspace.client.fetchAgents({ scope: "active" });
return Object.fromEntries(result.entries.map(({ agent }) => [agent.id, agent.status]));
},
cleanup: async () => {
await Promise.all(
Array.from(heartbeatIds, (id) =>
scheduleClient.scheduleDelete({ id }).catch(() => undefined),
),
);
await workspace.cleanup();
},
};
} catch (error) {
await workspace.cleanup();
throw error;
}
}
export async function expectWorkspaceRunning(page: Page): Promise<void> {
await expect(
page
.getByRole("button", { name: /^Heartbeat workspace(?:, Working)?$/ })
.getByTestId("workspace-status-indicator-running"),
).toBeVisible({ timeout: 60_000 });
}
export async function expectWorkspaceDone(page: Page): Promise<void> {
const workspaceRow = page.getByRole("button", {
name: /^Heartbeat workspace(?:, Working)?$/,
});
await expect(workspaceRow.getByTestId("workspace-status-indicator-done")).toBeVisible({
timeout: 60_000,
});
await expect(workspaceRow.getByTestId("workspace-status-indicator-running")).toHaveCount(0);
}
export async function openHeartbeatAndSubagentTracks(
page: Page,
heartbeat: SeededHeartbeat,
subagentId: string,
): Promise<void> {
await openHeartbeatTrack(page, heartbeat);
await ensureTrackExpanded(
page,
"1 subagent",
page.getByTestId(`subagents-track-row-${subagentId}`),
);
}
export async function openHeartbeatTrack(page: Page, heartbeat: SeededHeartbeat): Promise<void> {
await ensureTrackExpanded(
page,
"1 heartbeat",
page.getByRole("link", { name: `${heartbeat.name}, ${heartbeat.cadence}` }),
);
}
export async function expectHeartbeatVisible(
page: Page,
heartbeat: SeededHeartbeat,
): Promise<void> {
await expect(
page.getByRole("link", { name: `${heartbeat.name}, ${heartbeat.cadence}` }),
).toBeVisible({ timeout: 30_000 });
}
export async function openHeartbeat(page: Page, heartbeat: SeededHeartbeat): Promise<void> {
await page.getByRole("link", { name: `${heartbeat.name}, ${heartbeat.cadence}` }).click();
}
export async function expectHeartbeatDetails(
page: Page,
heartbeat: SeededHeartbeat,
): Promise<void> {
const sheet = page.getByTestId("schedule-form-sheet");
await expect(sheet).toBeVisible({ timeout: 30_000 });
await expect(sheet.getByText("Edit heartbeat", { exact: true })).toBeVisible();
await expect(page).toHaveURL(new RegExp(`scheduleId=${heartbeat.id}`));
await expect(page.getByText("*/15 * * * *", { exact: true }).first()).toBeVisible();
}
export async function returnToAgentHeartbeat(page: Page): Promise<void> {
await page.goBack();
await expect(page.getByRole("button", { name: "1 heartbeat" })).toBeVisible({
timeout: 30_000,
});
}
export async function removeSeededSubagent(scenario: AgentHeartbeatScenario): Promise<void> {
await scenario.workspace.client.archiveAgent(scenario.child.id);
}
export async function deleteHeartbeat(page: Page, heartbeat: SeededHeartbeat): Promise<void> {
const row = page.getByRole("link", { name: `${heartbeat.name}, ${heartbeat.cadence}` });
await row.hover();
page.once("dialog", (dialog) => void dialog.accept());
await page.getByRole("button", { name: `Delete heartbeat ${heartbeat.name}` }).click();
await expect(row).toHaveCount(0, { timeout: 30_000 });
}
export async function captureHeartbeatEvidence(
page: Page,
testInfo: TestInfo,
name: string,
): Promise<void> {
const path = testInfo.outputPath(`${name}.png`);
await page.screenshot({ path, fullPage: true });
await testInfo.attach(name, { path, contentType: "image/png" });
}
async function ensureTrackExpanded(page: Page, name: string, content: Locator): Promise<void> {
const disclosure = page.getByRole("button", { name });
await expect(disclosure).toBeVisible({ timeout: 30_000 });
if (!(await content.isVisible())) {
await disclosure.click();
}
await expect(content).toBeVisible({ timeout: 30_000 });
}
+37 -2
View File
@@ -123,12 +123,11 @@ export async function waitForConnectedHost(
await expect(host).not.toBeVisible();
}
export async function openSidebarHostFilter(page: Page): Promise<void> {
export async function openSidebarDisplayPreferences(page: Page): Promise<void> {
await page.getByTestId("sidebar-display-preferences-menu").click();
await expect(page.getByTestId("sidebar-display-preferences-content")).toBeVisible({
timeout: 10_000,
});
await page.getByTestId("sidebar-display-host-filter").click();
}
// A host's filter row carries a status dot on the left next to its label.
@@ -145,6 +144,32 @@ export async function selectAllHostsFilter(page: Page): Promise<void> {
await page.getByTestId("sidebar-host-filter-all").click();
}
// Playwright reports resolved colors, so the expected value is derived from the same identity
// table the app reads. A hex literal in the test would be a second copy of the palette.
function toRgb(hex: string): string {
const { r, g, b } = parseHex(hex);
return `rgb(${r}, ${g}, ${b})`;
}
function toRgba(hex: string, alpha: number): string {
const { r, g, b } = parseHex(hex);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
interface RgbChannels {
r: number;
g: number;
b: number;
}
function parseHex(hex: string): RgbChannels {
return {
r: Number.parseInt(hex.slice(1, 3), 16),
g: Number.parseInt(hex.slice(3, 5), 16),
b: Number.parseInt(hex.slice(5, 7), 16),
};
}
export async function openHostAppearanceSettings(page: Page, serverId: string): Promise<void> {
await openSettings(page);
await selectSettingsHost(page, serverId);
@@ -223,6 +248,11 @@ export async function expectHostBadgeTinted(
): Promise<void> {
const badge = hostBadge(page, target);
await expect(badge).toBeVisible({ timeout: 15_000 });
await expect(badge.getByText(target.hostName)).toHaveCSS(
"color",
toRgb(identityColor(target.color)),
);
await expect(badge).toHaveCSS("background-color", toRgba(identityColor(target.color), 0.1));
await expect(badge.locator("svg")).toHaveAttribute("stroke", identityColor(target.color));
}
@@ -234,6 +264,11 @@ export async function expectHostAppearancePreview(
await expect(preview).toBeVisible();
const badge = preview.getByTestId(`sidebar-host-badge-${input.serverId}`);
await expect(badge).toHaveText(input.hostName);
await expect(badge.getByText(input.hostName)).toHaveCSS(
"color",
toRgb(identityColor(input.color)),
);
await expect(badge).toHaveCSS("background-color", toRgba(identityColor(input.color), 0.1));
await expect(badge.locator("svg")).toHaveAttribute("stroke", identityColor(input.color));
}
@@ -348,23 +348,6 @@ export async function searchAndSelectBranchInPicker(page: Page, name: string): P
await selectBranchInPicker(page, name);
}
// Ref picker rows are named for a user: "main, origin branch" is the upstream copy and
// "main, local branch, 2 commits ahead of origin main" is the local one.
export function startingRefRow(page: Page, accessibleName: string) {
return page.getByRole("button", { name: accessibleName, exact: true });
}
export async function expectStartingRefRows(page: Page, accessibleNames: string[]): Promise<void> {
for (const name of accessibleNames) {
await expect(startingRefRow(page, name)).toBeVisible({ timeout: 30_000 });
}
}
export async function captureStartingRefPicker(page: Page, screenshotPath: string): Promise<void> {
await expect(page.getByTestId("combobox-desktop-container")).toBeVisible({ timeout: 30_000 });
await page.getByTestId("combobox-desktop-container").screenshot({ path: screenshotPath });
}
export async function selectGitHubPrInPicker(page: Page, number: number): Promise<void> {
const prRow = page.getByTestId(`new-workspace-ref-picker-pr-${number}`);
await expect(prRow).toBeVisible({ timeout: 30_000 });
@@ -39,7 +39,6 @@ export interface SeedDaemonClient {
}>;
removeProject(projectId: string): Promise<{ removedWorkspaceIds: string[] }>;
renameProject(projectId: string, customName: string | null): Promise<void>;
setWorkspacePinned(workspaceId: string, pinned: boolean): Promise<{ pinnedAt: string | null }>;
fetchWorkspaces(options?: { filter?: { projectId?: string } }): Promise<{
entries: SeedWorkspaceDescriptor[];
}>;
@@ -1,4 +1,9 @@
import { expect, type Locator } from "@playwright/test";
import { expect, type Locator, type Page } from "@playwright/test";
export interface Rails {
left: number;
right: number;
}
interface Box {
x: number;
@@ -13,6 +18,30 @@ async function readBox(locator: Locator): Promise<Box> {
return box as Box;
}
/** Leading and trailing edges of an element, in whole pixels. */
export async function readRails(locator: Locator): Promise<Rails> {
const box = await readBox(locator);
return { left: Math.round(box.x), right: Math.round(box.x + box.width) };
}
/**
* The two rails an AdaptiveModalSheet header indents to: the title's leading
* glyph and the close button's trailing edge. Sheet content sits on the same
* two rails, in every presentation.
*/
export async function readSheetHeaderRails(
page: Page,
sheetTestID: string,
title: string,
): Promise<Rails> {
const header = page.getByTestId(sheetTestID);
const [titleRails, closeRails] = await Promise.all([
readRails(header.getByText(title, { exact: true })),
readRails(header.getByRole("button", { name: "Close" })),
]);
return { left: titleRails.left, right: closeRails.right };
}
/** Vertical space between the bottom of one element and the top of the next. */
export async function readVerticalGap(upper: Locator, lower: Locator): Promise<number> {
const [above, below] = await Promise.all([readBox(upper), readBox(lower)]);
+1 -75
View File
@@ -1,5 +1,5 @@
import { execSync } from "node:child_process";
import { mkdtemp, readFile, writeFile, rm, mkdir, realpath } from "node:fs/promises";
import { mkdtemp, writeFile, rm, mkdir, realpath } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
@@ -159,80 +159,6 @@ export async function createTempDirectory(prefix = "paseo-e2e-dir-"): Promise<Te
};
}
/**
* A commit that exists only in the local checkout. The whole point of defaulting a new
* worktree to the upstream is that these commits do not leak into it.
*/
export function commitLocalOnly(repoPath: string, marker: string): string {
execSync(`git commit --allow-empty -m ${JSON.stringify(`local only ${marker}`)}`, {
cwd: repoPath,
stdio: "ignore",
});
return execSync("git rev-parse HEAD", { cwd: repoPath, stdio: "pipe" }).toString().trim();
}
/**
* A fork checkout: `origin` is the fork, `upstream` is the source repo, and the current
* branch tracks `upstream/main`. Upstream carries a commit origin does not, so a worktree
* based on the wrong remote is provable rather than coincidentally identical.
*/
export async function trackForkUpstream(repoPath: string): Promise<string> {
const upstreamDir = path.join(repoPath, "upstream.git");
const upstreamClone = await mkdtemp(path.join(await resolveTempRoot(), "paseo-e2e-upstream-"));
await mkdir(upstreamDir, { recursive: true });
execSync(`git init --bare -b main ${upstreamDir}`, { cwd: repoPath, stdio: "ignore" });
execSync(`git remote add upstream ${upstreamDir}`, { cwd: repoPath, stdio: "ignore" });
execSync("git push upstream main", { cwd: repoPath, stdio: "ignore" });
execSync(`git clone ${upstreamDir} ${upstreamClone}`, { stdio: "ignore" });
execSync('git config user.email "e2e@paseo.test"', { cwd: upstreamClone, stdio: "ignore" });
execSync('git config user.name "Paseo E2E"', { cwd: upstreamClone, stdio: "ignore" });
execSync("git config commit.gpgsign false", { cwd: upstreamClone, stdio: "ignore" });
execSync('git commit --allow-empty -m "upstream only"', {
cwd: upstreamClone,
stdio: "ignore",
});
execSync("git push origin main", { cwd: upstreamClone, stdio: "ignore" });
await rm(upstreamClone, { recursive: true, force: true });
execSync("git fetch upstream", { cwd: repoPath, stdio: "ignore" });
execSync("git branch --set-upstream-to=upstream/main main", {
cwd: repoPath,
stdio: "ignore",
});
return execSync("git rev-parse refs/remotes/upstream/main", { cwd: repoPath, stdio: "pipe" })
.toString()
.trim();
}
export function readRepoRef(repoPath: string, ref: string): string {
return execSync(`git rev-parse ${JSON.stringify(ref)}`, { cwd: repoPath, stdio: "pipe" })
.toString()
.trim();
}
/**
* The base identity the daemon recorded in worktree.json: the display name the UI reads back,
* and the exact ref every comparison and action resolves through.
*/
export async function readWorktreeBaseMetadata(
worktreePath: string,
): Promise<{ baseRefName: string; baseRef?: string }> {
const gitDir = execSync("git rev-parse --absolute-git-dir", { cwd: worktreePath, stdio: "pipe" })
.toString()
.trim();
const metadata = JSON.parse(
await readFile(path.join(gitDir, "paseo", "worktree.json"), "utf8"),
) as { baseRefName?: string; baseRef?: string };
if (!metadata.baseRefName) {
throw new Error(`worktree.json has no baseRefName: ${worktreePath}`);
}
return {
baseRefName: metadata.baseRefName,
...(metadata.baseRef ? { baseRef: metadata.baseRef } : {}),
};
}
export async function readWorktreeBranchInfo({ worktreePath }: { worktreePath: string }): Promise<{
currentBranch: string;
hasAncestor: (ref: string) => boolean;
@@ -5,7 +5,6 @@ import React from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { RetainedPanelActivity } from "@/components/retained-panel";
import type { StreamItem } from "@/types/stream";
import type { StreamRenderInput, StreamSegmentRenderers, StreamViewportHandle } from "./strategy";
import { createWebStreamStrategy } from "./strategy-web";
@@ -26,10 +25,6 @@ vi.hoisted(() => {
});
});
vi.mock("react-native-unistyles", () => ({
withUnistyles: (Component: React.ComponentType) => Component,
}));
function userMessage(index: number): StreamItem {
return {
kind: "user_message",
@@ -538,7 +533,6 @@ describe("createWebStreamStrategy", () => {
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
const viewportRef = React.createRef<StreamViewportHandle>();
const historyMounted = Array.from({ length: 20 }, (_, index) => userMessage(index));
const onNearHistoryStart = vi.fn().mockReturnValue(false);
const routeBottomAnchorRequest = {
agentId: "agent",
reason: "initial-entry" as const,
@@ -561,10 +555,10 @@ describe("createWebStreamStrategy", () => {
viewportRef,
routeBottomAnchorRequest,
onNearBottomChange: vi.fn(),
onNearHistoryStart,
onNearHistoryStart: vi.fn().mockReturnValue(true),
isLoadingOlderHistory: false,
hasOlderHistory: true,
olderHistoryProgressKey: "epoch-1:20",
hasOlderHistory: false,
olderHistoryProgressKey: null,
scrollEnabled: true,
listStyle: null,
baseListContentContainerStyle: null,
@@ -601,7 +595,7 @@ describe("createWebStreamStrategy", () => {
act(() => {
scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -240 }));
});
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 0 });
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 520 });
act(() => {
scrollContainer.dispatchEvent(new Event("scroll"));
});
@@ -617,14 +611,6 @@ describe("createWebStreamStrategy", () => {
});
expect(scrollTo).not.toHaveBeenCalled();
await act(async () => Promise.resolve());
expect(onNearHistoryStart).toHaveBeenCalledOnce();
act(() => {
scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -240 }));
});
await act(async () => Promise.resolve());
expect(onNearHistoryStart).toHaveBeenCalledTimes(2);
});
it("does not force bottom after upward wheel when cached scroll top is stale", async () => {
@@ -835,413 +821,4 @@ describe("createWebStreamStrategy", () => {
expect(scrollTo).toHaveBeenCalled();
});
it("keeps following output after geometry, nested-scroll, and zoom wheel events", () => {
const scrollTo = vi.fn(function (this: HTMLElement, options?: ScrollToOptions | number) {
const top = typeof options === "object" ? (options.top ?? 0) : 0;
Object.defineProperty(this, "scrollTop", { configurable: true, value: top });
});
HTMLElement.prototype.scrollTo = scrollTo;
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
const viewportRef = React.createRef<StreamViewportHandle>();
const renderInput: StreamRenderInput = {
agentId: "agent",
segments: {
historyVirtualized: [],
historyMounted: [userMessage(1), userMessage(2)],
liveHead: [],
},
boundary: {
hasVirtualizedHistory: false,
hasMountedHistory: true,
hasLiveHead: false,
},
renderers: createRenderers(vi.fn()),
listEmptyComponent: null,
viewportRef,
routeBottomAnchorRequest: null,
isAuthoritativeHistoryReady: true,
onNearBottomChange: vi.fn(),
onNearHistoryStart: vi.fn().mockReturnValue(true),
isLoadingOlderHistory: false,
hasOlderHistory: false,
olderHistoryProgressKey: null,
scrollEnabled: true,
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
};
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
act(() => root?.render(strategy.render(renderInput)));
const scrollContainer = container.querySelector('[data-testid="agent-chat-scroll"]');
if (!(scrollContainer instanceof HTMLElement)) {
throw new Error("Expected agent chat scroll container");
}
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 500 });
Object.defineProperty(scrollContainer, "clientWidth", { configurable: true, value: 500 });
Object.defineProperty(scrollContainer, "offsetWidth", { configurable: true, value: 500 });
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1500 });
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 1000 });
scrollContainer.getBoundingClientRect = () =>
({
bottom: 500,
height: 500,
left: 0,
right: 500,
top: 0,
width: 500,
x: 0,
y: 0,
toJSON: () => {},
}) as DOMRect;
act(() => scrollContainer.dispatchEvent(new Event("scroll")));
const jitterPointerDown = new MouseEvent("pointerdown", {
bubbles: true,
button: 0,
buttons: 1,
clientX: 100,
clientY: 400,
});
const jitterPointerMove = new MouseEvent("pointermove", {
bubbles: true,
buttons: 1,
clientX: 100,
clientY: 397,
});
for (const event of [jitterPointerDown, jitterPointerMove]) {
Object.defineProperties(event, {
isPrimary: { value: true },
pointerId: { value: 1 },
pointerType: { value: "mouse" },
});
}
act(() => {
scrollContainer.dispatchEvent(jitterPointerDown);
window.dispatchEvent(jitterPointerMove);
});
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 700 });
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 800 });
act(() => scrollContainer.dispatchEvent(new Event("scroll")));
scrollTo.mockClear();
const nestedScroller = document.createElement("div");
nestedScroller.style.overflowY = "auto";
Object.defineProperty(nestedScroller, "clientHeight", { configurable: true, value: 100 });
Object.defineProperty(nestedScroller, "scrollHeight", { configurable: true, value: 300 });
Object.defineProperty(nestedScroller, "scrollTop", { configurable: true, value: 100 });
scrollContainer.append(nestedScroller);
act(() => {
nestedScroller.dispatchEvent(new WheelEvent("wheel", { bubbles: true, deltaY: -100 }));
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 700 });
scrollContainer.dispatchEvent(new Event("scroll"));
scrollContainer.dispatchEvent(
new WheelEvent("wheel", { bubbles: true, ctrlKey: true, deltaY: -100 }),
);
});
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1800 });
act(() => {
root?.render(
strategy.render({
...renderInput,
segments: { ...renderInput.segments, liveHead: [userMessage(3)] },
boundary: { ...renderInput.boundary, hasLiveHead: true },
}),
);
});
expect(scrollTo).toHaveBeenCalledWith({ top: 1800, behavior: "auto" });
});
it("stops following output after scrollbar and middle-button upward scrolling", () => {
const scrollTo = vi.fn(function (this: HTMLElement, options?: ScrollToOptions | number) {
const top = typeof options === "object" ? (options.top ?? 0) : 0;
Object.defineProperty(this, "scrollTop", { configurable: true, value: top });
});
HTMLElement.prototype.scrollTo = scrollTo;
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
const renderInput: StreamRenderInput = {
agentId: "agent",
segments: {
historyVirtualized: [],
historyMounted: [userMessage(1), userMessage(2)],
liveHead: [],
},
boundary: {
hasVirtualizedHistory: false,
hasMountedHistory: true,
hasLiveHead: false,
},
renderers: createRenderers(vi.fn()),
listEmptyComponent: null,
viewportRef: React.createRef<StreamViewportHandle>(),
routeBottomAnchorRequest: null,
isAuthoritativeHistoryReady: true,
onNearBottomChange: vi.fn(),
onNearHistoryStart: vi.fn().mockReturnValue(true),
isLoadingOlderHistory: false,
hasOlderHistory: false,
olderHistoryProgressKey: null,
scrollEnabled: true,
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
};
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
act(() => root?.render(strategy.render(renderInput)));
const scrollContainer = container.querySelector('[data-testid="agent-chat-scroll"]');
if (!(scrollContainer instanceof HTMLElement)) {
throw new Error("Expected agent chat scroll container");
}
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 500 });
Object.defineProperty(scrollContainer, "clientWidth", { configurable: true, value: 500 });
Object.defineProperty(scrollContainer, "offsetWidth", { configurable: true, value: 520 });
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1500 });
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 1000 });
scrollContainer.getBoundingClientRect = () =>
({
bottom: 500,
height: 500,
left: 0,
right: 520,
top: 0,
width: 520,
x: 0,
y: 0,
toJSON: () => {},
}) as DOMRect;
act(() => scrollContainer.dispatchEvent(new Event("scroll")));
const scrollbarPointerDown = new MouseEvent("pointerdown", {
bubbles: true,
button: 0,
buttons: 1,
clientX: 515,
clientY: 400,
});
Object.defineProperties(scrollbarPointerDown, {
isPrimary: { value: true },
pointerId: { value: 1 },
pointerType: { value: "mouse" },
});
act(() => scrollContainer.dispatchEvent(scrollbarPointerDown));
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 700 });
act(() => scrollContainer.dispatchEvent(new Event("scroll")));
scrollTo.mockClear();
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1800 });
act(() => {
root?.render(
strategy.render({
...renderInput,
segments: { ...renderInput.segments, liveHead: [userMessage(3)] },
boundary: { ...renderInput.boundary, hasLiveHead: true },
}),
);
});
expect(scrollTo).not.toHaveBeenCalled();
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 1300 });
act(() => scrollContainer.dispatchEvent(new Event("scroll")));
Object.defineProperty(scrollContainer, "offsetWidth", { configurable: true, value: 500 });
scrollContainer.getBoundingClientRect = () =>
({
bottom: 500,
height: 500,
left: 0,
right: 500,
top: 0,
width: 500,
x: 0,
y: 0,
toJSON: () => {},
}) as DOMRect;
const overlayScrollbarPointerDown = new MouseEvent("pointerdown", {
bubbles: true,
button: 0,
buttons: 1,
clientX: 495,
clientY: 400,
});
Object.defineProperties(overlayScrollbarPointerDown, {
isPrimary: { value: true },
pointerId: { value: 2 },
pointerType: { value: "mouse" },
});
act(() => scrollContainer.dispatchEvent(overlayScrollbarPointerDown));
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 900 });
act(() => scrollContainer.dispatchEvent(new Event("scroll")));
scrollTo.mockClear();
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1900 });
act(() => {
root?.render(
strategy.render({
...renderInput,
segments: {
...renderInput.segments,
liveHead: [userMessage(3), userMessage(4)],
},
boundary: { ...renderInput.boundary, hasLiveHead: true },
}),
);
});
expect(scrollTo).not.toHaveBeenCalled();
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 1400 });
act(() => scrollContainer.dispatchEvent(new Event("scroll")));
const autoscrollPointerDown = new MouseEvent("pointerdown", {
bubbles: true,
button: 1,
buttons: 4,
clientX: 100,
clientY: 400,
});
const autoscrollPointerUp = new MouseEvent("pointerup", {
bubbles: true,
button: 1,
buttons: 0,
clientX: 100,
clientY: 400,
});
const autoscrollPointerMove = new MouseEvent("pointermove", {
bubbles: true,
buttons: 0,
clientX: 100,
clientY: 300,
});
for (const event of [autoscrollPointerDown, autoscrollPointerUp, autoscrollPointerMove]) {
Object.defineProperties(event, {
isPrimary: { value: true },
pointerId: { value: 3 },
pointerType: { value: "mouse" },
});
}
act(() => {
scrollContainer.dispatchEvent(autoscrollPointerDown);
window.dispatchEvent(autoscrollPointerUp);
window.dispatchEvent(autoscrollPointerMove);
});
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 1000 });
act(() => scrollContainer.dispatchEvent(new Event("scroll")));
scrollTo.mockClear();
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 2000 });
act(() => {
root?.render(
strategy.render({
...renderInput,
segments: {
...renderInput.segments,
liveHead: [userMessage(3), userMessage(4), userMessage(5)],
},
boundary: { ...renderInput.boundary, hasLiveHead: true },
}),
);
});
expect(scrollTo).not.toHaveBeenCalled();
});
it("keeps the retained viewport mounted while inactive and reconciles it once on return", () => {
const observed = new Map<Element, ReturnType<typeof vi.fn>>();
Object.defineProperty(globalThis, "ResizeObserver", {
configurable: true,
value: class ResizeObserver {
readonly disconnect = vi.fn();
observe(target: Element) {
observed.set(target, this.disconnect);
}
},
});
const scrollTo = vi.fn(function (this: HTMLElement, options?: ScrollToOptions | number) {
const top = typeof options === "object" ? (options.top ?? 0) : 0;
Object.defineProperty(this, "scrollTop", { configurable: true, value: top });
});
HTMLElement.prototype.scrollTo = scrollTo;
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
const viewportRef = React.createRef<StreamViewportHandle>();
const renderInput: StreamRenderInput = {
agentId: "agent",
segments: {
historyVirtualized: [],
historyMounted: [userMessage(1), userMessage(2)],
liveHead: [],
},
boundary: {
hasVirtualizedHistory: false,
hasMountedHistory: true,
hasLiveHead: false,
},
renderers: createRenderers(vi.fn()),
listEmptyComponent: null,
viewportRef,
routeBottomAnchorRequest: null,
isAuthoritativeHistoryReady: true,
onNearBottomChange: vi.fn(),
onNearHistoryStart: vi.fn().mockReturnValue(true),
isLoadingOlderHistory: false,
hasOlderHistory: false,
olderHistoryProgressKey: null,
scrollEnabled: true,
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
};
const renderWithActivity = (active: boolean, input: StreamRenderInput = renderInput) => (
<RetainedPanelActivity active={active}>{strategy.render(input)}</RetainedPanelActivity>
);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
act(() => root?.render(renderWithActivity(true)));
const scrollContainer = container.querySelector('[data-testid="agent-chat-scroll"]');
if (!(scrollContainer instanceof HTMLElement)) {
throw new Error("Expected agent chat scroll container");
}
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 500 });
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1500 });
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 1000 });
act(() => scrollContainer.dispatchEvent(new Event("scroll")));
const viewportObserverDisconnect = observed.get(scrollContainer);
expect(viewportObserverDisconnect).toBeDefined();
act(() => root?.render(renderWithActivity(false)));
expect(container.querySelector('[data-testid="agent-chat-scroll"]')).toBe(scrollContainer);
expect(viewportObserverDisconnect).toHaveBeenCalledOnce();
scrollTo.mockClear();
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 2200 });
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 0 });
act(() => {
scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -100 }));
scrollContainer.dispatchEvent(new Event("scroll"));
root?.render(
renderWithActivity(false, {
...renderInput,
segments: { ...renderInput.segments, liveHead: [userMessage(3)] },
boundary: { ...renderInput.boundary, hasLiveHead: true },
}),
);
});
expect(scrollTo).not.toHaveBeenCalled();
act(() => {
root?.render(
renderWithActivity(true, {
...renderInput,
segments: { ...renderInput.segments, liveHead: [userMessage(3)] },
boundary: { ...renderInput.boundary, hasLiveHead: true },
}),
);
});
expect(container.querySelector('[data-testid="agent-chat-scroll"]')).toBe(scrollContainer);
expect(scrollTo).toHaveBeenCalledWith({ top: 2200, behavior: "auto" });
});
});
+36 -278
View File
@@ -9,11 +9,9 @@ import React, {
} from "react";
import { measureElement as measureVirtualElement, useVirtualizer } from "@tanstack/react-virtual";
import { withUnistyles } from "react-native-unistyles";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useStableEvent } from "@/hooks/use-stable-event";
import type { Theme } from "@/styles/theme";
import { WEB_SCROLLBAR_SIZE_PX } from "@/styles/web-scrollbar";
import { estimateStreamItemHeight } from "./web-virtualization";
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy";
import { createStreamStrategy } from "./strategy";
@@ -53,7 +51,6 @@ const AUTO_SCROLL_RESUME_THRESHOLD_PX = 1;
const HISTORY_START_SETTLE_FRAMES = 2;
const HISTORY_START_SLOT_HEIGHT_PX = 32;
const CONTENT_PADDING_TOP_PX = 16;
const UPWARD_INPUT_EVIDENCE_TIMEOUT_MS = 100;
const VIRTUALIZER_SCROLL_MARGIN_PX = HISTORY_START_SLOT_HEIGHT_PX + CONTENT_PADDING_TOP_PX;
// A row has to clear this much of the viewport top before the next one takes over as the
// reading position, so a row resting exactly on the edge does not flip back and forth.
@@ -108,77 +105,6 @@ function isScrollContainerAtBottom(
return isScrollContainerNearBottom(scrollContainer, AUTO_SCROLL_RESUME_THRESHOLD_PX);
}
function isScrollContainerMeasurable(
scrollContainer: Pick<HTMLElement, "clientHeight" | "scrollHeight">,
): boolean {
return scrollContainer.clientHeight > 0 && scrollContainer.scrollHeight > 0;
}
function isEditableEventTarget(target: EventTarget | null): boolean {
if (!(target instanceof Element)) {
return false;
}
const editableRoot = target.closest("input, textarea, [contenteditable]");
if (!editableRoot) {
return false;
}
const tagName = editableRoot.tagName.toLowerCase();
if (tagName === "input" || tagName === "textarea") {
return true;
}
return editableRoot.getAttribute("contenteditable")?.toLowerCase() !== "false";
}
function isUpwardViewportScrollKey(event: KeyboardEvent): boolean {
if (isEditableEventTarget(event.target)) {
return false;
}
return (
event.key === "ArrowUp" ||
event.key === "PageUp" ||
event.key === "Home" ||
(event.key === " " && event.shiftKey)
);
}
function canNestedScrollerConsumeUpwardInput(
target: EventTarget | null,
scrollContainer: HTMLElement,
): boolean {
let element = target instanceof Element ? target : null;
while (element && element !== scrollContainer) {
if (element instanceof HTMLElement) {
const overflowY = window.getComputedStyle(element).overflowY;
const canScroll = overflowY === "auto" || overflowY === "scroll";
if (canScroll && element.scrollHeight > element.clientHeight && element.scrollTop > 0) {
return true;
}
}
element = element.parentElement;
}
return false;
}
function isVerticalScrollbarGutterPress(
event: PointerEvent,
scrollContainer: HTMLElement,
): boolean {
if (event.target !== scrollContainer) {
return false;
}
if (scrollContainer.scrollHeight <= scrollContainer.clientHeight) {
return false;
}
const scrollbarWidth = Math.max(
scrollContainer.offsetWidth - scrollContainer.clientWidth,
WEB_SCROLLBAR_SIZE_PX,
);
const bounds = scrollContainer.getBoundingClientRect();
return window.getComputedStyle(scrollContainer).direction === "rtl"
? event.clientX <= bounds.left + scrollbarWidth
: event.clientX >= bounds.right - scrollbarWidth;
}
function scrollElementToBottom(
scrollContainer: HTMLElement,
behavior: ScrollBehaviorLike = "auto",
@@ -233,8 +159,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
scrollEnabled,
isMobileBreakpoint,
} = props;
const isActive = useRetainedPanelActive();
const isActiveRef = useRef(isActive);
const scrollContainerRef = useRef<HTMLElement | null>(null);
const contentRef = useRef<HTMLElement | null>(null);
const handleScrollContainerRef = useCallback((node: HTMLElement | null) => {
@@ -251,18 +175,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
return value;
};
const lastKnownScrollTopRef = useRef(0);
const mouseScrollGestureRef = useRef<
| { kind: "scrollbar"; pointerId: number }
| {
kind: "autoscroll";
pointerId: number;
lastClientY: number;
hasUpwardEvidence: boolean;
evidenceExpiryFrame: number | null;
}
| null
>(null);
const upwardInputEvidenceUntilRef = useRef(0);
const pendingUserScrollUpIntentRef = useRef(false);
const lastTouchClientYRef = useRef<number | null>(null);
const pendingAutoScrollFrameRef = useRef<number | null>(null);
const pendingAutoScrollTimeoutRef = useRef<number | null>(null);
@@ -283,7 +196,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
renderLiveAuxiliary,
} = renderers;
isActiveRef.current = isActive;
followOutputRef.current = followOutput;
const hasRouteBottomAnchorRequest = routeBottomAnchorRequest !== null;
@@ -322,7 +234,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const virtualTotalSize = rowVirtualizer.getTotalSize();
const getHistoryStartPaginationInput = useStableEvent((): HistoryStartPaginationInput | null => {
const scrollContainer = scrollContainerRef.current;
if (!isActiveRef.current || !scrollContainer || !isScrollContainerMeasurable(scrollContainer)) {
if (!scrollContainer) {
return null;
}
const bottomAnchorSettled =
@@ -459,9 +371,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
});
useLayoutEffect(() => {
if (!isActiveRef.current) {
return;
}
const anchor = historyStartPrependAnchorRef.current;
if (!anchor || anchor.progressKey === olderHistoryProgressKey) {
return;
@@ -493,7 +402,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}
const frame = window.requestAnimationFrame(() => {
pendingFrames.delete(node);
if (isActiveRef.current && node.isConnected) {
if (node.isConnected) {
rowVirtualizer.measureElement(node);
}
});
@@ -525,47 +434,10 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}
}, []);
const clearMouseScrollGesture = useCallback(() => {
const gesture = mouseScrollGestureRef.current;
const evidenceExpiryFrame = gesture?.kind === "autoscroll" ? gesture.evidenceExpiryFrame : null;
if (evidenceExpiryFrame !== null && evidenceExpiryFrame !== undefined) {
window.cancelAnimationFrame(evidenceExpiryFrame);
}
mouseScrollGestureRef.current = null;
}, []);
const clearUpwardInputEvidence = useCallback(() => {
upwardInputEvidenceUntilRef.current = 0;
}, []);
const markUpwardInputEvidence = useCallback(() => {
upwardInputEvidenceUntilRef.current =
window.performance.now() + UPWARD_INPUT_EVIDENCE_TIMEOUT_MS;
}, []);
useLayoutEffect(() => {
if (isActive) {
return;
}
cancelPendingStickToBottom();
historyStartSettleSchedulerRef.current?.cancel();
for (const frame of pendingVirtualRowMeasureFramesRef.current.values()) {
window.cancelAnimationFrame(frame);
}
pendingVirtualRowMeasureFramesRef.current.clear();
clearMouseScrollGesture();
clearUpwardInputEvidence();
lastTouchClientYRef.current = null;
}, [cancelPendingStickToBottom, clearMouseScrollGesture, clearUpwardInputEvidence, isActive]);
const scrollMessagesToBottom = useCallback(
(behavior: ScrollBehaviorLike = "auto") => {
const scrollContainer = scrollContainerRef.current;
if (
!isActiveRef.current ||
!scrollContainer ||
!isScrollContainerMeasurable(scrollContainer)
) {
if (!scrollContainer) {
return;
}
if (isScrollContainerOverscrolledPastBottom(scrollContainer)) {
@@ -580,9 +452,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
);
const scheduleStickToBottom = useCallback(() => {
if (!isActiveRef.current) {
return;
}
const scrollContainer = scrollContainerRef.current;
if (scrollContainer && isScrollContainerOverscrolledPastBottom(scrollContainer)) {
return;
@@ -592,7 +461,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}
pendingAutoScrollFrameRef.current = window.requestAnimationFrame(() => {
pendingAutoScrollFrameRef.current = null;
if (!isActiveRef.current || !followOutputRef.current) {
if (!followOutputRef.current) {
return;
}
scrollMessagesToBottom("auto");
@@ -608,9 +477,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
// Rows are laid out in DOM order, virtualized block first, so the first row whose bottom
// clears the reading line is the one the reader is looking at.
const reportReadingPosition = useStableEvent(() => {
if (!isActiveRef.current) {
return;
}
if (!onReadingPositionChange) {
return;
}
@@ -636,9 +502,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
});
const updateScrollMetrics = useCallback(() => {
if (!isActiveRef.current) {
return;
}
const scrollContainer = scrollContainerRef.current;
if (!scrollContainer) {
onNearBottomChange(true);
@@ -649,7 +512,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}, [onNearBottomChange, reportReadingPosition]);
const { isJumpSettling, scrollToMessage } = useScrollToMessage({
active: isActive,
scrollContainerRef,
rowVirtualizer,
historyVirtualized: segments.historyVirtualized,
@@ -658,17 +520,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
onNearBottomChange,
});
const stopFollowingOutputFromUserIntent = useStableEvent(() => {
cancelPendingStickToBottom();
if (followOutputRef.current) {
setFollowOutput(false);
}
rearmHistoryStartFromUserIntent();
});
const handleDomScroll = useCallback(() => {
const scrollContainer = scrollContainerRef.current;
if (!isActiveRef.current || !scrollContainer || !isScrollContainerMeasurable(scrollContainer)) {
if (!scrollContainer) {
return;
}
@@ -677,25 +531,33 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - USER_SCROLL_DELTA_EPSILON;
const scrolledDown =
currentScrollTop > lastKnownScrollTopRef.current + USER_SCROLL_DELTA_EPSILON;
const mouseGesture = mouseScrollGestureRef.current;
const hasUpwardInputEvidence =
mouseGesture?.kind === "scrollbar" ||
(mouseGesture?.kind === "autoscroll" && mouseGesture.hasUpwardEvidence) ||
window.performance.now() < upwardInputEvidenceUntilRef.current;
if (!followOutputRef.current && isAtBottom && scrolledDown && !isJumpSettling()) {
if (scrolledUp) {
cancelPendingStickToBottom();
if (followOutputRef.current) {
setFollowOutput(false);
}
pendingUserScrollUpIntentRef.current = false;
rearmHistoryStartFromUserIntent();
} else if (!followOutputRef.current && isAtBottom && scrolledDown && !isJumpSettling()) {
setFollowOutput(true);
} else if (followOutputRef.current && scrolledUp && hasUpwardInputEvidence) {
stopFollowingOutputFromUserIntent();
pendingUserScrollUpIntentRef.current = false;
} else if (followOutputRef.current && pendingUserScrollUpIntentRef.current) {
if (!isAtBottom) {
cancelPendingStickToBottom();
setFollowOutput(false);
}
pendingUserScrollUpIntentRef.current = false;
}
lastKnownScrollTopRef.current = currentScrollTop;
updateScrollMetrics();
evaluateHistoryStart();
}, [
cancelPendingStickToBottom,
evaluateHistoryStart,
isJumpSettling,
stopFollowingOutputFromUserIntent,
rearmHistoryStartFromUserIntent,
updateScrollMetrics,
]);
@@ -718,7 +580,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}, [evaluateHistoryStart, props.agentId]);
useLayoutEffect(() => {
if (!isActiveRef.current || !isActivationReady) {
if (!isActivationReady) {
return;
}
if (hasRouteBottomAnchorRequest && !followOutputRef.current) {
@@ -753,14 +615,13 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
// Following output is a layout invariant: rows, footer, and bottom offset must
// reach the browser in the same paint.
useLayoutEffect(() => {
if (!isActive || !followOutputRef.current) {
if (!followOutputRef.current) {
return;
}
cancelPendingStickToBottom();
scrollMessagesToBottom("auto");
}, [
cancelPendingStickToBottom,
isActive,
renderLiveAuxiliary,
scrollMessagesToBottom,
segments.historyMounted,
@@ -770,9 +631,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
]);
useEffect(() => {
if (!isActive) {
return;
}
updateScrollMetrics();
evaluateHistoryStart();
if (historyStartPaginationStateRef.current.status === "settling") {
@@ -781,7 +639,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}, [
evaluateHistoryStart,
hasOlderHistory,
isActive,
isLoadingOlderHistory,
olderHistoryProgressKey,
scheduleHistoryStartPrependSettle,
@@ -792,10 +649,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
virtualTotalSize,
]);
useLayoutEffect(() => {
if (!isActive) {
return;
}
useEffect(() => {
const scrollContainer = scrollContainerRef.current;
const contentNode = contentRef.current;
if (!scrollContainer || typeof ResizeObserver === "undefined") {
@@ -828,96 +682,22 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}, [
applyHistoryStartPrependAnchor,
evaluateHistoryStart,
isActive,
scheduleHistoryStartPrependSettle,
scheduleStickToBottom,
updateScrollMetrics,
]);
useLayoutEffect(() => {
if (!isActive) {
return;
}
useEffect(() => {
const scrollContainer = scrollContainerRef.current;
if (!scrollContainer) {
return;
}
const markUpwardViewportInput = () => {
markUpwardInputEvidence();
if (scrollContainer.scrollTop <= USER_SCROLL_DELTA_EPSILON) {
rearmHistoryStartFromUserIntent();
}
};
const handleWheel = (event: WheelEvent) => {
if (
!event.ctrlKey &&
event.deltaY < 0 &&
!canNestedScrollerConsumeUpwardInput(event.target, scrollContainer)
) {
markUpwardViewportInput();
}
};
const handleKeyDown = (event: KeyboardEvent) => {
if (!isUpwardViewportScrollKey(event)) {
return;
}
if (canNestedScrollerConsumeUpwardInput(event.target, scrollContainer)) {
return;
}
markUpwardViewportInput();
};
const handlePointerDown = (event: PointerEvent) => {
clearMouseScrollGesture();
if (
event.pointerType !== "mouse" ||
!event.isPrimary ||
(event.button !== 0 && event.button !== 1) ||
isEditableEventTarget(event.target)
) {
return;
}
if (event.button === 0) {
if (isVerticalScrollbarGutterPress(event, scrollContainer)) {
mouseScrollGestureRef.current = { kind: "scrollbar", pointerId: event.pointerId };
}
return;
}
mouseScrollGestureRef.current = {
kind: "autoscroll",
pointerId: event.pointerId,
lastClientY: event.clientY,
hasUpwardEvidence: false,
evidenceExpiryFrame: null,
};
};
const handlePointerMove = (event: PointerEvent) => {
const gesture = mouseScrollGestureRef.current;
if (!gesture || gesture.kind !== "autoscroll" || gesture.pointerId !== event.pointerId) {
return;
}
if (event.clientY < gesture.lastClientY - USER_SCROLL_DELTA_EPSILON) {
gesture.hasUpwardEvidence = true;
if (gesture.evidenceExpiryFrame !== null) {
window.cancelAnimationFrame(gesture.evidenceExpiryFrame);
}
gesture.evidenceExpiryFrame = window.requestAnimationFrame(() => {
if (mouseScrollGestureRef.current === gesture) {
mouseScrollGestureRef.current = null;
}
});
}
gesture.lastClientY = event.clientY;
};
const handlePointerEnd = (event: PointerEvent) => {
const gesture = mouseScrollGestureRef.current;
if (gesture?.pointerId === event.pointerId && gesture.kind === "scrollbar") {
clearMouseScrollGesture();
}
};
const handlePointerCancel = (event: PointerEvent) => {
if (mouseScrollGestureRef.current?.pointerId === event.pointerId) {
clearMouseScrollGesture();
if (event.deltaY < 0) {
pendingUserScrollUpIntentRef.current = true;
cancelPendingStickToBottom();
rearmHistoryStartFromUserIntent();
}
};
const handleTouchStart = (event: TouchEvent) => {
@@ -933,12 +713,10 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
return;
}
const previousTouchY = lastTouchClientYRef.current;
if (
previousTouchY !== null &&
touch.clientY > previousTouchY + 1 &&
!canNestedScrollerConsumeUpwardInput(event.target, scrollContainer)
) {
markUpwardViewportInput();
if (previousTouchY !== null && touch.clientY > previousTouchY + 1) {
pendingUserScrollUpIntentRef.current = true;
cancelPendingStickToBottom();
rearmHistoryStartFromUserIntent();
}
lastTouchClientYRef.current = touch.clientY;
};
@@ -948,12 +726,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
scrollContainer.addEventListener("scroll", handleDomScroll, { passive: true });
scrollContainer.addEventListener("wheel", handleWheel, { passive: true });
scrollContainer.addEventListener("keydown", handleKeyDown, { passive: true });
scrollContainer.addEventListener("pointerdown", handlePointerDown, { passive: true });
window.addEventListener("pointermove", handlePointerMove, { passive: true });
window.addEventListener("pointerup", handlePointerEnd, { passive: true });
window.addEventListener("pointercancel", handlePointerCancel, { passive: true });
window.addEventListener("blur", clearMouseScrollGesture);
scrollContainer.addEventListener("touchstart", handleTouchStart, { passive: true });
scrollContainer.addEventListener("touchmove", handleTouchMove, { passive: true });
scrollContainer.addEventListener("touchend", handleTouchEnd, { passive: true });
@@ -962,26 +734,12 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
return () => {
scrollContainer.removeEventListener("scroll", handleDomScroll);
scrollContainer.removeEventListener("wheel", handleWheel);
scrollContainer.removeEventListener("keydown", handleKeyDown);
scrollContainer.removeEventListener("pointerdown", handlePointerDown);
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerEnd);
window.removeEventListener("pointercancel", handlePointerCancel);
window.removeEventListener("blur", clearMouseScrollGesture);
scrollContainer.removeEventListener("touchstart", handleTouchStart);
scrollContainer.removeEventListener("touchmove", handleTouchMove);
scrollContainer.removeEventListener("touchend", handleTouchEnd);
scrollContainer.removeEventListener("touchcancel", handleTouchEnd);
clearUpwardInputEvidence();
};
}, [
clearMouseScrollGesture,
clearUpwardInputEvidence,
handleDomScroll,
isActive,
markUpwardInputEvidence,
rearmHistoryStartFromUserIntent,
]);
}, [cancelPendingStickToBottom, handleDomScroll, rearmHistoryStartFromUserIntent]);
useEffect(() => {
const handle: StreamViewportHandle = {
@@ -1,10 +1,9 @@
import type React from "react";
import { useCallback, useEffect, useLayoutEffect, useMemo } from "react";
import { useCallback, useEffect, useMemo } from "react";
import type { Virtualizer } from "@tanstack/react-virtual";
import { createPromptJumpSettleController, PROMPT_JUMP_TOP_INSET_PX } from "./prompt-jump-settle";
interface UseScrollToMessageInput {
active: boolean;
scrollContainerRef: React.RefObject<HTMLElement | null>;
rowVirtualizer: Virtualizer<HTMLElement, Element>;
historyVirtualized: readonly { id: string }[];
@@ -24,7 +23,6 @@ const SCROLL_AFFECTING_KEYS = new Set([
]);
export function useScrollToMessage({
active,
scrollContainerRef,
rowVirtualizer,
historyVirtualized,
@@ -81,15 +79,9 @@ export function useScrollToMessage({
);
useEffect(() => () => settleController.cancel(), [settleController]);
useLayoutEffect(() => {
if (!active) {
settleController.cancel();
}
}, [active, settleController]);
const scrollToMessage = useCallback(
(itemId: string) => {
if (!active) return;
const container = scrollContainerRef.current;
if (!container) return;
cancelPendingStickToBottom();
@@ -117,7 +109,6 @@ export function useScrollToMessage({
}
},
[
active,
cancelPendingStickToBottom,
historyVirtualized,
onNearBottomChange,
+13 -16
View File
@@ -269,14 +269,6 @@ const AGENT_CAPABILITY_FLAG_KEYS: (keyof AgentCapabilityFlags)[] = [
];
const EMPTY_STREAM_HEAD: StreamItem[] = [];
function useRetainedValue<T>(value: T, active: boolean): T {
const retainedRef = useRef(value);
if (active) {
retainedRef.current = value;
}
return active ? value : retainedRef.current;
}
const EMPTY_PENDING_MESSAGE_SUBMISSIONS: readonly PendingMessageSubmission[] = [];
const GROUPED_TOOL_CALL_DETAIL_MAX_HEIGHT = 200;
@@ -473,15 +465,20 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
});
});
// Freeze stream presentation while this tab slot is hidden to prevent offscreen
// cell-window and turn-lifecycle renders from background agents.
// Freeze stream data while this tab slot is hidden to prevent offscreen FlatList
// cell-window renders on every 48ms flush from background agents.
// When isActive flips back to true, the context change triggers a re-render and
// the component reads the current (fresh) streamItems/streamHead from props.
const isActive = useRetainedPanelActive();
const effectiveStreamItems = useRetainedValue(streamItems, isActive);
const effectiveStreamHead = useRetainedValue(streamHead, isActive);
const effectiveTurnPresentation = useRetainedValue(turnPresentation, isActive);
const isTurnActive = effectiveTurnPresentation.isActive;
const frozenStreamItemsRef = useRef(streamItems);
const frozenStreamHeadRef = useRef(streamHead);
if (isActive) {
frozenStreamItemsRef.current = streamItems;
frozenStreamHeadRef.current = streamHead;
}
const effectiveStreamItems = isActive ? streamItems : frozenStreamItemsRef.current;
const effectiveStreamHead = isActive ? streamHead : frozenStreamHeadRef.current;
const isTurnActive = turnPresentation.isActive;
// Keep retained history outside the 48ms live-head flush path.
const preparedToolCallHistory = useMemo(
() => prepareToolCallHistory(toolCallDetailLevel, effectiveStreamItems),
@@ -508,7 +505,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
const baseRenderModel = useMemo(() => {
return buildAgentStreamRenderModel({
isTurnActive,
activeTurnStartedAt: effectiveTurnPresentation.startedAt,
activeTurnStartedAt: turnPresentation.startedAt,
tail: projectedToolCalls.tail,
head: projectedToolCalls.head,
platform: isWeb ? "web" : "native",
@@ -519,7 +516,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
isTurnActive,
projectedToolCalls.head,
projectedToolCalls.tail,
effectiveTurnPresentation.startedAt,
turnPresentation.startedAt,
]);
const streamLayout = useMemo(
() =>
+6 -6
View File
@@ -940,7 +940,9 @@ function RuntimeProviders({ children }: { children: ReactNode }) {
<HostRuntimeBootstrapProvider>
<PushNotificationRouter />
<SidebarCalloutProvider>
<ProvidersWrapper>{children}</ProvidersWrapper>
<ToastProvider>
<ProvidersWrapper>{children}</ProvidersWrapper>
</ToastProvider>
</SidebarCalloutProvider>
</HostRuntimeBootstrapProvider>
);
@@ -958,11 +960,9 @@ function RootProviders({ children }: { children: ReactNode }) {
<WindowChromeProvider>
<KeyboardProvider>
<KeyboardShiftProvider>
<ToastProvider>
<PortalProvider>
<BottomSheetModalProvider>{children}</BottomSheetModalProvider>
</PortalProvider>
</ToastProvider>
<PortalProvider>
<BottomSheetModalProvider>{children}</BottomSheetModalProvider>
</PortalProvider>
</KeyboardShiftProvider>
</KeyboardProvider>
</WindowChromeProvider>
+9 -1
View File
@@ -1,10 +1,18 @@
import { useMemo } from "react";
import { useLocalSearchParams } from "expo-router";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import { SchedulesScreen } from "@/screens/schedules-screen";
import { parseScheduleFocus } from "@/utils/host-routes";
export default function SchedulesRoute() {
const params = useLocalSearchParams<{ serverId?: string; scheduleId?: string }>();
const serverId = typeof params.serverId === "string" ? params.serverId : undefined;
const scheduleId = typeof params.scheduleId === "string" ? params.scheduleId : undefined;
const focus = useMemo(() => parseScheduleFocus({ serverId, scheduleId }), [serverId, scheduleId]);
return (
<HostRouteBootstrapBoundary>
<SchedulesScreen />
<SchedulesScreen focus={focus} />
</HostRouteBootstrapBoundary>
);
}
@@ -592,7 +592,6 @@ export function CommandCenter() {
return (
<IsolatedBottomSheetModal
ref={sheetRef}
contextBridge={null}
snapPoints={COMMAND_CENTER_SNAP_POINTS}
index={0}
enableDynamicSizing={false}
@@ -15,7 +15,6 @@ import { getIsElectronRuntime } from "@/constants/layout";
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
import { useOpenAddProject } from "@/hooks/use-open-add-project";
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
import { useKeyboardShortcutsAvailable } from "@/keyboard/availability";
import { resolveShortcutKeysForAction } from "@/keyboard/keyboard-shortcuts";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { clearCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
@@ -78,7 +77,6 @@ function HomeIcon({ size }: CommandCenterIconProps) {
export function CommandCenterRootActions() {
const { t } = useTranslation();
const { overrides } = useKeyboardShortcutOverrides();
const shortcutsAvailable = useKeyboardShortcutsAvailable();
const openAddProject = useOpenAddProject();
const settingsRoute = useMemo<Href>(() => buildSettingsRoute(), []);
const homeRoute = useMemo<Href>(() => buildOpenProjectRoute(), []);
@@ -89,8 +87,8 @@ export function CommandCenterRootActions() {
() => ({ isMac: getShortcutOs() === "mac", isDesktop: getIsElectronRuntime() }),
[],
);
const actions = useMemo<CommandCenterContribution[]>(() => {
const availableActions: CommandCenterContribution[] = [
const actions = useMemo<CommandCenterContribution[]>(
() => [
{
id: "add-project",
group: "actions",
@@ -206,10 +204,7 @@ export function CommandCenterRootActions() {
undefined,
},
},
];
if (shortcutsAvailable) {
availableActions.push({
{
id: "keyboard-shortcuts",
group: "actions",
groupRank: 0,
@@ -226,22 +221,20 @@ export function CommandCenterRootActions() {
resolveShortcutKeysForAction("show-shortcuts", overrides, shortcutPlatform) ??
undefined,
},
});
}
return availableActions;
}, [
homeRoute,
openAddProject,
overrides,
schedulesRoute,
sessionsRoute,
setShortcutsDialogOpen,
settingsRoute,
shortcutPlatform,
shortcutsAvailable,
t,
]);
},
],
[
homeRoute,
openAddProject,
overrides,
schedulesRoute,
sessionsRoute,
setShortcutsDialogOpen,
settingsRoute,
shortcutPlatform,
t,
],
);
useCommandCenterActions({ sourceId: "root", enabled: true, actions });
return null;
@@ -674,7 +674,6 @@ export function AdaptiveModalSheet({
return (
<IsolatedBottomSheetModal
ref={sheetRef}
contextBridge={null}
snapPoints={resolvedSnapPoints}
index={0}
enableDynamicSizing={false}
@@ -67,7 +67,6 @@ import {
type ProjectPickerOption,
} from "@/components/project-picker-options";
import { Shortcut } from "@/components/ui/shortcut";
import { useKeyboardShortcutsAvailable } from "@/keyboard/availability";
import { getIsElectronRuntime } from "@/constants/layout";
import { isWeb } from "@/constants/platform";
import { pickDirectory } from "@/desktop/pick-directory";
@@ -288,9 +287,6 @@ function FlowRow({ option, active }: { option: FlowRowOption; active: boolean })
}
function FlowHint({ keys, action }: { keys: string[]; action: string }) {
const shortcutsAvailable = useKeyboardShortcutsAvailable();
if (!shortcutsAvailable) return null;
return (
<View style={styles.footerHint}>
<Shortcut keys={keys} textStyle={styles.footerKeyText} />
+2 -2
View File
@@ -36,11 +36,11 @@ const styles = StyleSheet.create((theme) => ({
additions: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.statusMutedSuccess,
color: theme.colors.diffStatAddition,
},
deletions: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.statusMutedDanger,
color: theme.colors.diffStatDeletion,
},
}));
@@ -1,15 +1,30 @@
import { useMemo, type ReactElement, type ReactNode } from "react";
import { withUnistyles } from "react-native-unistyles";
import { Copy, Download, FileText, MessageSquarePlus, type LucideIcon } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import { type PressableStateCallbackType } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import {
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
} from "@/components/ui/context-menu";
Copy,
Download,
FileText,
MessageSquarePlus,
MoreVertical,
type LucideIcon,
} from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { ICON_SIZE, SPACING, type Theme } from "@/styles/theme";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const ThemedMoreVertical = withUnistyles(MoreVertical);
/** Width occupied by a file action trigger, including its visual padding. */
export const FILE_ACTIONS_MENU_WIDTH = ICON_SIZE.sm + 2 * SPACING[1];
interface FileAction {
key: string;
label: string;
@@ -18,7 +33,7 @@ interface FileAction {
testID?: string;
}
interface FileActionsContextMenuContentProps {
interface FileActionsMenuProps {
fileKind: "file" | "directory";
fileExists?: boolean;
onOpenFile?: () => void;
@@ -27,14 +42,32 @@ interface FileActionsContextMenuContentProps {
onAddToChat?: () => void;
/** Optional metadata block rendered above the actions (e.g. size/modified). */
header?: ReactNode;
open?: boolean;
onOpenChange?: (open: boolean) => void;
hitSlop?: number;
accessibilityLabel: string;
testIDPrefix?: string;
}
// The menu lives inside pressable rows (diff header, explorer entry); stop the
// press so opening it doesn't also trigger the row.
function stopTriggerPropagation(event: { stopPropagation?: () => void }) {
event.stopPropagation?.();
}
function triggerStyle({
hovered,
pressed,
open,
}: PressableStateCallbackType & { hovered?: boolean; open?: boolean }) {
return [styles.trigger, (Boolean(hovered) || pressed || Boolean(open)) && styles.triggerActive];
}
/**
* Shared context-menu content for per-file actions. The file explorer tree and git diff pane
* own their row triggers while sharing action availability, ordering, and chrome here.
* Shared kebab (⋮) menu for per-file actions. Used by the file explorer tree and
* git diff pane so both surfaces share action availability, ordering, and chrome.
*/
export function FileActionsContextMenuContent({
export function FileActionsMenu({
fileKind,
fileExists = true,
onOpenFile,
@@ -42,8 +75,12 @@ export function FileActionsContextMenuContent({
onDownload,
onAddToChat,
header,
open,
onOpenChange,
hitSlop = 12,
accessibilityLabel,
testIDPrefix,
}: FileActionsContextMenuContentProps): ReactElement | null {
}: FileActionsMenuProps): ReactElement | null {
const { t } = useTranslation();
const actions = useMemo<FileAction[]>(() => {
const availableFile = fileKind === "file" && fileExists;
@@ -89,21 +126,28 @@ export function FileActionsContextMenuContent({
return null;
}
return (
<ContextMenuContent
align="start"
width={220}
testID={testIDPrefix ? `${testIDPrefix}-context-menu` : undefined}
>
{header ? (
<>
{header}
<ContextMenuSeparator />
</>
) : null}
{actions.map((action) => (
<FileActionMenuItem key={action.key} action={action} />
))}
</ContextMenuContent>
<DropdownMenu open={open} onOpenChange={onOpenChange}>
<DropdownMenuTrigger
hitSlop={hitSlop}
onPressIn={stopTriggerPropagation}
style={triggerStyle}
accessibilityLabel={accessibilityLabel}
testID={testIDPrefix ? `${testIDPrefix}-actions` : undefined}
>
<ThemedMoreVertical size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={220}>
{header ? (
<>
{header}
<DropdownMenuSeparator />
</>
) : null}
{actions.map((action) => (
<FileActionMenuItem key={action.key} action={action} />
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -115,8 +159,27 @@ function FileActionMenuItem({ action }: { action: FileAction }): ReactElement {
[ThemedIcon],
);
return (
<ContextMenuItem leading={leading} onSelect={action.onSelect} testID={action.testID}>
<DropdownMenuItem leading={leading} onSelect={action.onSelect} testID={action.testID}>
{action.label}
</ContextMenuItem>
</DropdownMenuItem>
);
}
const styles = StyleSheet.create((theme) => ({
trigger: {
// The hover box comes from padding, but an equal negative vertical margin
// cancels its height contribution so the trigger overlaps the row's natural
// line height instead of growing it. The comfortable tap target is `hitSlop`,
// never padding.
padding: theme.spacing[1],
width: FILE_ACTIONS_MENU_WIDTH,
marginVertical: -theme.spacing[1],
borderRadius: theme.borderRadius.md,
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
},
triggerActive: {
backgroundColor: theme.colors.surface2,
},
}));
@@ -1,43 +0,0 @@
import type { ReactElement } from "react";
import { withUnistyles } from "react-native-unistyles";
import { SquareMinus, SquarePlus } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import type { Theme } from "@/styles/theme";
const ThemedSquarePlus = withUnistyles(SquarePlus);
const ThemedSquareMinus = withUnistyles(SquareMinus);
const successMapping = (theme: Theme) => ({ color: theme.colors.statusMutedSuccess });
const dangerMapping = (theme: Theme) => ({ color: theme.colors.statusMutedDanger });
const ICON_SIZE = 14;
/**
* Whether a file was added or removed outright, shown beside its diff stat.
*
* Square-plus / square-minus because that is what every forge uses for the same fact, so it needs
* no label — the shape is the word. It sits at the same muted weight as the "+12 −3" next to it:
* these are footnotes on a file header, not the diff body where colour carries the line-by-line
* signal.
*/
export function FileChangeIcon({ change }: { change: "added" | "deleted" }): ReactElement {
const { t } = useTranslation();
if (change === "added") {
return (
<ThemedSquarePlus
size={ICON_SIZE}
uniProps={successMapping}
accessibilityLabel={t("workspace.git.diff.newFile")}
/>
);
}
return (
<ThemedSquareMinus
size={ICON_SIZE}
uniProps={dangerMapping}
accessibilityLabel={t("workspace.git.diff.deletedFile")}
/>
);
}
@@ -12,7 +12,7 @@ import {
type ViewStyle,
} from "react-native";
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor, WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import * as Clipboard from "expo-clipboard";
import { ChevronDown, Eye, EyeOff, RotateCw } from "lucide-react-native";
import { MaterialFileIcon } from "@/components/material-file-icon";
@@ -20,17 +20,9 @@ import {
TreeChevron,
TreeIndentGuides,
treeRowPaddingLeft,
WORKSPACE_FILE_ROW_TRAILING_PADDING,
WORKSPACE_FILE_ROW_VERTICAL_PADDING,
WORKSPACE_TREE_ICON_LABEL_GAP,
WORKSPACE_TREE_ICON_SIZE,
WORKSPACE_TREE_LOADING_ICON_SIZE,
} from "@/components/tree-primitives";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import {
useOverlayFlatListScrollbar,
type OverlayFlatListScrollbar,
} from "@/components/ui/overlay-scrollbar/use-overlay-flat-list-scrollbar";
import type { Theme } from "@/styles/theme";
import type {
AgentFileExplorerState,
@@ -38,8 +30,7 @@ import type {
ExplorerEntry,
} from "@/stores/session-store";
import { useSessionStore } from "@/stores/session-store";
import { FileActionsContextMenuContent } from "@/components/file-actions-menu";
import { ContextMenu, ContextMenuTrigger } from "@/components/ui/context-menu";
import { FileActionsMenu } from "@/components/file-actions-menu";
import { useFileDownload } from "@/hooks/use-file-download";
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
@@ -181,40 +172,34 @@ function TreeRowItem({
);
return (
<ContextMenu>
<ContextMenuTrigger onPress={handlePress} style={pressableStyle} testID={testID}>
<TreeIndentGuides depth={depth} />
<View ref={dragSourceRef} style={styles.entryInfo}>
<View style={styles.entryIcon}>
{(() => {
if (!isDirectory) {
return <MaterialFileIcon fileName={entry.name} size={WORKSPACE_TREE_ICON_SIZE} />;
}
if (loading) {
return (
<ThemedLoadingSpinner
size={WORKSPACE_TREE_LOADING_ICON_SIZE}
uniProps={foregroundMutedColorMapping}
/>
);
}
return <TreeChevron expanded={isExpanded} />;
})()}
</View>
<Text style={styles.entryName} numberOfLines={1}>
{entry.name}
</Text>
<Pressable onPress={handlePress} style={pressableStyle} testID={testID}>
<TreeIndentGuides depth={depth} />
<View ref={dragSourceRef} style={styles.entryInfo}>
<View style={styles.entryIcon}>
{(() => {
if (!isDirectory) {
return <MaterialFileIcon fileName={entry.name} size={16} />;
}
if (loading) {
return <ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />;
}
return <TreeChevron expanded={isExpanded} />;
})()}
</View>
</ContextMenuTrigger>
<FileActionsContextMenuContent
<Text style={styles.entryName} numberOfLines={1}>
{entry.name}
</Text>
</View>
<FileActionsMenu
fileKind={entry.kind}
onCopyPath={handleCopy}
onDownload={handleDownload}
onAddToChat={onAddToChat ? handleAddToChat : undefined}
header={metaHeader}
accessibilityLabel={t("workspace.fileActions.moreActions")}
testIDPrefix={testID}
/>
</ContextMenu>
</Pressable>
);
}
@@ -234,7 +219,6 @@ export function FileExplorerPane({
onAddToChat,
}: FileExplorerPaneProps) {
const { t } = useTranslation();
const isCompact = useIsCompactFormFactor();
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
const workspaceStateKey = useMemo(
@@ -287,7 +271,6 @@ export function FileExplorerPane({
);
const treeListRef = useRef<FlatList<ExplorerTreeRow>>(null);
const scrollbar = useOverlayFlatListScrollbar(treeListRef, { enabled: !isCompact });
const hasInitializedRef = useRef(false);
@@ -528,7 +511,6 @@ export function FileExplorerPane({
currentSortLabel={currentSortLabel}
isRefreshFetching={isRefreshFetching}
treeListRef={treeListRef}
scrollbar={scrollbar}
renderTreeRow={renderTreeRow}
handleSortCycle={handleSortCycle}
handleToggleHiddenFiles={handleToggleHiddenFiles}
@@ -550,7 +532,6 @@ interface FileExplorerPaneContentProps {
currentSortLabel: string;
isRefreshFetching: boolean;
treeListRef: RefObject<FlatList<ExplorerTreeRow> | null>;
scrollbar: OverlayFlatListScrollbar;
renderTreeRow: (info: ListRenderItemInfo<ExplorerTreeRow>) => ReactElement;
handleSortCycle: () => void;
handleToggleHiddenFiles: () => void;
@@ -572,7 +553,6 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
currentSortLabel,
isRefreshFetching,
treeListRef,
scrollbar,
renderTreeRow,
handleSortCycle,
handleToggleHiddenFiles,
@@ -695,17 +675,12 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
keyExtractor={treeRowKeyExtractor}
testID="file-explorer-tree-scroll"
contentContainerStyle={styles.entriesContent}
onLayout={scrollbar.onLayout}
onScroll={scrollbar.onScroll}
onContentSizeChange={scrollbar.onContentSizeChange}
scrollEventThrottle={16}
showsVerticalScrollIndicator={!scrollbar.enabled}
showsVerticalScrollIndicator
initialNumToRender={24}
maxToRenderPerBatch={40}
windowSize={12}
/>
)}
{treeRows.length > 0 ? scrollbar.overlay : null}
</View>
);
}
@@ -1073,7 +1048,7 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center",
justifyContent: "space-between",
paddingVertical: WORKSPACE_FILE_ROW_VERTICAL_PADDING,
paddingRight: WORKSPACE_FILE_ROW_TRAILING_PADDING,
paddingRight: theme.spacing[3],
},
entryRowActive: {
backgroundColor: theme.colors.surfaceSidebarHover,
@@ -1082,14 +1057,10 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
flexDirection: "row",
alignItems: "center",
gap: WORKSPACE_TREE_ICON_LABEL_GAP,
gap: theme.spacing[2],
minWidth: 0,
},
entryIcon: {
width: WORKSPACE_TREE_ICON_SIZE,
height: WORKSPACE_TREE_ICON_SIZE,
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
},
entryName: {
@@ -1,46 +0,0 @@
import { describe, expect, it } from "vitest";
import { getFileIconSvg } from "./file-icon-svg";
import { getRawFileIconSvg } from "./material-file-icons";
function hexColors(svg: string): string[] {
return svg.match(/#[0-9a-fA-F]{6}/g) ?? [];
}
/** How far a colour is from grey, measured crudely enough not to re-derive the implementation. */
function channelSpread(hex: string): number {
const [r, g, b] = [1, 3, 5].map((index) => Number.parseInt(hex.slice(index, index + 2), 16));
return Math.max(r, g, b) - Math.min(r, g, b);
}
describe("getFileIconSvg", () => {
it("keeps the vendor geometry and only rewrites colours", () => {
const raw = getRawFileIconSvg("index.ts");
const toned = getFileIconSvg("index.ts");
expect(toned).not.toBe(raw);
expect(toned.replace(/#[0-9a-fA-F]{6}/g, "#")).toBe(raw.replace(/#[0-9a-fA-F]{6}/g, "#"));
});
it("pulls every colour toward neutral", () => {
const spread = (svg: string) => hexColors(svg).map(channelSpread);
for (const file of ["main.ts", "app.jsx", "style.css", "data.json", "notes.md"]) {
const before = spread(getRawFileIconSvg(file));
const after = spread(getFileIconSvg(file));
expect(after).toHaveLength(before.length);
after.forEach((value, index) => expect(value).toBeLessThan(before[index]));
}
});
it("leaves non-colour attributes alone", () => {
const toned = getFileIconSvg("Main.kt");
expect(toned).toContain('fill="url(#a)"');
});
it("returns the same string for repeated lookups", () => {
expect(getFileIconSvg("a.ts")).toBe(getFileIconSvg("b.ts"));
});
it("falls back to the default icon for unknown extensions", () => {
expect(getFileIconSvg("thing.qqq")).toBe(getFileIconSvg("thing"));
});
});
@@ -1,39 +0,0 @@
import { getRawFileIconSvg } from "@/components/material-file-icons";
import { desaturateHexColor } from "@/utils/color";
/**
* File icons, toned to sit in our UI.
*
* material-icon-theme's palette is built for VS Code's file tree, where the icon is the only
* colour on the row. Ours share a row with status dots, diff stats, and check badges, and a
* column of them at full chroma is the loudest thing in the panel. Every colour in the vendor
* SVG is pulled toward neutral at the same perceived lightness, so hue still tells you what the
* file is — that is the icon's whole job — without the row competing for attention.
*
* One knob. If icons read as too grey or too loud, move this and nothing else; per-icon
* overrides would put us back to hand-picking 53 colours.
*/
const ICON_CHROMA = 0.65;
/** Anything hex-shaped. `desaturateHexColor` returns lengths it does not own untouched. */
const HEX_COLOR_PATTERN = /#[0-9a-fA-F]+/g;
const toned = new Map<string, string>();
function toneSvg(svg: string): string {
return svg.replace(HEX_COLOR_PATTERN, (hex) => desaturateHexColor(hex, ICON_CHROMA));
}
/**
* The SVG for a file, ready to render. Results are cached by the vendor string, so the whole
* table costs at most one pass per icon for the life of the process.
*/
export function getFileIconSvg(fileName: string): string {
const raw = getRawFileIconSvg(fileName);
const cached = toned.get(raw);
if (cached !== undefined) return cached;
const next = toneSvg(raw);
toned.set(raw, next);
return next;
}
+6 -8
View File
@@ -28,7 +28,7 @@ import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { resolveDesktopSidebarWidth } from "@/components/desktop-sidebar-layout";
import { HostPicker } from "@/components/hosts/host-picker";
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
import { SidebarDisplayPreferencesMenu } from "@/components/sidebar/display-preferences/menu";
import { SidebarDisplayPreferencesMenu } from "@/components/sidebar/sidebar-display-preferences-menu";
import { SidebarHelpMenu } from "@/components/sidebar/sidebar-help-menu";
import { SidebarResizeHandle } from "@/components/sidebar-resize-handle";
import { Shortcut } from "@/components/ui/shortcut";
@@ -988,11 +988,11 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[2],
// Rendered inside the scroll's listContent (paddingHorizontal spacing[2]). The title
// lands at spacing[2] left to align with project icons. Settings2's painted path stops
// inside its 14px SVG, so 4px aligns the ink rather than the SVG box to the row rail.
// Rendered inside the scroll's listContent (paddingHorizontal spacing[2]), so the
// title lands at spacing[2] left to align with project icons, and the trailing
// pill sits flush with the list edge on the right.
paddingLeft: theme.spacing[2],
paddingRight: 4,
paddingRight: 0,
paddingTop: theme.spacing[1],
paddingBottom: theme.spacing[1],
},
@@ -1030,9 +1030,7 @@ const styles = StyleSheet.create((theme) => ({
pointerEvents: "box-none",
},
mobileCloseButton: {
// The 16px X paints farther inside its 32px hit target than the 14px Settings2 glyph.
// This optical inset puts their painted right edges on the same sidebar rail.
marginRight: theme.spacing[2] + 1.5,
marginRight: theme.spacing[4],
width: 32,
height: 32,
alignItems: "center",
@@ -1,6 +1,6 @@
import type { ComponentType } from "react";
import { SvgXml } from "react-native-svg";
import { getFileIconSvg } from "@/components/file-icon-svg";
import { getFileIconSvg } from "@/components/material-file-icons";
import type { PanelIconProps } from "@/panels/panel-registry";
export function MaterialFileIcon({ fileName, size }: { fileName: string; size: number }) {
@@ -1,8 +1,4 @@
// Vendor icon table, transcribed from material-icon-theme. The SVGs are copied verbatim,
// including their colours — see file-icon-svg.ts for the module that tones them down for our
// surfaces. Keeping this file a faithful copy is what makes re-copying an icon a mechanical edit.
//
// See docs/file-icons.md for how to add one.
// Auto-generated from material-icon-theme. Do not edit manually.
const SVG_ICONS: Record<string, string> = {
_default: `<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="m8.668 6h3.6641l-3.6641-3.668v3.668m-4.668-4.668h5.332l4 4v8c0 0.73828-0.59375 1.3359-1.332 1.3359h-8c-0.73828 0-1.332-0.59766-1.332-1.3359v-10.664c0-0.74219 0.59375-1.3359 1.332-1.3359m3.332 1.3359h-3.332v10.664h8v-6h-4.668z" fill="#90a4ae" /></svg>`,
@@ -129,7 +125,7 @@ const EXTENSION_TO_ICON: Record<string, string> = {
zig: "zig",
};
export function getRawFileIconSvg(fileName: string): string {
export function getFileIconSvg(fileName: string): string {
const ext = getExtension(fileName);
if (ext) {
const iconName = EXTENSION_TO_ICON[ext];
@@ -162,7 +162,8 @@ export function ProjectEditSheet({
iconDataUri={state.previewDataUri}
initial={projectInitial(snapshot.projectName)}
projectViewKey={projectViewKey}
size={40}
imageStyle={styles.preview}
fallbackStyle={styles.previewFallback}
textStyle={styles.previewText}
/>
<Button
@@ -272,6 +273,18 @@ const styles = StyleSheet.create((theme) => ({
flexWrap: "wrap",
gap: theme.spacing[2],
},
preview: {
width: 40,
height: 40,
borderRadius: theme.borderRadius.md,
},
previewFallback: {
width: 40,
height: 40,
borderRadius: theme.borderRadius.md,
alignItems: "center",
justifyContent: "center",
},
previewText: {
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.medium,
@@ -1,62 +1,44 @@
import { useMemo } from "react";
import { Image, type StyleProp, Text, type TextStyle, View } from "react-native";
import {
Image,
type ImageStyle,
type StyleProp,
Text,
type TextStyle,
View,
type ViewStyle,
} from "react-native";
import { deriveIdentityColorName, identityColor } from "@/styles/identity-colors";
const WHITE_TEXT = { color: "#ffffff" } as const;
const FALLBACK_LAYOUT = { alignItems: "center", justifyContent: "center" } as const;
/**
* Corner radius of the *generated* project icon — the colored square with an initial — as a
* fraction of the box, so it reads as the same shape at 16pt in the sidebar and 40pt in the edit
* sheet. Fixed tokens did not give that: the radius scale is coarse at the bottom, so small icons
* landed on 2pt and looked square while large ones were visibly rounder.
*
* This is ours to shape. A **user-uploaded icon is never rounded** — it is someone's mark, and
* clipping its corners distorts branding we don't own. A square logo stays square, a round one is
* already round.
*/
const RADIUS_RATIO = 0.25;
export function projectIconRadius(size: number): number {
return Math.round(size * RADIUS_RATIO);
}
/**
* A project's icon: its chosen image, or a colored square carrying its initial.
*
* Geometry lives here, not at the call site. It used to be five copies of the same
* width/height/radius/centering block, which is how the radius drifted apart in the first
* place — pass a `size` and the shape follows.
*/
export function ProjectIconView({
iconDataUri,
initial,
projectViewKey,
size,
imageStyle,
fallbackStyle,
textStyle,
}: {
iconDataUri: string | null;
initial: string;
projectViewKey: string;
size: number;
imageStyle: StyleProp<ImageStyle>;
fallbackStyle: StyleProp<ViewStyle>;
textStyle: StyleProp<TextStyle>;
}) {
const imageSource = useMemo(() => ({ uri: iconDataUri ?? "" }), [iconDataUri]);
// The uploaded image is sized but never clipped — see projectIconRadius.
const box = useMemo(() => ({ width: size, height: size }), [size]);
const fallbackStyles = useMemo(
() => [
box,
{ borderRadius: projectIconRadius(size) },
FALLBACK_LAYOUT,
fallbackStyle,
{ backgroundColor: identityColor(deriveIdentityColorName(projectViewKey)) },
],
[box, size, projectViewKey],
[fallbackStyle, projectViewKey],
);
const textStyles = useMemo(() => [textStyle, WHITE_TEXT], [textStyle]);
if (iconDataUri) {
return <Image source={imageSource} style={box} />;
return <Image source={imageSource} style={imageStyle} />;
}
return (
<View style={fallbackStyles}>
@@ -1,4 +1,4 @@
import React, { createContext, memo, type ReactNode, useContext } from "react";
import { createContext, memo, type ReactNode, useContext } from "react";
import { StyleSheet, View, type StyleProp, type ViewStyle } from "react-native";
const RetainedPanelActiveContext = createContext(true);
@@ -4,6 +4,7 @@ import { StyleSheet } from "react-native-unistyles";
import { ScheduleRow, type ScheduleRowPending } from "@/components/schedules/schedule-row";
import { useScheduleMutations } from "@/hooks/use-schedule-mutations";
import type { AggregatedSchedule } from "@/hooks/use-schedules";
import { resolveDeleteScheduleDialog } from "@/schedules/delete-schedule";
import type { ScheduleDerivedState } from "@/schedules/schedule-derivation";
import { settingsStyles } from "@/styles/settings";
import { confirmDialog } from "@/utils/confirm-dialog";
@@ -113,13 +114,12 @@ function SchedulesTableRow({
const handleDelete = useCallback(() => {
void (async () => {
const productName = scheduleProductName(schedule);
const confirmed = await confirmDialog({
title: `Delete ${productName.toLowerCase()}`,
message: `Delete "${resolveScheduleTitle(schedule)}"? This cannot be undone.`,
confirmLabel: "Delete",
destructive: true,
});
const confirmed = await confirmDialog(
resolveDeleteScheduleDialog({
productName: scheduleProductName(schedule),
title: resolveScheduleTitle(schedule),
}),
);
if (!confirmed) {
return;
}
@@ -1,9 +1,6 @@
import { useEffect, useMemo, useRef } from "react";
import { Animated, View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { projectIconRadius } from "@/components/project-icon-view";
const PROJECT_ICON_SIZE = 16;
const SECTION_OPACITIES: readonly number[] = [1, 0.7, 0.4];
const SECTION_KEYS = SECTION_OPACITIES.map((_, i) => `skeleton-section-${i}`);
@@ -119,10 +116,9 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.surface2,
},
projectIcon: {
width: PROJECT_ICON_SIZE,
height: PROJECT_ICON_SIZE,
// Matches the real icon it stands in for, so the skeleton doesn't change shape on load.
borderRadius: projectIconRadius(PROJECT_ICON_SIZE),
width: 16,
height: 16,
borderRadius: theme.borderRadius.sm,
backgroundColor: theme.colors.surface2,
},
sectionTitle: {
@@ -33,9 +33,9 @@ import {
} from "@/stores/navigation-active-workspace-store";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import type { Theme } from "@/styles/theme";
import { getSidebarRowBackdrop } from "@/components/sidebar/sidebar-row-backdrop";
import { type GestureType } from "react-native-gesture-handler";
import * as Clipboard from "expo-clipboard";
import { DiffStat } from "@/components/diff-stat";
import {
ExternalLink,
GitPullRequest,
@@ -111,11 +111,6 @@ import {
SidebarWorkspaceTrailingActionOverlay,
SidebarWorkspaceTrailingActionSlot,
} from "@/components/sidebar/sidebar-workspace-row-content";
import { selectWorkspaceScriptSummary } from "@/components/sidebar/workspace-meta-row";
import {
SidebarWorkspaceTrailingContent,
useSidebarWorkspaceTrailing,
} from "@/components/sidebar/workspace-trailing";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Shortcut } from "@/components/ui/shortcut";
@@ -147,7 +142,6 @@ import { getDesktopHost } from "@/desktop/host";
import { OpenInFileManagerMenuItem } from "@/workspace/open-in-file-manager/menu-item";
import { useLocalDaemonServerId, useLocalDaemonServerIdState } from "@/hooks/use-is-local-daemon";
import { selectHostBadges, type HostBadgeModel } from "@/hosts/appearance";
import { useSidebarRowItems } from "@/components/sidebar/display-preferences/model";
const workspaceKeyExtractor = (workspace: SidebarWorkspacePlacement) => workspace.workspaceKey;
@@ -169,13 +163,13 @@ const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
const redColorMapping = (theme: Theme) => ({
color: theme.colors.statusMutedDanger,
color: theme.colors.palette.red[500],
});
const greenColorMapping = (theme: Theme) => ({
color: theme.colors.statusMutedSuccess,
color: theme.colors.palette.green[500],
});
const purpleColorMapping = (theme: Theme) => ({
color: theme.colors.statusMutedMerged,
color: theme.colors.palette.purple[500],
});
function getPrIconUniMapping(state: PrHint["state"]) {
@@ -359,6 +353,7 @@ export function PrBadge({ hint, style }: { hint: PrHint; style?: StyleProp<ViewS
<ThemedGitPullRequest size={12} uniProps={iconUniProps} />
)}
<Text style={textStyle} numberOfLines={1}>
{presentation.numberPrefix}
{hint.number}
</Text>
</Pressable>
@@ -495,7 +490,7 @@ function ProjectKebabMenu({
}) {
const { t } = useTranslation();
return (
<DropdownMenu compactMode="sheet">
<DropdownMenu>
<DropdownMenuTrigger
hitSlop={8}
style={projectKebabStyle}
@@ -505,7 +500,7 @@ function ProjectKebabMenu({
>
{renderKebabTriggerIcon}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={220} sheetTitle={t("sidebar.project.actions.menu")}>
<DropdownMenuContent align="end" width={220}>
<ProjectMenuItems
surface="dropdown"
projectViewKey={projectViewKey}
@@ -647,32 +642,34 @@ function WorkspaceRowRightGroup({
}) {
const workspacePath = workspace.workspaceDirectory ?? workspace.projectRootPath;
const { t } = useTranslation();
const trailing = useSidebarWorkspaceTrailing();
const showShortcut = showShortcutBadge && shortcutNumber !== null;
const {
showTrailing,
showDiffStat,
showKebab: showKebabInSlot,
showScrim,
renderSlot,
reserveSlotWidth,
} = resolveTrailingActionVisibility({
workspace,
trailing,
hasDiffStat: Boolean(workspace.diffStat),
hasArchiveAction: Boolean(onArchive),
isHovered,
isTouchPlatform,
showShortcut,
});
const shouldRenderActionSlot = Boolean(onArchive || workspace.diffStat);
return (
<>
{isCreating ? (
<Text style={styles.workspaceCreatingText}>{t("sidebar.workspace.status.creating")}</Text>
) : null}
{renderSlot ? (
<SidebarWorkspaceTrailingActionSlot reserveWidth={reserveSlotWidth}>
<SidebarWorkspaceTrailingActionBase visible={showTrailing}>
<SidebarWorkspaceTrailingContent workspace={workspace} trailing={trailing} />
{shouldRenderActionSlot ? (
<SidebarWorkspaceTrailingActionSlot>
<SidebarWorkspaceTrailingActionBase visible={showDiffStat}>
{workspace.diffStat ? (
<DiffStat
additions={workspace.diffStat.additions}
deletions={workspace.diffStat.deletions}
/>
) : null}
</SidebarWorkspaceTrailingActionBase>
<SidebarWorkspaceTrailingActionOverlay visible={showKebabInSlot} scrim={showScrim}>
{onArchive ? (
@@ -931,7 +928,6 @@ function ProjectHeaderRow({
iconDataUri={iconDataUri}
statusBucket={statusBucket}
projectViewKey={project.viewKey}
backdrop={getSidebarRowBackdrop({ isDragging, selected, isHovered })}
chevron={chevron}
showChevron={isHovered && chevron !== null}
isArchiving={isArchiving}
@@ -1082,7 +1078,14 @@ function WorkspaceRowInner({
<SidebarWorkspaceRowFrame workspace={workspace} isDragging={isDragging}>
{({ isHovered, contextMenuOpen, onContextMenuOpenChange, hoverHandlers }) => {
const isDesktop = !isTouchPlatform;
const scriptSummary = isDesktop ? selectWorkspaceScriptSummary(workspace.scripts) : null;
const showScriptsIcon = isDesktop && workspace.hasRunningScripts;
const hasRunningService = workspace.scripts.some(
(s) => s.lifecycle === "running" && (s.type ?? "service") === "service",
);
let scriptIconKind: "service" | "command" | null = null;
if (showScriptsIcon) {
scriptIconKind = hasRunningService ? "service" : "command";
}
const workspaceRowStyle = getProjectWorkspaceRowStyle({
isDragging,
selected,
@@ -1130,8 +1133,7 @@ function WorkspaceRowInner({
hostBadge={hostBadge}
leadingProjectName={leadingProjectName}
leadingProjectIconDataUri={leadingProjectIconDataUri}
scriptSummary={scriptSummary}
backdrop={getSidebarRowBackdrop({ isDragging, selected, isHovered })}
scriptIconKind={scriptIconKind}
isHovered={isHovered}
isLoading={isArchiving || isCreating}
isCreating={isCreating}
@@ -1890,20 +1892,15 @@ export function SidebarWorkspaceList({
const hosts = useHosts();
const localServerId = useLocalDaemonServerId();
const localDaemon = useLocalDaemonServerIdState();
const rowItems = useSidebarRowItems();
// Host badge visibility is a lattice, not three competing switches: this gate is the global
// "off", `shouldShowSidebarHostLabels` is the automatic "there is only one host so it says
// nothing", and each host's own `badgeDisplay` decides name vs icon vs hidden. Turning the
// item off here removes the badge everywhere; leaving it on defers to the per-host setting.
const hostBadgeByServerId = useMemo(
() =>
selectHostBadges({
hosts,
localServerId,
localHostResolutionPending: localDaemon.status !== "resolved",
enabled: rowItems.host && shouldShowSidebarHostLabels(projects),
enabled: shouldShowSidebarHostLabels(projects),
}),
[hosts, localDaemon.status, localServerId, projects, rowItems.host],
[hosts, localDaemon.status, localServerId, projects],
);
const serverIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]);
const supportsMultiplicityByServerId = useHostFeatureMap(serverIds, "workspaceMultiplicity");
@@ -2541,9 +2538,6 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center",
gap: 2,
flexShrink: 0,
// MoreVertical paints only around the center of its 14px SVG. Keep the 24px controls,
// but pull their painted edge through the unused view-box space onto the row rail.
marginRight: -6,
},
projectKebabButton: {
width: 24,
@@ -1,406 +0,0 @@
import { useCallback, useMemo, type ComponentType, type ReactElement, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { View, type PressableStateCallbackType } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import {
CircleCheck,
CircleDashed,
Clock,
Diff,
Folder,
GitBranch,
GitPullRequest,
Server,
Settings2,
SquareTerminal,
Type,
} from "lucide-react-native";
import {
MenuItem,
MenuRoot,
MenuSeparator,
MenuSubTrigger,
MenuSurface,
MenuTrigger,
type MenuPageDefinition,
} from "@/components/ui/menu";
import { HostStatusDot } from "@/components/host-status-dot";
import { isWeb } from "@/constants/platform";
import { useHosts } from "@/runtime/host-runtime";
import type { Theme } from "@/styles/theme";
import type { SidebarGroupMode } from "@/stores/sidebar-view-store";
import type { WorkspaceTitleSource } from "@/hooks/use-settings";
import { useSidebarDisplayPreferences, type SidebarTrailingChoice } from "./model";
import { SIDEBAR_ROW_ITEMS, type SidebarRowItem } from "./row-items";
const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const ThemedSettings2 = withUnistyles(Settings2);
/** Fits the item's 16pt leading slot with a hair of room, matching the trailing check. */
const OPTION_ICON_SIZE = 14;
const MENU_WIDTH = 232;
type OptionIcon = ComponentType<{
size: number;
uniProps: (theme: Theme) => { color: string };
}>;
// Options carry icons; the root rows deliberately do not. The root is four labels with their
// current values, and a column of icons there would be decoration competing with the values.
const GROUPING_ICONS: Record<SidebarGroupMode, OptionIcon> = {
project: withUnistyles(Folder),
status: withUnistyles(CircleDashed),
};
const TITLE_SOURCE_ICONS: Record<WorkspaceTitleSource, OptionIcon> = {
title: withUnistyles(Type),
branch: withUnistyles(GitBranch),
};
// The same marks these things carry on the workspace row itself, so the menu and the row it
// configures name each item the same way twice.
const ROW_ITEM_ICONS: Record<SidebarRowItem, OptionIcon> = {
host: withUnistyles(Server),
changeRequest: withUnistyles(GitPullRequest),
checks: withUnistyles(CircleCheck),
scripts: withUnistyles(SquareTerminal),
};
const TRAILING_ICONS: Record<SidebarTrailingChoice, OptionIcon> = {
diff: withUnistyles(Diff),
timestamp: withUnistyles(Clock),
};
const GROUPING_MODES: readonly SidebarGroupMode[] = ["project", "status"];
const TITLE_SOURCES: readonly WorkspaceTitleSource[] = ["title", "branch"];
const TRAILING_CHOICES: readonly SidebarTrailingChoice[] = ["diff", "timestamp"];
const GROUPING_LABEL_KEYS: Record<SidebarGroupMode, string> = {
project: "sidebar.display.grouping.project",
status: "sidebar.display.grouping.status",
};
const TITLE_SOURCE_LABEL_KEYS: Record<WorkspaceTitleSource, string> = {
title: "sidebar.display.titleSource.title",
branch: "sidebar.display.titleSource.branch",
};
const ROW_ITEM_LABEL_KEYS: Record<SidebarRowItem, string> = {
host: "sidebar.display.show.host",
changeRequest: "sidebar.display.show.changeRequest",
checks: "sidebar.display.show.checks",
scripts: "sidebar.display.show.scripts",
};
const TRAILING_LABEL_KEYS: Record<SidebarTrailingChoice, string> = {
diff: "sidebar.display.show.diff",
timestamp: "sidebar.display.show.timestamp",
};
/**
* What the sidebar shows and how it is arranged.
*
* The root is one row per decision with its current value; the options live a level down. The
* shape is deliberate — every option of every decision on one surface is what this menu used to
* be, and it grew a row for each host on top of that.
*/
export function SidebarDisplayPreferencesMenu(): ReactElement {
const { t } = useTranslation();
const preferences = useSidebarDisplayPreferences();
const hosts = useHosts();
const triggerStyle = useCallback(
({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.trigger,
hovered && styles.triggerHovered,
],
[],
);
const showHostFilter = hosts.length > 1;
const pages = useMemo<MenuPageDefinition[]>(() => {
const definitions: MenuPageDefinition[] = [
{
id: "grouping",
title: t("sidebar.display.grouping.label"),
content: (
<OptionList
values={GROUPING_MODES}
icons={GROUPING_ICONS}
labelKeys={GROUPING_LABEL_KEYS}
selectedValue={preferences.grouping}
onSelect={preferences.setGrouping}
testIDPrefix="sidebar-grouping"
/>
),
},
{
id: "titleSource",
title: t("sidebar.display.titleSource.label"),
content: (
<OptionList
values={TITLE_SOURCES}
icons={TITLE_SOURCE_ICONS}
labelKeys={TITLE_SOURCE_LABEL_KEYS}
selectedValue={preferences.titleSource}
onSelect={preferences.setTitleSource}
testIDPrefix="sidebar-workspace-title-source"
/>
),
},
{
id: "show",
title: t("sidebar.display.show.label"),
content: <ShowPage preferences={preferences} />,
},
];
if (showHostFilter) {
definitions.push({
id: "hostFilter",
title: t("sidebar.display.hostFilter.label"),
content: <HostFilterPage preferences={preferences} hosts={hosts} />,
});
}
return definitions;
}, [t, preferences, hosts, showHostFilter]);
return (
<MenuRoot compactMode="sheet">
<MenuTrigger
style={triggerStyle}
accessibilityRole={isWeb ? undefined : "button"}
accessibilityLabel={t("sidebar.display.trigger")}
testID="sidebar-display-preferences-menu"
>
<ThemedSettings2 size={14} uniProps={mutedIconMapping} />
</MenuTrigger>
<MenuSurface
align="end"
width={MENU_WIDTH}
pages={pages}
sheetTitle={t("sidebar.display.heading")}
testID="sidebar-display-preferences-content"
>
<MenuSubTrigger
id="grouping"
value={t(GROUPING_LABEL_KEYS[preferences.grouping])}
testID="sidebar-display-grouping"
>
{t("sidebar.display.grouping.label")}
</MenuSubTrigger>
<MenuSubTrigger
id="titleSource"
value={t(TITLE_SOURCE_LABEL_KEYS[preferences.titleSource])}
testID="sidebar-display-title-source"
>
{t("sidebar.display.titleSource.label")}
</MenuSubTrigger>
<MenuSubTrigger id="show" testID="sidebar-display-show">
{t("sidebar.display.show.label")}
</MenuSubTrigger>
{showHostFilter ? (
<>
<MenuSeparator />
{/* A filtered sidebar looks like workspaces went missing, so the branch says so
from the root rather than making you open it to find out. */}
<MenuSubTrigger
id="hostFilter"
indicator={preferences.hostFilters.length > 0}
testID="sidebar-display-host-filter"
>
{t("sidebar.display.hostFilter.label")}
</MenuSubTrigger>
</>
) : null}
</MenuSurface>
</MenuRoot>
);
}
type Preferences = ReturnType<typeof useSidebarDisplayPreferences>;
/** One option row: its mark on the left, its label, and a check when it is the chosen one. */
function OptionItem<Value extends string>({
value,
icon: Icon,
label,
selected,
closeOnSelect = true,
onSelect,
testID,
}: {
value: Value;
icon: OptionIcon;
label: string;
selected: boolean;
closeOnSelect?: boolean;
onSelect: (value: Value) => void;
testID: string;
}): ReactElement {
const handleSelect = useCallback(() => onSelect(value), [onSelect, value]);
const leading = useMemo(
() => <Icon size={OPTION_ICON_SIZE} uniProps={mutedIconMapping} />,
[Icon],
);
return (
<MenuItem
selected={selected}
leading={leading}
closeOnSelect={closeOnSelect}
onSelect={handleSelect}
testID={testID}
>
{label}
</MenuItem>
);
}
/** A page of mutually exclusive options — pick one and the menu closes. */
function OptionList<Value extends string>({
values,
icons,
labelKeys,
selectedValue,
onSelect,
testIDPrefix,
}: {
values: readonly Value[];
icons: Record<Value, OptionIcon>;
labelKeys: Record<Value, string>;
selectedValue: Value;
onSelect: (value: Value) => void;
testIDPrefix: string;
}): ReactNode {
const { t } = useTranslation();
return values.map((value) => (
<OptionItem
key={value}
value={value}
icon={icons[value]}
label={t(labelKeys[value])}
selected={value === selectedValue}
onSelect={onSelect}
testID={`${testIDPrefix}-${value}`}
/>
));
}
/**
* 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.
*/
function ShowPage({ preferences }: { preferences: Preferences }): ReactElement {
const { t } = useTranslation();
return (
<>
{SIDEBAR_ROW_ITEMS.map((item) => (
<OptionItem
key={item}
value={item}
icon={ROW_ITEM_ICONS[item]}
label={t(ROW_ITEM_LABEL_KEYS[item])}
selected={preferences.rowItems[item]}
closeOnSelect={false}
onSelect={preferences.toggleRowItem}
testID={`sidebar-row-item-${item}`}
/>
))}
<MenuSeparator />
{TRAILING_CHOICES.map((choice) => (
<OptionItem
key={choice}
value={choice}
icon={TRAILING_ICONS[choice]}
label={t(TRAILING_LABEL_KEYS[choice])}
selected={preferences.trailing === choice}
closeOnSelect={false}
onSelect={preferences.toggleTrailing}
testID={`sidebar-workspace-trailing-${choice}`}
/>
))}
</>
);
}
function HostFilterPage({
preferences,
hosts,
}: {
preferences: Preferences;
hosts: ReturnType<typeof useHosts>;
}): ReactElement {
const { t } = useTranslation();
return (
<>
<MenuItem
selected={preferences.hostFilters.length === 0}
closeOnSelect={false}
onSelect={preferences.clearHostFilters}
testID="sidebar-host-filter-all"
>
{t("sidebar.display.hostFilter.all")}
</MenuItem>
{hosts.map((host) => (
<HostFilterItem
key={host.serverId}
serverId={host.serverId}
label={host.label?.trim() || host.serverId}
selected={preferences.hostFilters.includes(host.serverId)}
onToggle={preferences.toggleHostFilter}
/>
))}
</>
);
}
/** The one option row whose mark is live state rather than an icon. */
function HostFilterItem({
serverId,
label,
selected,
onToggle,
}: {
serverId: string;
label: string;
selected: boolean;
onToggle: (serverId: string) => void;
}): ReactElement {
const handleSelect = useCallback(() => onToggle(serverId), [onToggle, serverId]);
const leading = useMemo(
() => (
<View testID={`sidebar-host-filter-status-${serverId}`}>
<HostStatusDot serverId={serverId} />
</View>
),
[serverId],
);
return (
<MenuItem
selected={selected}
closeOnSelect={false}
leading={leading}
onSelect={handleSelect}
testID={`sidebar-host-filter-${serverId}`}
>
{label}
</MenuItem>
);
}
const styles = StyleSheet.create((theme) => ({
trigger: {
width: 28,
height: 28,
alignItems: "center",
justifyContent: "center",
borderRadius: theme.borderRadius.md,
},
triggerHovered: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
}));
@@ -1,112 +0,0 @@
import { useCallback, useMemo } from "react";
import {
useAppSettings,
type SidebarWorkspaceTrailing,
type WorkspaceTitleSource,
} from "@/hooks/use-settings";
import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store";
import { DEFAULT_SIDEBAR_ROW_ITEMS, type SidebarRowItem, type SidebarRowItems } from "./row-items";
/** The trailing slot holds one thing, so these are a choice rather than toggles. */
export type SidebarTrailingChoice = Exclude<SidebarWorkspaceTrailing, "none">;
export interface SidebarDisplayPreferences {
grouping: SidebarGroupMode;
setGrouping: (mode: SidebarGroupMode) => void;
titleSource: WorkspaceTitleSource;
setTitleSource: (source: WorkspaceTitleSource) => void;
rowItems: SidebarRowItems;
toggleRowItem: (item: SidebarRowItem) => void;
trailing: SidebarWorkspaceTrailing;
/** Picking the choice that is already showing clears the slot. */
toggleTrailing: (choice: SidebarTrailingChoice) => void;
hostFilters: readonly string[];
toggleHostFilter: (serverId: string) => void;
clearHostFilters: () => void;
}
/**
* Every decision the sidebar's display-preferences menu can make, behind one interface.
*
* Grouping and host filters live in a local zustand store while the title source and row items
* are synced app settings — a split that exists for good reasons (a filter is transient view
* state; a preference follows you) and that the menu has no business knowing about. Callers ask
* this for a value and set it; where it lands is this module's problem.
*/
export function useSidebarDisplayPreferences(): SidebarDisplayPreferences {
const grouping = useSidebarViewStore((state) => state.groupMode);
const setGrouping = useSidebarViewStore((state) => state.setGroupMode);
const hostFilters = useSidebarViewStore((state) => state.hostFilters);
const toggleHostFilter = useSidebarViewStore((state) => state.toggleHostFilter);
const clearHostFilters = useSidebarViewStore((state) => state.clearHostFilters);
const {
settings: { workspaceTitleSource, sidebarWorkspaceTrailing, sidebarRowItems },
updateSettings,
} = useAppSettings();
const setTitleSource = useCallback(
(source: WorkspaceTitleSource) => {
void updateSettings({ workspaceTitleSource: source });
},
[updateSettings],
);
const toggleRowItem = useCallback(
(item: SidebarRowItem) => {
void updateSettings({
sidebarRowItems: { ...sidebarRowItems, [item]: !sidebarRowItems[item] },
});
},
[updateSettings, sidebarRowItems],
);
const toggleTrailing = useCallback(
(choice: SidebarTrailingChoice) => {
void updateSettings({
sidebarWorkspaceTrailing: sidebarWorkspaceTrailing === choice ? "none" : choice,
});
},
[updateSettings, sidebarWorkspaceTrailing],
);
return useMemo(
() => ({
grouping,
setGrouping,
titleSource: workspaceTitleSource,
setTitleSource,
rowItems: sidebarRowItems,
toggleRowItem,
trailing: sidebarWorkspaceTrailing,
toggleTrailing,
hostFilters,
toggleHostFilter,
clearHostFilters,
}),
[
grouping,
setGrouping,
workspaceTitleSource,
setTitleSource,
sidebarRowItems,
toggleRowItem,
sidebarWorkspaceTrailing,
toggleTrailing,
hostFilters,
toggleHostFilter,
clearHostFilters,
],
);
}
/**
* Just the row items, for the row renderers. They re-render per workspace, so they subscribe to
* the one field they use rather than to every preference in the menu.
*/
export function useSidebarRowItems(): SidebarRowItems {
const {
settings: { sidebarRowItems },
} = useAppSettings();
return sidebarRowItems ?? DEFAULT_SIDEBAR_ROW_ITEMS;
}
@@ -1,44 +0,0 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_SIDEBAR_ROW_ITEMS, parseSidebarRowItems, SIDEBAR_ROW_ITEMS } from "./row-items";
describe("parseSidebarRowItems", () => {
it("shows everything by default", () => {
for (const item of SIDEBAR_ROW_ITEMS) {
expect(DEFAULT_SIDEBAR_ROW_ITEMS[item]).toBe(true);
}
});
it("applies stored overrides", () => {
expect(parseSidebarRowItems({ checks: false })).toEqual({
...DEFAULT_SIDEBAR_ROW_ITEMS,
checks: false,
});
});
it("leaves items absent from storage at their default", () => {
// A newly added item must ship visible rather than inheriting "missing means off".
expect(parseSidebarRowItems({ host: false })).toEqual({
...DEFAULT_SIDEBAR_ROW_ITEMS,
host: false,
});
});
it("ignores unknown keys and non-boolean values", () => {
expect(
parseSidebarRowItems({ checks: "yes", nonsense: false, scripts: null, host: false }),
).toEqual({ ...DEFAULT_SIDEBAR_ROW_ITEMS, host: false });
});
it.each([[null], [undefined], ["diff"], [42], [["checks"]]])(
"falls back to defaults for %s",
(value) => {
expect(parseSidebarRowItems(value)).toEqual(DEFAULT_SIDEBAR_ROW_ITEMS);
},
);
it("does not hand back the shared default object", () => {
const parsed = parseSidebarRowItems({ checks: false });
expect(parsed).not.toBe(DEFAULT_SIDEBAR_ROW_ITEMS);
expect(DEFAULT_SIDEBAR_ROW_ITEMS.checks).toBe(true);
});
});
@@ -1,47 +0,0 @@
/**
* Which facts a sidebar workspace row is allowed to show about a workspace.
*
* Each item is independent — this is not a mode. Turning one off removes it from every row and
* gives the space back to the title.
*
* Pure on purpose: `hooks/use-settings/storage.ts` validates the persisted value through
* `parseSidebarRowItems` rather than growing its own checks, so this module stays the one place
* that knows what an item is.
*/
export const SIDEBAR_ROW_ITEMS = ["host", "changeRequest", "checks", "scripts"] as const;
export type SidebarRowItem = (typeof SIDEBAR_ROW_ITEMS)[number];
export type SidebarRowItems = Record<SidebarRowItem, boolean>;
/**
* Everything on by default. A new item ships visible and users turn it off, which is also why
* the persisted shape is a record of overrides rather than a list of enabled items: an absent
* key has to mean "default", not "off".
*/
export const DEFAULT_SIDEBAR_ROW_ITEMS: SidebarRowItems = {
host: true,
changeRequest: true,
checks: true,
scripts: true,
};
function isSidebarRowItem(value: string): value is SidebarRowItem {
return (SIDEBAR_ROW_ITEMS as readonly string[]).includes(value);
}
export function parseSidebarRowItems(value: unknown): SidebarRowItems {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return DEFAULT_SIDEBAR_ROW_ITEMS;
}
const stored = value as Record<string, unknown>;
const result = { ...DEFAULT_SIDEBAR_ROW_ITEMS };
for (const [key, entry] of Object.entries(stored)) {
if (isSidebarRowItem(key) && typeof entry === "boolean") {
result[key] = entry;
}
}
return result;
}
@@ -0,0 +1,116 @@
import { Text, View, type TextStyle, type ViewStyle } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Server } from "lucide-react-native";
import { HOST_COLORS, type HostColor } from "@/hosts/appearance";
import { identityColor, identityTint } from "@/styles/identity-colors";
import type { Theme } from "@/styles/theme";
const ThemedServer = withUnistyles(Server);
const MAX_NAMED_BADGE_WIDTH = 96;
interface HostBadgeColorOverride {
badge: ViewStyle | null;
text: TextStyle | null;
}
function buildColorOverrides(): Record<HostColor, HostBadgeColorOverride> {
const byColor = {} as Record<HostColor, HostBadgeColorOverride>;
for (const color of HOST_COLORS) {
byColor[color] =
color === "none"
? { badge: null, text: null }
: {
badge: { backgroundColor: identityTint(color) },
text: { color: identityColor(color) },
};
}
return byColor;
}
// The identity table is theme-independent, so its icon mapping is fixed for the life of the
// module. It must not be rebuilt per render: a fresh `uniProps` identity makes withUnistyles
// re-subscribe every pass.
function buildIconMappings(): Record<HostColor, (theme: Theme) => { color: string }> {
const byColor = {} as Record<HostColor, (theme: Theme) => { color: string }>;
for (const color of HOST_COLORS) {
byColor[color] =
color === "none"
? (theme: Theme) => ({ color: theme.colors.foregroundMuted })
: () => ({ color: identityColor(color) });
}
return byColor;
}
const COLOR_OVERRIDES = buildColorOverrides();
const ICON_MAPPINGS = buildIconMappings();
/**
* Which host a workspace lives on, as a pill after the title. Only rendered in multi-host
* setups — see selectHostBadges. It's a badge rather than a second line of text because the
* row has to stay scannable at one line per workspace.
*
*/
export function HostBadge({
serverId,
label,
color,
showLabel,
}: {
serverId: string;
label: string;
color: HostColor;
showLabel: boolean;
}) {
const override = COLOR_OVERRIDES[color];
return (
<View
style={[
hostBadgeStyles.badge,
showLabel ? null : hostBadgeStyles.badgeIconOnly,
override.badge,
]}
testID={`sidebar-host-badge-${serverId}`}
>
<ThemedServer size={9} uniProps={ICON_MAPPINGS[color]} />
{showLabel ? (
<Text style={[hostBadgeStyles.text, override.text]} numberOfLines={1}>
{label}
</Text>
) : null}
</View>
);
}
const hostBadgeStyles = StyleSheet.create((theme) => ({
// Sized off the title's 20pt line box so the pill never grows the row. A named badge must
// leave room for the workspace title and trailing actions, even when a host has an arbitrary
// custom label. The full host name remains in the row's accessible label and hover card.
badge: {
flexDirection: "row",
alignItems: "center",
gap: 3,
height: 16,
paddingLeft: 4,
paddingRight: 6,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.surface2,
maxWidth: MAX_NAMED_BADGE_WIDTH,
minWidth: 0,
flexShrink: 0,
},
// Icon-only drops the label, so the asymmetric padding that made room for text leaves a
// lopsided pill. Symmetric padding recenters the icon.
badgeIconOnly: {
paddingLeft: 4,
paddingRight: 4,
flexShrink: 0,
},
text: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
lineHeight: 14,
color: theme.colors.foregroundMuted,
flexShrink: 1,
minWidth: 0,
},
}));
@@ -4,7 +4,7 @@ import { ChevronDown, ChevronRight, CircleAlert } from "lucide-react-native";
import { ProjectIconView } from "@/components/project-icon-view";
import { PulsingStatusDot } from "@/components/sidebar/pulsing-status-dot";
import { STATUS_BUCKET_LABELS } from "@/hooks/sidebar-status-view-model";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import type { Theme } from "@/styles/theme";
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
import {
getProjectStatusBadgeContent,
@@ -13,25 +13,25 @@ import {
} from "@/utils/project-status-badge-content";
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
import { getStatusDotColor } from "@/utils/status-dot-color";
import type { SurfaceBackdrop } from "@/styles/surface-backdrop";
// Every surfaced status shares one badge shell, so the badge never changes size or position
// between states. Only the thing inside it changes.
const STATUS_BADGE_SIZE = 12;
const STATUS_BADGE_SIZE = 14;
const STATUS_BADGE_BORDER_WIDTH = 1;
// How far the badge overhangs the icon's bottom-right corner. Well short of half the badge:
// centered on the corner it reads as hanging off the icon rather than sitting on it, and the
// sidebar row has no padding there to absorb the overhang.
const STATUS_BADGE_OFFSET = -4;
// Both glyphs must be EVEN. A centered glyph of size N sits at a (12 - N) / 2 offset — fractional
// for odd N, which the browser snaps to a device-pixel boundary and renders visibly off-center (an
// odd size measured 1.5 device px right and down at 3x, ~3px of asymmetry between opposite gaps).
// Even sizes divide the shell into whole pixels and land dead center with no correction.
const STATUS_BADGE_OFFSET = -5;
// Both glyphs must be EVEN. The shell's interior is 12pt (14 minus the 1pt ring on each side),
// so a centered glyph of size N sits at a (12 - N) / 2 offset — fractional for odd N, which the
// browser snaps to a device-pixel boundary and renders visibly off-center (an odd size measured
// 1.5 device px right and down at 3x, ~3px of asymmetry between opposite gaps). Even sizes
// divide the interior into whole pixels and land dead center with no correction.
//
// Lucide's circle-alert paints ~83% of its nominal size, so an alert of 8 draws a ~6.6pt circle
// against the 6pt dot — the two states read as the same-diameter disc. 8 is the closest even
// size to the exact match (6 / 0.83 = 7.2); 6 would undershoot the dot rather than meet it.
const STATUS_BADGE_DOT_SIZE = 6;
const STATUS_BADGE_ALERT_SIZE = 8;
// Lucide's circle-alert paints ~83% of its nominal size, so an alert of 10 draws a ~8.3pt circle
// against the 8pt dot — the two states read as the same-diameter disc.
const STATUS_BADGE_DOT_SIZE = 8;
const STATUS_BADGE_ALERT_SIZE = 10;
// Matches the workspace title's lineHeight (sidebar-workspace-row-content's
// workspaceBranchText) so the icon centers on the title rather than floating above it.
const LEADING_SLOT_HEIGHT = 20;
@@ -55,7 +55,6 @@ export function ProjectLeadingVisual({
iconDataUri,
statusBucket,
projectViewKey,
backdrop,
chevron = null,
showChevron = false,
isArchiving = false,
@@ -65,8 +64,6 @@ export function ProjectLeadingVisual({
/** Aggregate status of the project's workspaces; null when it shouldn't be surfaced. */
statusBucket: SidebarStateBucket | null;
projectViewKey: string;
/** The row's current background, so the status badge can knock out of it. */
backdrop: SurfaceBackdrop;
chevron?: "expand" | "collapse" | null;
showChevron?: boolean;
isArchiving?: boolean;
@@ -93,7 +90,6 @@ export function ProjectLeadingVisual({
displayName={displayName}
projectViewKey={projectViewKey}
statusBucket={statusBucket}
backdrop={backdrop}
/>
);
}
@@ -108,7 +104,6 @@ export function ProjectStatusIndicator({
displayName,
projectViewKey,
statusBucket,
backdrop,
loading = false,
testID,
}: {
@@ -116,15 +111,13 @@ export function ProjectStatusIndicator({
displayName: string;
projectViewKey: string;
statusBucket: SidebarStateBucket | null;
/** The row's current background, so the status badge can knock out of it. */
backdrop: SurfaceBackdrop;
loading?: boolean;
testID?: string;
}) {
const placeholderInitial = projectIconPlaceholderLabelFromDisplayName(displayName)
.charAt(0)
.toUpperCase();
// A row that's still starting up and a row that's working are both "busy", and at this size
// A row that's still starting up and a row that's working are both "busy", and at 8pt
// there's no room to draw the difference — so `loading` just resolves to the running bucket
// and they share one badge.
const badgeBucket = loading ? "running" : statusBucket;
@@ -147,11 +140,7 @@ export function ProjectStatusIndicator({
projectViewKey={projectViewKey}
/>
{badgeContent === null || badgeBucket === null ? null : (
<ProjectStatusBadge
content={badgeContent}
statusBucket={badgeBucket}
backdrop={backdrop}
/>
<ProjectStatusBadge content={badgeContent} statusBucket={badgeBucket} />
)}
</View>
</View>
@@ -161,17 +150,15 @@ export function ProjectStatusIndicator({
function ProjectStatusBadge({
content,
statusBucket,
backdrop,
}: {
content: ProjectStatusBadgeContent;
statusBucket: SidebarStateBucket;
backdrop: SurfaceBackdrop;
}) {
return (
<View
role="status"
accessibilityLabel={STATUS_BUCKET_LABELS[statusBucket]}
style={[styles.statusBadge, getStatusBadgeBackdropStyle(backdrop)]}
style={styles.statusBadge}
testID="project-status-badge"
>
{content.kind === "alert" ? (
@@ -183,17 +170,6 @@ function ProjectStatusBadge({
);
}
function getStatusBadgeBackdropStyle(backdrop: SurfaceBackdrop): ViewStyle {
switch (backdrop) {
case "surfaceSidebarHover":
return styles.statusBadgeOnSidebarHover;
case "surface2":
return styles.statusBadgeOnSurface2;
default:
return styles.statusBadgeOnSidebar;
}
}
function ProjectStatusDot({ bucket }: { bucket: ProjectStatusBadgeDotBucket }) {
if (bucket !== "running") {
return <View testID="project-status-dot" style={getStatusDotColorStyle(bucket)} />;
@@ -215,7 +191,8 @@ function ProjectIcon({
iconDataUri={iconDataUri}
initial={placeholderInitial}
projectViewKey={projectViewKey}
size={ICON_SIZE.md}
imageStyle={styles.projectIcon}
fallbackStyle={styles.projectIconFallback}
textStyle={styles.projectIconFallbackText}
/>
);
@@ -268,13 +245,24 @@ const styles = StyleSheet.create((theme) => {
width: theme.iconSize.md,
height: theme.iconSize.md,
},
projectIcon: {
width: "100%",
height: "100%",
borderRadius: theme.borderRadius.sm,
},
projectIconFallback: {
width: "100%",
height: "100%",
borderRadius: theme.borderRadius.sm,
alignItems: "center",
justifyContent: "center",
},
projectIconFallbackText: {
fontSize: 9,
},
// The shell the alert and dot statuses share. It straddles the icon's bottom-right corner
// (half in, half out) so the lettered project box stays readable. The shell is a knockout:
// it is filled with the colour of the row behind it, which is what makes the ring around the
// dot read as a gap in the icon rather than as a white halo drawn on top of it.
// (half in, half out) so the lettered project box stays readable, and its ring is
// sidebar-colored so the badge separates from the icon underneath rather than blending in.
statusBadge: {
position: "absolute",
right: STATUS_BADGE_OFFSET,
@@ -282,13 +270,13 @@ const styles = StyleSheet.create((theme) => {
width: STATUS_BADGE_SIZE,
height: STATUS_BADGE_SIZE,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.surface0,
borderWidth: STATUS_BADGE_BORDER_WIDTH,
borderColor: theme.colors.surfaceSidebar,
alignItems: "center",
justifyContent: "center",
overflow: "hidden",
},
statusBadgeOnSidebar: { backgroundColor: theme.colors.surfaceSidebar },
statusBadgeOnSidebarHover: { backgroundColor: theme.colors.surfaceSidebarHover },
statusBadgeOnSurface2: { backgroundColor: theme.colors.surface2 },
statusDotRunning: statusDot("running"),
statusDotFailed: statusDot("failed"),
statusDotAttention: statusDot("attention"),
@@ -0,0 +1,220 @@
import { useCallback, useMemo } from "react";
import { Text, View, type PressableStateCallbackType } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Settings2 } from "lucide-react-native";
import type { Theme } from "@/styles/theme";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { HostStatusDot } from "@/components/host-status-dot";
import { isWeb as platformIsWeb } from "@/constants/platform";
import { useAppSettings, type WorkspaceTitleSource } from "@/hooks/use-settings";
import { useHosts } from "@/runtime/host-runtime";
import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store";
const ThemedSettings2 = withUnistyles(Settings2);
const filterColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const GROUP_MODE_ITEMS: Array<{ value: SidebarGroupMode; label: string }> = [
{ value: "project", label: "Project" },
{ value: "status", label: "Status" },
];
const WORKSPACE_TITLE_SOURCE_ITEMS: Array<{ value: WorkspaceTitleSource; label: string }> = [
{ value: "title", label: "Title" },
{ value: "branch", label: "Branch name" },
];
interface DisplayPreferenceOption<Value extends string> {
value: Value;
label: string;
}
export function SidebarDisplayPreferencesMenu() {
const groupMode = useSidebarViewStore((state) => state.groupMode);
const hostFilters = useSidebarViewStore((state) => state.hostFilters);
const setGroupMode = useSidebarViewStore((state) => state.setGroupMode);
const toggleHostFilter = useSidebarViewStore((state) => state.toggleHostFilter);
const clearHostFilters = useSidebarViewStore((state) => state.clearHostFilters);
const hosts = useHosts();
const {
settings: { workspaceTitleSource },
updateSettings,
} = useAppSettings();
const handleSelectMode = useCallback(
(mode: SidebarGroupMode) => {
setGroupMode(mode);
},
[setGroupMode],
);
const handleWorkspaceTitleSourceSelect = useCallback(
(source: WorkspaceTitleSource) => {
void updateSettings({ workspaceTitleSource: source });
},
[updateSettings],
);
const triggerStyle = useCallback(
({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.trigger,
hovered && styles.triggerHovered,
],
[],
);
const showHostFilter = hosts.length > 1;
const allHostsSelected = hostFilters.length === 0;
return (
<DropdownMenu>
<DropdownMenuTrigger
style={triggerStyle}
accessibilityRole={platformIsWeb ? undefined : "button"}
accessibilityLabel="Display preferences"
testID="sidebar-display-preferences-menu"
>
<ThemedSettings2 size={14} uniProps={filterColorMapping} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={220} testID="sidebar-display-preferences-content">
<View style={styles.menuHeader}>
<Text style={styles.menuHeaderLabel}>Group by</Text>
</View>
{GROUP_MODE_ITEMS.map((item) => (
<DisplayPreferenceMenuItem
key={item.value}
item={item}
isSelected={groupMode === item.value}
testIDPrefix="sidebar-grouping"
onSelect={handleSelectMode}
/>
))}
{showHostFilter ? (
<>
<DropdownMenuSeparator />
<View style={styles.menuHeader}>
<Text style={styles.menuHeaderLabel}>Filter</Text>
</View>
<DropdownMenuItem
testID="sidebar-host-filter-all"
selected={allHostsSelected}
closeOnSelect={false}
onSelect={clearHostFilters}
>
All hosts
</DropdownMenuItem>
{hosts.map((host) => (
<HostFilterItem
key={host.serverId}
label={host.label?.trim() || host.serverId}
serverId={host.serverId}
selected={hostFilters.includes(host.serverId)}
onToggle={toggleHostFilter}
/>
))}
</>
) : null}
<DropdownMenuSeparator />
<View style={styles.menuHeader}>
<Text style={styles.menuHeaderLabel}>Workspace title</Text>
</View>
{WORKSPACE_TITLE_SOURCE_ITEMS.map((item) => (
<DisplayPreferenceMenuItem
key={item.value}
item={item}
isSelected={workspaceTitleSource === item.value}
testIDPrefix="sidebar-workspace-title-source"
onSelect={handleWorkspaceTitleSourceSelect}
/>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
function DisplayPreferenceMenuItem<Value extends string>({
item,
isSelected,
testIDPrefix,
onSelect,
}: {
item: DisplayPreferenceOption<Value>;
isSelected: boolean;
testIDPrefix: string;
onSelect: (value: Value) => void;
}) {
const handleSelect = useCallback(() => onSelect(item.value), [item.value, onSelect]);
return (
<DropdownMenuItem
testID={`${testIDPrefix}-${item.value}`}
selected={isSelected}
onSelect={handleSelect}
>
<Text style={styles.optionLabel}>{item.label}</Text>
</DropdownMenuItem>
);
}
function HostFilterItem({
label,
serverId,
selected,
onToggle,
}: {
label: string;
serverId: string;
selected: boolean;
onToggle: (serverId: string) => void;
}) {
const handleSelect = useCallback(() => onToggle(serverId), [serverId, onToggle]);
const leading = useMemo(
() => (
<View testID={`sidebar-host-filter-status-${serverId}`}>
<HostStatusDot serverId={serverId} />
</View>
),
[serverId],
);
return (
<DropdownMenuItem
testID={`sidebar-host-filter-${serverId}`}
selected={selected}
closeOnSelect={false}
leading={leading}
onSelect={handleSelect}
>
{label}
</DropdownMenuItem>
);
}
const styles = StyleSheet.create((theme) => ({
trigger: {
width: 28,
height: 28,
alignItems: "center",
justifyContent: "center",
borderRadius: theme.borderRadius.md,
},
triggerHovered: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
menuHeader: {
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
},
menuHeaderLabel: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foregroundMuted,
},
optionLabel: {
fontSize: theme.fontSize.sm,
},
}));
@@ -15,8 +15,9 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative } from "@/constants/platform";
import { useAppDiagnosticStore } from "@/diagnostics/store";
import { useKeyboardShortcutsAvailable } from "@/keyboard/availability";
import { useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { useSessionStore } from "@/stores/session-store";
@@ -77,10 +78,11 @@ function HostVersionHint({ host }: { host: HostProfile }) {
export function SidebarHelpMenu() {
const { t } = useTranslation();
const shortcutsAvailable = useKeyboardShortcutsAvailable();
const isCompactLayout = useIsCompactFormFactor();
const openAppDiagnostic = useAppDiagnosticStore((state) => state.open);
const setShortcutsDialogOpen = useKeyboardShortcutsStore((state) => state.setShortcutsDialogOpen);
const [open, setOpen] = useState(false);
const showKeyboardShortcuts = !isNative && !isCompactLayout;
const version = formatVersionWithPrefix(resolveAppVersion());
const hosts = useHosts();
@@ -126,7 +128,7 @@ export function SidebarHelpMenu() {
</Tooltip>
<DropdownMenuContent side="top" align="end" offset={8} width={280} testID="sidebar-help-menu">
<DropdownMenuLabel>{t("sidebar.help.sectionHelp")}</DropdownMenuLabel>
{shortcutsAvailable ? (
{showKeyboardShortcuts ? (
<DropdownMenuItem
testID="sidebar-help-shortcuts"
leading={shortcutsLeadingIcon}
@@ -1,27 +0,0 @@
import type { SurfaceBackdrop } from "@/styles/surface-backdrop";
/**
* Which surface a sidebar row is currently painting, so anything knocking out of it — the status
* badge on a project icon — can match.
*
* This restates the precedence of the row style arrays, which live in three separate renderers
* (`sidebar-workspace-list`, `sidebar-status-list`, `sidebar-workspace-row`). One copy of the rule
* here rather than three ternaries beside three stylesheets: drifting from them is how the badge
* ends up with a halo, and a halo in one grouping mode but not the other is worse than either.
*
* The pressed background is deliberately absent. It lives in a Pressable style callback and lasts
* as long as a finger is down, which is not long enough to see a mismatched ring.
*/
export function getSidebarRowBackdrop({
isDragging = false,
selected = false,
isHovered = false,
}: {
isDragging?: boolean;
selected?: boolean;
isHovered?: boolean;
}): SurfaceBackdrop {
if (isDragging) return "surface2";
if (selected || isHovered) return "surfaceSidebarHover";
return "surfaceSidebar";
}
@@ -19,6 +19,7 @@ import {
CircleDot,
CircleX,
} from "lucide-react-native";
import { DiffStat } from "@/components/diff-stat";
import { useToast } from "@/contexts/toast-context";
import { useMutation } from "@tanstack/react-query";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
@@ -39,15 +40,7 @@ import {
SidebarWorkspaceTrailingActionBase,
SidebarWorkspaceTrailingActionOverlay,
SidebarWorkspaceTrailingActionSlot,
sidebarWorkspaceRowStyles,
} from "@/components/sidebar/sidebar-workspace-row-content";
import { getSidebarRowBackdrop } from "@/components/sidebar/sidebar-row-backdrop";
import { selectWorkspaceScriptSummary } from "@/components/sidebar/workspace-meta-row";
import {
SidebarWorkspaceTrailingContent,
useSidebarWorkspaceTrailing,
type SidebarWorkspaceTrailing,
} from "@/components/sidebar/workspace-trailing";
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
import {
SidebarWorkspaceContextMenu,
@@ -128,7 +121,6 @@ export function SidebarStatusWorkspaceList({
projectIconByProjectViewKey,
hostBadgeByServerId,
})}
inStatusGroup={false}
shortcutNumber={statusShortcutIndex.get(workspace.workspaceKey) ?? null}
showShortcutBadge={showShortcutBadges}
canPin={supportsPinningByServerId.get(workspace.serverId) === true}
@@ -407,7 +399,6 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
canPin,
onToggleWorkspacePin,
reserveIdleStatusIndicatorSpace = true,
inStatusGroup = true,
onWorkspacePress,
}: {
workspace: SidebarWorkspaceEntry;
@@ -419,11 +410,6 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
canPin: boolean;
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
reserveIdleStatusIndicatorSpace?: boolean;
/**
* Whether the row sits under a status header, which is what it indents from. Pinned rows
* are a flat list under their own header and sit flush.
*/
inStatusGroup?: boolean;
onWorkspacePress?: () => void;
}) {
const activeWorkspaceSelection = useActiveWorkspaceSelection();
@@ -449,7 +435,6 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
canPin={canPin}
onToggleWorkspacePin={onToggleWorkspacePin}
reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace}
inStatusGroup={inStatusGroup}
onPress={handlePress}
/>
);
@@ -466,7 +451,6 @@ function StatusWorkspaceRowWithMenu({
canPin,
onToggleWorkspacePin,
reserveIdleStatusIndicatorSpace = true,
inStatusGroup = true,
onPress,
}: {
workspace: SidebarWorkspaceEntry;
@@ -479,11 +463,6 @@ function StatusWorkspaceRowWithMenu({
canPin: boolean;
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
reserveIdleStatusIndicatorSpace?: boolean;
/**
* Whether the row sits under a status header, which is what it indents from. Pinned rows
* are a flat list under their own header and sit flush.
*/
inStatusGroup?: boolean;
onPress: () => void;
}) {
const { t } = useTranslation();
@@ -605,7 +584,6 @@ function StatusWorkspaceRowWithMenu({
isPinned={isPinned}
onTogglePin={onTogglePin}
reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace}
inStatusGroup={inStatusGroup}
/>
<AdaptiveRenameModal
visible={isRenameOpen}
@@ -643,7 +621,6 @@ function StatusWorkspaceRowInner({
isPinned,
onTogglePin,
reserveIdleStatusIndicatorSpace = true,
inStatusGroup = true,
}: {
workspace: SidebarWorkspaceEntry;
hostBadge: HostBadgeModel | null;
@@ -666,17 +643,18 @@ function StatusWorkspaceRowInner({
isPinned?: boolean;
onTogglePin?: () => void;
reserveIdleStatusIndicatorSpace?: boolean;
/**
* Whether the row sits under a status header, which is what it indents from. Pinned rows
* are a flat list under their own header and sit flush.
*/
inStatusGroup?: boolean;
}) {
const isTouchPlatform = platformIsNative;
const trailing = useSidebarWorkspaceTrailing();
const isDesktop = !isTouchPlatform;
const scriptSummary = isDesktop ? selectWorkspaceScriptSummary(workspace.scripts) : null;
const showScriptsIcon = isDesktop && workspace.hasRunningScripts;
const hasRunningService = workspace.scripts.some(
(s) => s.lifecycle === "running" && (s.type ?? "service") === "service",
);
let scriptIconKind: "service" | "command" | null = null;
if (showScriptsIcon) {
scriptIconKind = hasRunningService ? "service" : "command";
}
const accessibilityState = useMemo(() => ({ selected }), [selected]);
@@ -685,24 +663,18 @@ function StatusWorkspaceRowInner({
{({ isHovered, contextMenuOpen, onContextMenuOpenChange, hoverHandlers }) => {
const showShortcut = showShortcutBadge && shortcutNumber !== null;
const {
showTrailing,
showDiffStat,
showKebab: showKebabInSlot,
showScrim,
renderSlot,
reserveSlotWidth,
} = resolveTrailingActionVisibility({
workspace,
trailing,
hasDiffStat: Boolean(workspace.diffStat),
hasArchiveAction: Boolean(onArchive),
isHovered,
isTouchPlatform,
showShortcut,
});
const workspaceRowStyle = getStatusWorkspaceRowStyle({
selected,
isHovered,
inStatusGroup,
});
const shouldRenderActionSlot = Boolean(onArchive || workspace.diffStat);
const workspaceRowStyle = getStatusWorkspaceRowStyle({ selected, isHovered });
return (
<View style={styles.workspaceRowContainer} {...hoverHandlers}>
<SidebarWorkspaceContextMenu
@@ -711,7 +683,7 @@ function StatusWorkspaceRowInner({
workspace={workspace}
leadingProjectName={projectName}
hostBadgeLabel={hostBadge?.label}
scriptSummary={scriptSummary}
scriptIconKind={scriptIconKind}
workspaceKey={workspace.workspaceKey}
onCopyPath={onCopyPath}
onCopyBranchName={onCopyBranchName}
@@ -737,22 +709,19 @@ function StatusWorkspaceRowInner({
hostBadge={hostBadge}
leadingProjectName={projectName}
leadingProjectIconDataUri={projectIconDataUri}
scriptSummary={scriptSummary}
backdrop={getSidebarRowBackdrop({ selected, isHovered })}
scriptIconKind={scriptIconKind}
isHovered={isHovered}
isLoading={isArchiving}
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace}
>
{renderSlot ? (
{shouldRenderActionSlot ? (
<StatusWorkspaceActionSlot
workspace={workspace}
trailing={trailing}
showBase={showTrailing}
showBase={showDiffStat}
showKebab={showKebabInSlot}
showScrim={showScrim}
reserveSlotWidth={reserveSlotWidth}
isPinned={isPinned}
onTogglePin={onTogglePin}
onCopyPath={onCopyPath}
@@ -777,11 +746,9 @@ function StatusWorkspaceRowInner({
function StatusWorkspaceActionSlot({
workspace,
trailing,
showBase,
showKebab,
showScrim,
reserveSlotWidth,
isPinned,
onTogglePin,
onCopyPath,
@@ -795,11 +762,9 @@ function StatusWorkspaceActionSlot({
archiveShortcutKeys,
}: {
workspace: SidebarWorkspaceEntry;
trailing: SidebarWorkspaceTrailing;
showBase: boolean;
showKebab: boolean;
showScrim: boolean;
reserveSlotWidth: boolean;
isPinned?: boolean;
onTogglePin?: () => void;
onCopyPath?: () => void;
@@ -813,9 +778,14 @@ function StatusWorkspaceActionSlot({
archiveShortcutKeys?: ShortcutKey[][] | null;
}) {
return (
<SidebarWorkspaceTrailingActionSlot reserveWidth={reserveSlotWidth}>
<SidebarWorkspaceTrailingActionSlot>
<SidebarWorkspaceTrailingActionBase visible={showBase}>
<SidebarWorkspaceTrailingContent workspace={workspace} trailing={trailing} />
{workspace.diffStat ? (
<DiffStat
additions={workspace.diffStat.additions}
deletions={workspace.diffStat.deletions}
/>
) : null}
</SidebarWorkspaceTrailingActionBase>
<SidebarWorkspaceTrailingActionOverlay visible={showKebab} scrim={showScrim}>
{showKebab && onArchive ? (
@@ -842,15 +812,12 @@ function StatusWorkspaceActionSlot({
function getStatusWorkspaceRowStyle({
selected,
isHovered,
inStatusGroup,
}: {
selected: boolean;
isHovered: boolean;
inStatusGroup: boolean;
}) {
return [
styles.workspaceRow,
inStatusGroup && sidebarWorkspaceRowStyles.rowIndented,
selected && styles.sidebarRowSelected,
isHovered && styles.workspaceRowHovered,
];
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
import { type PressableStateCallbackType } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Archive, CircleCheck, Copy, MoreVertical, Pencil, Pin, PinOff } from "lucide-react-native";
import { isWeb } from "@/constants/platform";
import { isNative, isWeb } from "@/constants/platform";
import { getForgePresentation, normalizeForge } from "@/git/forge";
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
import { useAppSettings } from "@/hooks/use-settings";
@@ -24,7 +24,6 @@ import {
import { Shortcut } from "@/components/ui/shortcut";
import { OpenInFileManagerMenuItem } from "@/workspace/open-in-file-manager/menu-item";
import { resolveSidebarWorkspaceAccessibilityLabel } from "@/components/sidebar/sidebar-workspace-title";
import type { WorkspaceScriptSummary } from "@/components/sidebar/workspace-meta-row";
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const foregroundMutedColorMapping = (theme: Theme) => ({
@@ -110,7 +109,7 @@ function SidebarWorkspaceMenuItems({
}: SidebarWorkspaceMenuItemsProps & { surface: MenuSurface }): ReactNode {
const { t } = useTranslation();
const archiveTrailing = useMemo(
() => (archiveShortcutKeys ? <Shortcut chord={archiveShortcutKeys} /> : null),
() => (archiveShortcutKeys && !isNative ? <Shortcut chord={archiveShortcutKeys} /> : null),
[archiveShortcutKeys],
);
@@ -205,7 +204,7 @@ export function SidebarWorkspaceMenu({
}: SidebarWorkspaceMenuProps) {
const { t } = useTranslation();
return (
<DropdownMenu compactMode="sheet">
<DropdownMenu>
<DropdownMenuTrigger
hitSlop={8}
style={triggerStyle}
@@ -215,7 +214,7 @@ export function SidebarWorkspaceMenu({
>
{renderTriggerIcon}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={260} sheetTitle={t("sidebar.workspace.actions.menu")}>
<DropdownMenuContent align="end" width={260}>
<SidebarWorkspaceMenuItems
surface="dropdown"
workspaceKey={workspaceKey}
@@ -249,7 +248,7 @@ export function SidebarWorkspaceContextMenu({
workspace,
leadingProjectName,
hostBadgeLabel,
scriptSummary,
scriptIconKind,
workspaceKey,
onCopyPath,
onCopyBranchName,
@@ -273,7 +272,7 @@ export function SidebarWorkspaceContextMenu({
workspace: SidebarWorkspaceEntry;
leadingProjectName?: string | null;
hostBadgeLabel?: string | null;
scriptSummary?: WorkspaceScriptSummary | null;
scriptIconKind?: "service" | "command" | null;
}
>) {
const {
@@ -292,7 +291,7 @@ export function SidebarWorkspaceContextMenu({
leadingProjectName,
hostBadgeLabel,
pullRequestLabel,
scriptLabel: scriptSummary ? t("workspace.status.scriptsAvailable") : null,
scriptLabel: scriptIconKind ? t("workspace.status.scriptsAvailable") : null,
});
return (
@@ -339,9 +338,6 @@ const styles = StyleSheet.create((theme) => ({
padding: 2,
borderRadius: 4,
marginLeft: 2,
// MoreVertical paints only around the center of its SVG. Keep the padded hit box, but
// pull the painted dots through that unused view-box space onto the trailing-content rail.
marginRight: -7,
},
triggerHovered: {
backgroundColor: theme.colors.surface2,
@@ -1,27 +1,38 @@
import { memo, useId, useMemo, useCallback, useState, type ReactNode } from "react";
import { Text, View, type ViewStyle } from "react-native";
import { memo, useCallback, useId, useMemo, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import {
Pressable,
Text,
View,
type GestureResponderEvent,
type PressableStateCallbackType,
type ViewStyle,
} from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg";
import { CircleAlert, Folder, FolderGit2, Monitor } from "lucide-react-native";
import { ProjectStatusIndicator } from "@/components/sidebar/project-leading-visual";
import type { SurfaceBackdrop } from "@/styles/surface-backdrop";
import { PulsingStatusDot } from "@/components/sidebar/pulsing-status-dot";
import {
WorkspaceMetaRow,
type WorkspaceScriptSummary,
} from "@/components/sidebar/workspace-meta-row";
CircleAlert,
Folder,
FolderGit2,
GitPullRequest,
Globe,
Monitor,
SquareTerminal,
} from "lucide-react-native";
import { HostBadge } from "@/components/sidebar/host-badge";
import { ProjectStatusIndicator } from "@/components/sidebar/project-leading-visual";
import { PulsingStatusDot } from "@/components/sidebar/pulsing-status-dot";
import { WorkspaceHoverCard } from "@/components/workspace-hover-card";
import type { HostBadgeModel } from "@/hosts/appearance";
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
import {
hasSidebarWorkspaceTrailing,
type SidebarWorkspaceTrailing,
} from "@/components/sidebar/workspace-trailing";
import { useAppSettings } from "@/hooks/use-settings";
import type { Theme } from "@/styles/theme";
import type { PrHint } from "@/git/use-pr-status-query";
import { getForgePresentation, normalizeForge } from "@/git/forge";
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
import { getStatusDotColor, isEmphasizedStatusDotBucket } from "@/utils/status-dot-color";
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
import { openExternalUrl } from "@/utils/open-external-url";
import { resolveSidebarWorkspacePrimaryLabel } from "@/components/sidebar/sidebar-workspace-title";
// The scrim spans more than the kebab so the fade starts left of the diff stat. Solid from
@@ -29,17 +40,6 @@ import { resolveSidebarWorkspacePrimaryLabel } from "@/components/sidebar/sideba
const SCRIM_WIDTH = 48;
const SCRIM_SOLID_OFFSET = "55%";
/**
* How far a workspace row sits inside the status header above it. Only status grouping
* indents: a project row carries an icon and its workspaces line up under it already, but a
* status header is just a label, so its rows need the offset to read as belonging to it.
*
* It is row padding rather than a margin on the list, because the row's hover and selected
* backgrounds have to keep spanning the group's full width. Indenting the container instead
* pulls the highlight in with the content and the row stops lining up with its header.
*/
const WORKSPACE_ROW_INDENT = 12;
const DEFAULT_STATUS_DOT_SIZE = 7;
const EMPHASIZED_STATUS_DOT_SIZE = 9;
const DEFAULT_STATUS_DOT_OFFSET = 0;
@@ -47,11 +47,18 @@ const EMPHASIZED_STATUS_DOT_OFFSET = -1;
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const amberColorMapping = (theme: Theme) => ({ color: theme.colors.palette.amber[500] });
const blueColorMapping = (theme: Theme) => ({ color: theme.colors.palette.blue[500] });
const greenColorMapping = (theme: Theme) => ({ color: theme.colors.palette.green[500] });
const redColorMapping = (theme: Theme) => ({ color: theme.colors.palette.red[500] });
const purpleColorMapping = (theme: Theme) => ({ color: theme.colors.palette.purple[500] });
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
const ThemedCircleAlert = withUnistyles(CircleAlert);
const ThemedMonitor = withUnistyles(Monitor);
const ThemedFolder = withUnistyles(Folder);
const ThemedFolderGit2 = withUnistyles(FolderGit2);
const ThemedGlobe = withUnistyles(Globe);
const ThemedSquareTerminal = withUnistyles(SquareTerminal);
/**
* react-native-svg's extractGradient reads stopColor off the child elements structurally,
@@ -80,6 +87,18 @@ const ThemedTrailingActionScrimSvg = withUnistyles(TrailingActionScrimSvg);
const scrimColorMapping = (theme: Theme) => ({ color: theme.colors.surfaceSidebarHover });
function pullRequestIconStyle({
hovered,
pressed,
}: PressableStateCallbackType & { hovered?: boolean }) {
return [
pullRequestIconStyles.pressable,
(Boolean(hovered) || pressed) && pullRequestIconStyles.pressableActive,
];
}
type SidebarWorkspaceScriptIconKind = "service" | "command";
export function SidebarWorkspaceRowFrame({
workspace,
isDragging = false,
@@ -131,8 +150,7 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
hostBadge,
leadingProjectName = null,
leadingProjectIconDataUri = null,
scriptSummary = null,
backdrop,
scriptIconKind = null,
isHovered,
isLoading,
isCreating = false,
@@ -146,9 +164,7 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
/** Hoisted rows use their project icon as the leading visual because no project row contains them. */
leadingProjectName?: string | null;
leadingProjectIconDataUri?: string | null;
scriptSummary?: WorkspaceScriptSummary | null;
/** The row's current background, so the project status badge can knock out of it. */
backdrop: SurfaceBackdrop;
scriptIconKind?: SidebarWorkspaceScriptIconKind | null;
isHovered: boolean;
isLoading: boolean;
isCreating?: boolean;
@@ -162,13 +178,17 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
settings: { workspaceTitleSource },
} = useAppSettings();
const workspaceLabel = resolveSidebarWorkspacePrimaryLabel({ workspace, workspaceTitleSource });
const hasTitleAccessory = Boolean(scriptIconKind || workspace.prHint || hostBadge);
const workspaceBranchTextStyle = useMemo(
() => [
styles.workspaceBranchText,
hasTitleAccessory
? styles.workspaceBranchTextWithAccessory
: styles.workspaceBranchTextFlexible,
isHovered && styles.workspaceBranchTextHovered,
isCreating && styles.workspaceBranchTextCreating,
],
[isHovered, isCreating],
[hasTitleAccessory, isHovered, isCreating],
);
return (
@@ -180,7 +200,6 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
displayName={leadingProjectName}
projectViewKey={workspace.projectViewKey}
statusBucket={workspace.statusBucket}
backdrop={backdrop}
loading={isLoading}
testID={`sidebar-row-project-icon-${workspace.workspaceKey}`}
/>
@@ -194,16 +213,23 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
)}
<View style={styles.workspaceContentColumn}>
<View style={styles.workspaceTitleRow}>
<Text style={workspaceBranchTextStyle} numberOfLines={1}>
{workspaceLabel}
</Text>
<View style={styles.workspaceTitleLeft}>
{workspace.prHint ? <PullRequestIcon hint={workspace.prHint} /> : null}
{hostBadge ? (
<HostBadge
serverId={hostBadge.serverId}
label={hostBadge.label}
color={hostBadge.color}
showLabel={hostBadge.showLabel}
/>
) : null}
<Text style={workspaceBranchTextStyle} numberOfLines={1}>
{workspaceLabel}
</Text>
{scriptIconKind ? <WorkspaceScriptIcon kind={scriptIconKind} /> : null}
</View>
<View style={sidebarWorkspaceRowStyles.rowRight}>{children}</View>
</View>
<WorkspaceMetaRow
hostBadge={hostBadge ?? null}
prHint={workspace.prHint}
scriptSummary={scriptSummary}
/>
</View>
</View>
{showShortcutBadge && shortcutNumber !== null ? (
@@ -215,6 +241,22 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
);
});
function WorkspaceScriptIcon({ kind }: { kind: SidebarWorkspaceScriptIconKind }) {
return (
<View
style={styles.workspaceTitleAccessory}
accessibilityLabel="Scripts available"
testID={kind === "service" ? "workspace-globe-icon" : "workspace-terminal-icon"}
>
{kind === "service" ? (
<ThemedGlobe size={12} uniProps={blueColorMapping} />
) : (
<ThemedSquareTerminal size={12} uniProps={blueColorMapping} />
)}
</View>
);
}
function WorkspaceStatusIndicator({
bucket,
workspaceKind,
@@ -326,6 +368,47 @@ function StatusDotOverlay({
return <View style={overlayStyle} />;
}
function PullRequestIcon({ hint }: { hint: PrHint }) {
const { t } = useTranslation();
const handlePress = useCallback(
(event: GestureResponderEvent) => {
event.stopPropagation();
void openExternalUrl(hint.url);
},
[hint.url],
);
const presentation = getForgePresentation(normalizeForge(hint.forge));
const handlePressIn = useCallback((event: GestureResponderEvent) => event.stopPropagation(), []);
return (
<Pressable
accessibilityRole="link"
accessibilityLabel={t("workspace.git.pr.accessibility.pullRequest", {
number: hint.number,
context: presentation.changeRequestContext,
})}
hitSlop={4}
onPressIn={handlePressIn}
onPress={handlePress}
style={pullRequestIconStyle}
>
<ThemedGitPullRequest size={12} uniProps={getPrIconUniMapping(hint.state)} />
</Pressable>
);
}
function getPrIconUniMapping(state: PrHint["state"]) {
switch (state) {
case "merged":
return purpleColorMapping;
case "open":
return greenColorMapping;
case "closed":
return redColorMapping;
}
}
function getStatusDotColorStyle(bucket: SidebarStateBucket) {
switch (bucket) {
case "needs_input":
@@ -341,12 +424,19 @@ function getStatusDotColorStyle(bucket: SidebarStateBucket) {
}
}
export const sidebarWorkspaceRowStyles = StyleSheet.create((theme) => ({
// Layered over the row's own padding rather than replacing it, so the indent stays one
// number here instead of being baked into a row style.
rowIndented: {
paddingLeft: theme.spacing[2] + WORKSPACE_ROW_INDENT,
const pullRequestIconStyles = StyleSheet.create(() => ({
pressable: {
height: 20,
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
},
pressableActive: {
opacity: 0.82,
},
}));
export const sidebarWorkspaceRowStyles = StyleSheet.create((theme) => ({
rowRight: {
flexDirection: "row",
alignItems: "flex-start",
@@ -372,16 +462,7 @@ export const sidebarWorkspaceRowStyles = StyleSheet.create((theme) => ({
lineHeight: 14,
},
hidden: { opacity: 0 },
// Stays position:relative at zero width so the absolutely-positioned kebab keeps
// anchoring to the same right edge whether or not the slot holds anything.
trailingActionSlot: {
position: "relative",
minHeight: 20,
flexShrink: 0,
alignItems: "flex-end",
justifyContent: "flex-start",
},
trailingActionSlotReserved: {
position: "relative",
minWidth: 18,
minHeight: 20,
@@ -417,68 +498,30 @@ export function SidebarWorkspaceShortcutBadge({ number }: { number: number }) {
* into each of them and immediately drifted — one call site kept hiding the diff after the
* others stopped.
*
* The trailing content survives the kebab on hover and fades under the scrim instead of
* blinking out. Touch has no hover, so its permanent kebab still hides the content outright
* rather than scrimming an unhovered row whose background doesn't match the gradient.
* The diff survives the kebab on hover and fades under the scrim instead of blinking out.
* Touch has no hover, so its permanent kebab still hides the diff outright rather than
* scrimming an unhovered row whose background doesn't match the gradient.
*/
export function resolveTrailingActionVisibility({
workspace,
trailing,
hasDiffStat,
hasArchiveAction,
isHovered,
isTouchPlatform,
showShortcut,
}: {
workspace: SidebarWorkspaceEntry;
trailing: SidebarWorkspaceTrailing;
hasDiffStat: boolean;
hasArchiveAction: boolean;
isHovered: boolean;
isTouchPlatform: boolean;
showShortcut: boolean;
}): {
showTrailing: boolean;
showKebab: boolean;
showScrim: boolean;
renderSlot: boolean;
reserveSlotWidth: boolean;
} {
const hasTrailing = hasSidebarWorkspaceTrailing({ workspace, trailing });
}): { showDiffStat: boolean; showKebab: boolean; showScrim: boolean } {
const showKebab = Boolean(hasArchiveAction && (isHovered || isTouchPlatform)) && !showShortcut;
const showTrailing = hasTrailing && !showShortcut && (isHovered || !showKebab);
return {
showTrailing,
showKebab,
// The scrim paints the row's own hover background, so it can only be drawn on a hovered
// row — over an unhovered one the gradient fades to the wrong color. That is also why
// touch, which shows the kebab without ever hovering, never gets one.
showScrim: showKebab && isHovered,
renderSlot: hasArchiveAction || hasTrailing,
// The slot only holds width for something that permanently sits in it. Trailing content
// does; the kebab only does on touch, where there is no hover for it to appear on and so
// no scrim to let it overlay the title. Everywhere else the width goes back to the title
// and the kebab fades in over its tail.
reserveSlotWidth: hasTrailing || (hasArchiveAction && isTouchPlatform),
};
const showDiffStat = hasDiffStat && !showShortcut && (isHovered || !showKebab);
return { showDiffStat, showKebab, showScrim: showDiffStat && showKebab };
}
export function SidebarWorkspaceTrailingActionSlot({
reserveWidth,
children,
}: {
reserveWidth: boolean;
children: ReactNode;
}) {
return (
<View
style={
reserveWidth
? sidebarWorkspaceRowStyles.trailingActionSlotReserved
: sidebarWorkspaceRowStyles.trailingActionSlot
}
>
{children}
</View>
);
export function SidebarWorkspaceTrailingActionSlot({ children }: { children: ReactNode }) {
return <View style={sidebarWorkspaceRowStyles.trailingActionSlot}>{children}</View>;
}
export function SidebarWorkspaceTrailingActionBase({
@@ -552,6 +595,13 @@ const styles = StyleSheet.create((theme) => ({
justifyContent: "space-between",
gap: theme.spacing[2],
},
workspaceTitleLeft: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
flex: 1,
minWidth: 0,
},
shortcutBadgeOverlay: {
position: "absolute",
top: 1,
@@ -590,17 +640,26 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.foregroundExtraMuted,
opacity: 0.3,
},
// The title owns the first line outright now that the host, change request and CI moved
// to the meta row, so it takes the full width the trailing slot leaves behind.
workspaceBranchText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: "400",
lineHeight: 20,
opacity: 0.76,
flex: 1,
minWidth: 0,
},
workspaceBranchTextFlexible: {
flex: 1,
},
workspaceBranchTextWithAccessory: {
flexShrink: 1,
},
workspaceTitleAccessory: {
height: 20,
flexShrink: 0,
alignItems: "center",
justifyContent: "center",
},
workspaceBranchTextCreating: {
opacity: 0.92,
},
@@ -8,6 +8,7 @@ import type { HostBadgeModel } from "@/hosts/appearance";
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
import type { DraggableListDragHandleProps } from "@/components/draggable-list.types";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { DiffStat } from "@/components/diff-stat";
import { AdaptiveRenameModal } from "@/components/rename-modal";
import { useToast } from "@/contexts/toast-context";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
@@ -32,13 +33,6 @@ import {
SidebarWorkspaceTrailingActionOverlay,
SidebarWorkspaceTrailingActionSlot,
} from "@/components/sidebar/sidebar-workspace-row-content";
import { getSidebarRowBackdrop } from "@/components/sidebar/sidebar-row-backdrop";
import { selectWorkspaceScriptSummary } from "@/components/sidebar/workspace-meta-row";
import {
SidebarWorkspaceTrailingContent,
useSidebarWorkspaceTrailing,
type SidebarWorkspaceTrailing,
} from "@/components/sidebar/workspace-trailing";
function noop() {}
@@ -263,7 +257,6 @@ function WorkspaceRowBody({
archiveShortcutKeys,
}: WorkspaceRowBodyProps) {
const isTouchPlatform = platformIsNative;
const trailing = useSidebarWorkspaceTrailing();
const draggable = Boolean(drag);
const interaction = useLongPressDragInteraction({
drag: drag ?? noop,
@@ -290,7 +283,14 @@ function WorkspaceRowBody({
<SidebarWorkspaceRowFrame workspace={workspace} isDragging={isDragging}>
{({ isHovered, contextMenuOpen, onContextMenuOpenChange, hoverHandlers }) => {
const isDesktop = !isTouchPlatform;
const scriptSummary = isDesktop ? selectWorkspaceScriptSummary(workspace.scripts) : null;
const showScriptsIcon = isDesktop && workspace.hasRunningScripts;
const hasRunningService = workspace.scripts.some(
(s) => s.lifecycle === "running" && (s.type ?? "service") === "service",
);
let scriptIconKind: "service" | "command" | null = null;
if (showScriptsIcon) {
scriptIconKind = hasRunningService ? "service" : "command";
}
const workspaceRowStyle = getWorkspaceRowStyle({ isDragging, selected, isHovered });
return (
<View
@@ -307,7 +307,7 @@ function WorkspaceRowBody({
onContextMenuOpenChange={onContextMenuOpenChange}
workspace={workspace}
hostBadgeLabel={hostBadge?.label}
scriptSummary={scriptSummary}
scriptIconKind={scriptIconKind}
workspaceKey={workspace.workspaceKey}
onCopyPath={onCopyPath}
onCopyBranchName={onCopyBranchName}
@@ -333,8 +333,7 @@ function WorkspaceRowBody({
<SidebarWorkspaceRowContent
workspace={workspace}
hostBadge={hostBadge}
scriptSummary={scriptSummary}
backdrop={getSidebarRowBackdrop({ isDragging, selected, isHovered })}
scriptIconKind={scriptIconKind}
isHovered={isHovered}
isLoading={isArchiving || isCreating}
isCreating={isCreating}
@@ -343,7 +342,6 @@ function WorkspaceRowBody({
>
<WorkspaceRowTrailingActions
workspace={workspace}
trailing={trailing}
isHovered={isHovered}
isTouchPlatform={isTouchPlatform}
isCreating={isCreating}
@@ -370,7 +368,6 @@ function WorkspaceRowBody({
function WorkspaceRowTrailingActions({
workspace,
trailing,
isHovered,
isTouchPlatform,
isCreating,
@@ -387,7 +384,6 @@ function WorkspaceRowTrailingActions({
onRename,
}: {
workspace: SidebarWorkspaceEntry;
trailing: SidebarWorkspaceTrailing;
isHovered: boolean;
isTouchPlatform: boolean;
isCreating: boolean;
@@ -406,29 +402,32 @@ function WorkspaceRowTrailingActions({
const { t } = useTranslation();
const showShortcut = showShortcutBadge && shortcutNumber !== null;
const {
showTrailing,
showDiffStat,
showKebab: showKebabInSlot,
showScrim,
renderSlot,
reserveSlotWidth,
} = resolveTrailingActionVisibility({
workspace,
trailing,
hasDiffStat: Boolean(workspace.diffStat),
hasArchiveAction: Boolean(onArchive),
isHovered,
isTouchPlatform,
showShortcut,
});
const shouldRenderActionSlot = Boolean(onArchive || workspace.diffStat);
return (
<>
{isCreating ? (
<Text style={styles.workspaceCreatingText}>{t("sidebar.workspace.status.creating")}</Text>
) : null}
{renderSlot ? (
<SidebarWorkspaceTrailingActionSlot reserveWidth={reserveSlotWidth}>
<SidebarWorkspaceTrailingActionBase visible={showTrailing}>
<SidebarWorkspaceTrailingContent workspace={workspace} trailing={trailing} />
{shouldRenderActionSlot ? (
<SidebarWorkspaceTrailingActionSlot>
<SidebarWorkspaceTrailingActionBase visible={showDiffStat}>
{workspace.diffStat ? (
<DiffStat
additions={workspace.diffStat.additions}
deletions={workspace.diffStat.deletions}
/>
) : null}
</SidebarWorkspaceTrailingActionBase>
<SidebarWorkspaceTrailingActionOverlay visible={showKebabInSlot} scrim={showScrim}>
{onArchive ? (
@@ -1,108 +0,0 @@
import Svg, { Circle, Path } from "react-native-svg";
import { withUnistyles } from "react-native-unistyles";
import type { Theme } from "@/styles/theme";
import { toFilledQuarters, type CheckSummary } from "./check-summary";
/**
* Geometry in a 24-unit box scaled to the icon size, so the stroke weight matches the
* lucide icons sitting next to it in the row. The outline radius leaves half the stroke
* inside the box on each side; the pie is inset far enough to read as a separate shape
* rather than a thick ring.
*/
const VIEW_BOX = 24;
const STROKE_WIDTH = 2;
const OUTLINE_RADIUS = (VIEW_BOX - STROKE_WIDTH) / 2;
const CENTER = VIEW_BOX / 2;
const PIE_RADIUS = 7;
const CHECK_PATH = "M8 12.5 L11 15.5 L16.5 9";
const CROSS_PATH = "M8.5 8.5 L15.5 15.5 M15.5 8.5 L8.5 15.5";
/**
* A change request's CI as one glyph: a check inside a circle when everything passed, a
* cross when anything failed, and a pie filling clockwise from twelve while the run is
* still going. The three share the circle so a row's CI always occupies the same shape and
* only its interior changes.
*/
function CheckIndicatorSvg({
summary,
size,
color,
}: {
summary: CheckSummary;
size: number;
color: string;
}) {
return (
<Svg width={size} height={size} viewBox={`0 0 ${VIEW_BOX} ${VIEW_BOX}`}>
<Circle
cx={CENTER}
cy={CENTER}
r={OUTLINE_RADIUS}
stroke={color}
strokeWidth={STROKE_WIDTH}
fill="none"
/>
{summary.state === "running" ? (
<Pie fraction={toFilledQuarters(summary)} color={color} />
) : (
<Path
d={summary.state === "passed" ? CHECK_PATH : CROSS_PATH}
stroke={color}
strokeWidth={STROKE_WIDTH}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
)}
</Svg>
);
}
/**
* A filled wedge from the centre, starting at twelve o'clock and sweeping clockwise. A full
* circle is drawn as a circle rather than an arc: an arc whose start and end points
* coincide has nothing to sweep between and paints nothing at all.
*/
function Pie({ fraction, color }: { fraction: number; color: string }) {
if (fraction <= 0) return null;
if (fraction >= 1) {
return <Circle cx={CENTER} cy={CENTER} r={PIE_RADIUS} fill={color} />;
}
const angle = fraction * 2 * Math.PI;
const endX = CENTER + PIE_RADIUS * Math.sin(angle);
const endY = CENTER - PIE_RADIUS * Math.cos(angle);
const largeArc = fraction > 0.5 ? 1 : 0;
const d = [
`M ${CENTER} ${CENTER}`,
`L ${CENTER} ${CENTER - PIE_RADIUS}`,
`A ${PIE_RADIUS} ${PIE_RADIUS} 0 ${largeArc} 1 ${endX} ${endY}`,
"Z",
].join(" ");
return <Path d={d} fill={color} />;
}
const ThemedCheckIndicatorSvg = withUnistyles(CheckIndicatorSvg);
/**
* Passing CI is the uneventful case and asks nothing of the reader, so it gets the quiet
* tier — green enough to name the state, close enough to the subtitle's weight that a
* sidebar full of them doesn't bury the one red row. Failure and a run still deciding keep
* real colour, because those are the two that want something from you.
*/
const COLOR_MAPPINGS: Record<CheckSummary["state"], (theme: Theme) => { color: string }> = {
passed: (theme) => ({ color: theme.colors.statusMutedSuccess }),
failed: (theme) => ({ color: theme.colors.statusMutedDanger }),
running: (theme) => ({ color: theme.colors.statusMutedWarning }),
};
export function CheckIndicator({ summary, size }: { summary: CheckSummary; size: number }) {
return (
<ThemedCheckIndicatorSvg
summary={summary}
size={size}
uniProps={COLOR_MAPPINGS[summary.state]}
/>
);
}
@@ -1,108 +0,0 @@
import { describe, expect, it } from "vitest";
import type { PrHint } from "@/git/pr-hint";
import { selectCheckSummary, toFilledQuarters } from "./check-summary";
function hint(overrides: Partial<PrHint>): PrHint {
return {
url: "https://github.com/o/r/pull/1",
number: 1,
state: "open",
forge: "github",
...overrides,
};
}
function checks(...statuses: string[]): PrHint["checks"] {
return statuses.map((status, index) => ({ name: `check-${index}`, status, url: null }));
}
describe("selectCheckSummary", () => {
it("returns nothing without a pull request", () => {
expect(selectCheckSummary(null)).toBeNull();
});
it("returns nothing when the pull request reports no checks", () => {
expect(selectCheckSummary(hint({ checksStatus: "none" }))).toBeNull();
expect(selectCheckSummary(hint({ checks: [] }))).toBeNull();
});
it("passes once every check is terminal and none failed", () => {
expect(selectCheckSummary(hint({ checks: checks("success", "success", "skipped") }))).toEqual({
state: "passed",
completed: 3,
total: 3,
});
});
it("counts terminal checks while others are still pending", () => {
expect(
selectCheckSummary(hint({ checks: checks("success", "skipped", "pending", "pending") })),
).toEqual({ state: "running", completed: 2, total: 4 });
});
it("fails as soon as one check fails, even mid-run", () => {
expect(
selectCheckSummary(hint({ checks: checks("success", "failure", "pending", "pending") })),
).toEqual({ state: "failed", completed: 4, total: 4 });
});
it("treats cancelled checks as finished rather than failed", () => {
expect(selectCheckSummary(hint({ checks: checks("success", "cancelled") }))).toEqual({
state: "passed",
completed: 2,
total: 2,
});
});
it("treats an unrecognized status as still running", () => {
expect(selectCheckSummary(hint({ checks: checks("success", "queued") }))).toEqual({
state: "running",
completed: 1,
total: 2,
});
});
it("falls back to the summary status when individual checks are absent", () => {
expect(selectCheckSummary(hint({ checksStatus: "pending" }))).toEqual({
state: "running",
completed: 0,
total: 1,
});
expect(selectCheckSummary(hint({ checksStatus: "failure" }))).toEqual({
state: "failed",
completed: 1,
total: 1,
});
});
it("prefers individual checks over the summary status", () => {
expect(
selectCheckSummary(hint({ checks: checks("failure"), checksStatus: "success" })),
).toEqual({ state: "failed", completed: 1, total: 1 });
});
});
describe("toFilledQuarters", () => {
it("fills nothing before the first quarter completes", () => {
expect(toFilledQuarters({ state: "running", completed: 0, total: 8 })).toBe(0);
expect(toFilledQuarters({ state: "running", completed: 1, total: 8 })).toBe(0);
});
it("fills in quarter steps", () => {
expect(toFilledQuarters({ state: "running", completed: 2, total: 8 })).toBe(0.25);
expect(toFilledQuarters({ state: "running", completed: 5, total: 10 })).toBe(0.5);
expect(toFilledQuarters({ state: "running", completed: 6, total: 8 })).toBe(0.75);
});
it("rounds down so an unfinished run never reads as complete", () => {
expect(toFilledQuarters({ state: "running", completed: 9, total: 10 })).toBe(0.75);
});
it("closes the pie only when every check is in", () => {
expect(toFilledQuarters({ state: "passed", completed: 10, total: 10 })).toBe(1);
});
it("treats an empty run as empty rather than dividing by zero", () => {
expect(toFilledQuarters({ state: "running", completed: 0, total: 0 })).toBe(0);
});
});
@@ -1,72 +0,0 @@
import { mapCheckStatus } from "@/git/pull-request-panel/check-status";
import type { PrHint } from "@/git/pr-hint";
/**
* What the sidebar reports about a change request's CI, reduced to the three things a row
* can show. "running" carries a fraction so the indicator can fill as checks land.
*/
export type CheckSummaryState = "passed" | "failed" | "running";
export interface CheckSummary {
state: CheckSummaryState;
/** Checks that have reached a terminal status. Equals `total` when state is not "running". */
completed: number;
total: number;
}
/**
* Failure wins over progress. A run that is half done with one failure already needs the
* user to open the hover card, and a ring still filling says "wait" — so the moment any
* check fails the summary is "failed" and the fraction stops mattering.
*
* `checks` is the richer source and the only one that can produce a fraction. Forges that
* report a summary without the individual checks fall back to `checksStatus`, which
* degrades the ring to empty-or-full rather than dropping the indicator.
*/
export function selectCheckSummary(hint: PrHint | null): CheckSummary | null {
if (!hint) return null;
return summarizeChecks(hint.checks) ?? summarizeChecksStatus(hint.checksStatus);
}
/** The pie has four pieces, so a run reports progress in quarters. */
const PIE_PIECES = 4;
/**
* How much of the pie is filled, as a fraction, snapped down to a quarter. Rounding down
* rather than to nearest means the indicator never claims a check finished that hasn't —
* nine of ten reads as three quarters, and only the tenth closes the circle.
*/
export function toFilledQuarters(summary: CheckSummary): number {
if (summary.total === 0) return 0;
return Math.floor((summary.completed / summary.total) * PIE_PIECES) / PIE_PIECES;
}
function summarizeChecks(checks: PrHint["checks"]): CheckSummary | null {
if (!checks || checks.length === 0) return null;
let completed = 0;
let failed = false;
for (const check of checks) {
const status = mapCheckStatus(check.status);
if (status === "failure") failed = true;
if (status !== "pending") completed += 1;
}
const total = checks.length;
if (failed) return { state: "failed", completed: total, total };
if (completed === total) return { state: "passed", completed: total, total };
return { state: "running", completed, total };
}
function summarizeChecksStatus(checksStatus: PrHint["checksStatus"]): CheckSummary | null {
switch (checksStatus) {
case "success":
return { state: "passed", completed: 1, total: 1 };
case "failure":
return { state: "failed", completed: 1, total: 1 };
case "pending":
return { state: "running", completed: 0, total: 1 };
default:
return null;
}
}
@@ -1,354 +0,0 @@
import { Fragment, useCallback, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Pressable, Text, View, type GestureResponderEvent } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import {
ExternalLink,
GitMerge,
GitPullRequest,
GitPullRequestClosed,
Globe,
Server,
SquareTerminal,
} from "lucide-react-native";
import { HOST_COLORS, type HostBadgeModel, type HostColor } from "@/hosts/appearance";
import { identityColor } from "@/styles/identity-colors";
import type { PrHint } from "@/git/pr-hint";
import { getForgePresentation, normalizeForge } from "@/git/forge";
import { openExternalUrl } from "@/utils/open-external-url";
import { useSidebarRowItems } from "@/components/sidebar/display-preferences/model";
import type { Theme } from "@/styles/theme";
import { CheckIndicator } from "./check-indicator";
import type { CheckSummary, CheckSummaryState } from "./check-summary";
import { selectMetaRowItems, type MetaRowItem } from "./meta-items";
import type { WorkspaceScriptSummary } from "./script-summary";
export { selectWorkspaceScriptSummary, type WorkspaceScriptSummary } from "./script-summary";
/**
* One size for every glyph on the line. The items are peers — host, change request, CI,
* running service — so a glyph that differs in size reads as a different rank.
*/
const META_ICON_SIZE = 12;
const ThemedServer = withUnistyles(Server);
const ThemedExternalLink = withUnistyles(ExternalLink);
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
const ThemedGitMerge = withUnistyles(GitMerge);
const ThemedGitPullRequestClosed = withUnistyles(GitPullRequestClosed);
const ThemedGlobe = withUnistyles(Globe);
const ThemedSquareTerminal = withUnistyles(SquareTerminal);
const mutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const foregroundMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const mergedMapping = (theme: Theme) => ({ color: theme.colors.statusMutedMerged });
const dangerMapping = (theme: Theme) => ({ color: theme.colors.statusMutedDanger });
// Theme-independent, so the mapping per host color is fixed for the life of the module. A
// fresh `uniProps` identity on every render makes withUnistyles re-subscribe each pass.
const HOST_ICON_MAPPINGS: Record<HostColor, (theme: Theme) => { color: string }> = (() => {
const byColor = {} as Record<HostColor, (theme: Theme) => { color: string }>;
for (const color of HOST_COLORS) {
byColor[color] = color === "none" ? mutedMapping : () => ({ color: identityColor(color) });
}
return byColor;
})();
/**
* The subtitle under a workspace title: which host it lives on, its change request, that
* change request's CI, and any running service. Everything the row knows about a workspace
* that isn't its name.
*
* Items are peers separated by a dot rather than ranked by chrome. The host used to be a
* tinted pill on the title line, which made it the loudest thing in a row whose subject is
* the title; flattening it lets the whole line read as one piece of secondary text and
* leaves color to mean status.
*/
export function WorkspaceMetaRow({
hostBadge,
prHint,
scriptSummary,
}: {
hostBadge: HostBadgeModel | null;
prHint: PrHint | null;
scriptSummary: WorkspaceScriptSummary | null;
}) {
const visible = useSidebarRowItems();
const items = selectMetaRowItems({
hasHostBadge: hostBadge !== null,
prHint,
scriptSummary,
visible,
});
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}>·</Text> : null}
<MetaItemNode item={item} hostBadge={hostBadge} />
</Fragment>
))}
</View>
);
}
function MetaItemNode({
item,
hostBadge,
}: {
item: MetaRowItem;
hostBadge: HostBadgeModel | null;
}): ReactNode {
if (item.kind === "host") {
return hostBadge ? <HostItem hostBadge={hostBadge} /> : null;
}
if (item.kind === "changeRequest") {
return <PullRequestItem hint={item.hint} />;
}
if (item.kind === "checks") {
return <ChecksItem summary={item.summary} />;
}
return <ScriptItem summary={item.summary} />;
}
function HostItem({ hostBadge }: { hostBadge: HostBadgeModel }) {
return (
<View
style={styles.hostItem}
testID={`sidebar-host-badge-${hostBadge.serverId}`}
accessibilityLabel={hostBadge.label}
>
<ThemedServer size={META_ICON_SIZE} uniProps={HOST_ICON_MAPPINGS[hostBadge.color]} />
{hostBadge.showLabel ? (
<Text style={styles.hostLabel} numberOfLines={1}>
{hostBadge.label}
</Text>
) : null}
</View>
);
}
/**
* The only thing on the line that navigates, so it is the only thing that answers the
* pointer: it brightens to full foreground and its icon becomes an external-link arrow,
* saying where the click goes before you spend it.
*
* `onHoverIn`/`onHoverOut` with local state is safe here despite the usual rule in
* docs/hover.md — the state never leaves this Pressable, and nothing pressable is nested
* inside it, so there is no second hover state machine to fight. Both icons are the same
* size, so the swap can't move the target out from under the cursor.
*/
function PullRequestItem({ hint }: { hint: PrHint }) {
const { t } = useTranslation();
const [isHovered, setIsHovered] = useState(false);
const presentation = getForgePresentation(normalizeForge(hint.forge));
const handlePress = useCallback(
(event: GestureResponderEvent) => {
event.stopPropagation();
void openExternalUrl(hint.url);
},
[hint.url],
);
const handlePressIn = useCallback((event: GestureResponderEvent) => event.stopPropagation(), []);
const handleHoverIn = useCallback(() => setIsHovered(true), []);
const handleHoverOut = useCallback(() => setIsHovered(false), []);
const Icon = isHovered ? ThemedExternalLink : PR_ICONS[hint.state];
return (
<Pressable
accessibilityRole="link"
accessibilityLabel={t("workspace.git.pr.accessibility.pullRequest", {
number: hint.number,
context: presentation.changeRequestContext,
})}
hitSlop={4}
onPressIn={handlePressIn}
onPress={handlePress}
onHoverIn={handleHoverIn}
onHoverOut={handleHoverOut}
style={pressableItemStyle}
>
<Icon
size={META_ICON_SIZE}
uniProps={isHovered ? foregroundMapping : PR_COLOR_MAPPINGS[hint.state]}
/>
<Text style={isHovered ? styles.prTextHovered : styles.prText} numberOfLines={1}>
{hint.number}
{/* An open change request is the unremarkable case and says nothing extra; a merged
or closed one is why the row still looks like it has work in it. */}
{hint.state === "open" ? "" : ` ${t(PR_STATE_LABEL_KEYS[hint.state])}`}
</Text>
</Pressable>
);
}
/**
* Every state says its own name. A tick on its own has no subject — the reader has to already
* know the glyph is about CI to read it at all, and sitting after a host and a change request it
* reads as something left over. One short word costs less than that ambiguity.
*
* The words are the outcome, not the noun: "passed" against "failed", both naming how a finished
* run ended, "running" for one still deciding. Repeating the subject on every healthy row —
* "checks passed" — is the noise worth avoiding; naming the outcome is not.
*/
function ChecksItem({ summary }: { summary: CheckSummary }) {
const { t } = useTranslation();
return (
<View
style={styles.item}
accessibilityLabel={t(CHECK_STATE_ACCESSIBLE_KEYS[summary.state])}
testID={`sidebar-workspace-checks-${summary.state}`}
>
<CheckIndicator summary={summary} size={META_ICON_SIZE} />
<Text style={checksTextStyle(summary.state)} numberOfLines={1}>
{t(CHECK_STATE_LABEL_KEYS[summary.state])}
</Text>
</View>
);
}
const CHECK_STATE_LABEL_KEYS = {
passed: "workspace.git.pr.checksSummary.passedLabel",
failed: "workspace.git.pr.checksSummary.failedLabel",
running: "workspace.git.pr.checksSummary.runningLabel",
} as const;
const CHECK_STATE_ACCESSIBLE_KEYS = {
passed: "workspace.git.pr.checksSummary.passedAccessible",
failed: "workspace.git.pr.checksSummary.failedAccessible",
running: "workspace.git.pr.checksSummary.runningAccessible",
} as const;
function ScriptItem({ summary }: { summary: WorkspaceScriptSummary }) {
const { t } = useTranslation();
return (
<View
style={styles.item}
accessibilityLabel={t("workspace.status.scriptsAvailable")}
testID={summary.kind === "service" ? "workspace-globe-icon" : "workspace-terminal-icon"}
>
{summary.kind === "service" ? (
<ThemedGlobe size={META_ICON_SIZE} uniProps={successMapping} />
) : (
<ThemedSquareTerminal size={META_ICON_SIZE} uniProps={successMapping} />
)}
{summary.port !== null ? <Text style={styles.scriptPort}>:{summary.port}</Text> : null}
</View>
);
}
const successMapping = (theme: Theme) => ({ color: theme.colors.statusMutedSuccess });
const PR_ICONS = {
open: ThemedGitPullRequest,
merged: ThemedGitMerge,
closed: ThemedGitPullRequestClosed,
} as const;
// Same three colours the hover card gives the same three states — a change request has one
// identity, and it shouldn't shift when you hover the row that names it.
const PR_COLOR_MAPPINGS = {
open: successMapping,
merged: mergedMapping,
closed: dangerMapping,
} as const;
const PR_STATE_LABEL_KEYS = {
merged: "workspace.git.pr.states.merged",
closed: "workspace.git.pr.states.closed",
} as const;
function pressableItemStyle({ pressed }: { pressed: boolean }) {
return [styles.item, pressed && styles.itemPressed];
}
const styles = StyleSheet.create((theme) => ({
row: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1.5],
minWidth: 0,
},
item: {
flexDirection: "row",
alignItems: "center",
gap: 3,
minWidth: 0,
flexShrink: 0,
},
// The host is the only unbounded item, so it consumes the remaining width and gives way
// before the bounded change request, CI, and service items.
hostItem: {
flexDirection: "row",
alignItems: "center",
gap: 3,
minWidth: 0,
flexShrink: 1,
},
itemPressed: {
opacity: 0.82,
},
separator: {
color: theme.colors.foregroundExtraMuted,
fontSize: theme.fontSize.xs,
lineHeight: 16,
flexShrink: 0,
},
hostLabel: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
lineHeight: 16,
flexShrink: 1,
minWidth: 0,
},
scriptPort: {
color: theme.colors.statusMutedSuccess,
fontSize: theme.fontSize.xs,
lineHeight: 16,
flexShrink: 0,
},
prText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
lineHeight: 16,
flexShrink: 0,
},
prTextHovered: {
color: theme.colors.foreground,
fontSize: theme.fontSize.xs,
lineHeight: 16,
flexShrink: 0,
},
// Matches the indicator — see COLOR_MAPPINGS in check-indicator.tsx.
checksTextPassed: {
color: theme.colors.statusMutedSuccess,
fontSize: theme.fontSize.xs,
lineHeight: 16,
flexShrink: 0,
},
checksTextFailed: {
color: theme.colors.statusMutedDanger,
fontSize: theme.fontSize.xs,
lineHeight: 16,
flexShrink: 0,
},
checksTextRunning: {
color: theme.colors.statusMutedWarning,
fontSize: theme.fontSize.xs,
lineHeight: 16,
flexShrink: 0,
},
}));
// Read inside render, never into a module-scope table: touching `styles.x` at module load
// materializes the Unistyles proxy before the persisted theme has resolved, and the style
// freezes on whatever theme happened to be active first. See docs/unistyles.md.
function checksTextStyle(state: CheckSummaryState) {
if (state === "failed") return styles.checksTextFailed;
if (state === "running") return styles.checksTextRunning;
return styles.checksTextPassed;
}
@@ -1,76 +0,0 @@
import { describe, expect, it } from "vitest";
import type { PrHint } from "@/git/pr-hint";
import { DEFAULT_SIDEBAR_ROW_ITEMS } from "@/components/sidebar/display-preferences/row-items";
import { selectMetaRowItems } from "./meta-items";
import type { WorkspaceScriptSummary } from "./script-summary";
const PR_HINT: PrHint = {
url: "https://github.com/acme/app/pull/7",
number: 7,
state: "open",
forge: "github",
checksStatus: "success",
};
const SCRIPT: WorkspaceScriptSummary = { kind: "service", port: 3000 };
function select(overrides: Partial<Parameters<typeof selectMetaRowItems>[0]> = {}) {
return selectMetaRowItems({
hasHostBadge: true,
prHint: PR_HINT,
scriptSummary: SCRIPT,
visible: DEFAULT_SIDEBAR_ROW_ITEMS,
...overrides,
});
}
const kinds = (items: ReturnType<typeof selectMetaRowItems>) => items.map((item) => item.kind);
describe("selectMetaRowItems", () => {
it("reads identity, then the change, then its state, then what is running", () => {
expect(kinds(select())).toEqual(["host", "changeRequest", "checks", "scripts"]);
});
it("omits what the workspace does not have", () => {
expect(kinds(select({ hasHostBadge: false, prHint: null, scriptSummary: null }))).toEqual([]);
});
it.each([
["changeRequest", ["host", "checks", "scripts"]],
["checks", ["host", "changeRequest", "scripts"]],
["scripts", ["host", "changeRequest", "checks"]],
] as const)("drops %s and only %s when it is switched off", (item, expected) => {
expect(kinds(select({ visible: { ...DEFAULT_SIDEBAR_ROW_ITEMS, [item]: false } }))).toEqual(
expected,
);
});
it("keeps checks when the change request is hidden", () => {
// Each toggle answers for itself. A ticked "checks" that drew nothing because a different
// switch was off would be lying about its own state.
const items = select({
visible: { ...DEFAULT_SIDEBAR_ROW_ITEMS, changeRequest: false, checks: true },
});
expect(kinds(items)).toEqual(["host", "checks", "scripts"]);
});
it("draws nothing for checks when both are off", () => {
const items = select({
visible: { ...DEFAULT_SIDEBAR_ROW_ITEMS, changeRequest: false, checks: false },
});
expect(kinds(items)).toEqual(["host", "scripts"]);
});
it("carries the resolved check summary rather than the raw hint", () => {
const checks = select().find((item) => item.kind === "checks");
expect(checks).toEqual({
kind: "checks",
summary: { state: "passed", completed: 1, total: 1 },
});
});
it("keeps a change request whose forge reports no checks", () => {
const items = select({ prHint: { ...PR_HINT, checksStatus: undefined } });
expect(kinds(items)).toEqual(["host", "changeRequest", "scripts"]);
});
});
@@ -1,59 +0,0 @@
import type { PrHint } from "@/git/pr-hint";
import type { SidebarRowItems } from "@/components/sidebar/display-preferences/row-items";
import { selectCheckSummary, type CheckSummary } from "./check-summary";
import type { WorkspaceScriptSummary } from "./script-summary";
/**
* What ends up on the line under a workspace title, in the order it is read: where the
* workspace lives, what change it belongs to, whether that change is passing, and what it is
* running. Identity first, then the work, then the work's state.
*/
export type MetaRowItem =
| { kind: "host" }
| { kind: "changeRequest"; hint: PrHint }
| { kind: "checks"; summary: CheckSummary }
| { kind: "scripts"; summary: WorkspaceScriptSummary };
/**
* Which peers a row should draw, given what it knows and what the user left switched on.
*
* Kept out of the component because this — not the markup — is the part with rules in it: every
* toggle answers for itself, so a row can end up showing checks with no change request beside
* them, and CI resolves from the hint even when the hint itself is not drawn.
*
* The host is filtered upstream, where the badge map is built: a host that should show nothing
* has no badge to hand down, so by the time a row sees one it is meant to be drawn.
*/
export function selectMetaRowItems(input: {
hasHostBadge: boolean;
prHint: PrHint | null;
scriptSummary: WorkspaceScriptSummary | null;
visible: SidebarRowItems;
}): MetaRowItem[] {
const { hasHostBadge, prHint, scriptSummary, visible } = input;
const items: MetaRowItem[] = [];
if (hasHostBadge) {
items.push({ kind: "host" });
}
if (prHint && visible.changeRequest) {
items.push({ kind: "changeRequest", hint: prHint });
}
// Independent of the change request, even though checks are read off one. Tying them together
// meant the checks toggle could sit ticked while nothing was drawn, which is a switch that lies
// about its own state. Showing checks without the change request beside them is the stranger
// combination, but it is the one you asked for and it is what you get.
if (visible.checks) {
const summary = selectCheckSummary(prHint);
if (summary) {
items.push({ kind: "checks", summary });
}
}
if (scriptSummary && visible.scripts) {
items.push({ kind: "scripts", summary: scriptSummary });
}
return items;
}
@@ -1,66 +0,0 @@
import { describe, expect, it } from "vitest";
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
import { selectWorkspaceScriptSummary } from "./script-summary";
type Script = SidebarWorkspaceEntry["scripts"][number];
function script(overrides: Partial<Script>): Script {
return {
scriptName: "dev",
type: "service",
hostname: "localhost",
port: null,
proxyUrl: null,
lifecycle: "running",
health: null,
exitCode: null,
terminalId: null,
...overrides,
} as Script;
}
describe("selectWorkspaceScriptSummary", () => {
it("returns nothing when no script is running", () => {
expect(selectWorkspaceScriptSummary([])).toBeNull();
expect(selectWorkspaceScriptSummary([script({ lifecycle: "stopped", port: 3000 })])).toBeNull();
});
it("reports a running service with its port", () => {
expect(selectWorkspaceScriptSummary([script({ port: 3000 })])).toEqual({
kind: "service",
port: 3000,
});
});
it("reports a service that has not bound a port yet", () => {
expect(selectWorkspaceScriptSummary([script({ port: null })])).toEqual({
kind: "service",
port: null,
});
});
it("falls back to a running command when no service runs", () => {
expect(selectWorkspaceScriptSummary([script({ type: "script", port: null })])).toEqual({
kind: "command",
port: null,
});
});
it("prefers a running service over a running command regardless of order", () => {
expect(
selectWorkspaceScriptSummary([
script({ scriptName: "test", type: "script" }),
script({ scriptName: "dev", type: "service", port: 8080 }),
]),
).toEqual({ kind: "service", port: 8080 });
});
it("ignores stopped services when a command is running", () => {
expect(
selectWorkspaceScriptSummary([
script({ scriptName: "dev", type: "service", port: 3000, lifecycle: "stopped" }),
script({ scriptName: "test", type: "script" }),
]),
).toEqual({ kind: "command", port: null });
});
});
@@ -1,26 +0,0 @@
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
/**
* The one running script a row reports. A workspace can run several; the row has space for
* one item, and a service (something reachable on a port) is the one worth surfacing, so a
* running service outranks a running command.
*/
export interface WorkspaceScriptSummary {
kind: "service" | "command";
/** Only a service binds a port, and only then when the daemon has observed it. */
port: number | null;
}
export function selectWorkspaceScriptSummary(
scripts: SidebarWorkspaceEntry["scripts"],
): WorkspaceScriptSummary | null {
let command: WorkspaceScriptSummary | null = null;
for (const script of scripts) {
if (script.lifecycle !== "running") continue;
if ((script.type ?? "service") === "service") {
return { kind: "service", port: script.port };
}
command ??= { kind: "command", port: null };
}
return command;
}
@@ -1,80 +0,0 @@
import { Text } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { DiffStat } from "@/components/diff-stat";
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-compact-time-ago";
export type { SidebarWorkspaceTrailing };
/**
* The slot to the right of a workspace title. Three renderers behind one preference, so
* every row renderer asks the same question and the kebab overlay geometry stays identical
* no matter which one is showing.
*
* "none" exists because the slot is the only thing competing with the title for width, and
* a user who never reads the diff would rather have the characters.
*/
export function useSidebarWorkspaceTrailing(): SidebarWorkspaceTrailing {
const {
settings: { sidebarWorkspaceTrailing },
} = useAppSettings();
return sidebarWorkspaceTrailing;
}
/** Whether the slot has anything to draw for this workspace under the current preference. */
export function hasSidebarWorkspaceTrailing({
workspace,
trailing,
}: {
workspace: SidebarWorkspaceEntry;
trailing: SidebarWorkspaceTrailing;
}): boolean {
if (trailing === "diff") return workspace.diffStat !== null;
if (trailing === "timestamp") return workspace.statusEnteredAt !== null;
return false;
}
export function SidebarWorkspaceTrailingContent({
workspace,
trailing,
}: {
workspace: SidebarWorkspaceEntry;
trailing: SidebarWorkspaceTrailing;
}) {
if (trailing === "diff" && workspace.diffStat) {
return (
<DiffStat additions={workspace.diffStat.additions} deletions={workspace.diffStat.deletions} />
);
}
if (trailing === "timestamp" && workspace.statusEnteredAt) {
return <WorkspaceTimestamp enteredAt={workspace.statusEnteredAt} />;
}
return null;
}
/**
* Its own component so the clock stops here. `useCompactTimeAgo` holds state, and state
* 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 }: { enteredAt: Date }) {
const label = useCompactTimeAgo(enteredAt);
return (
<Text style={styles.timestamp} numberOfLines={1} testID="sidebar-workspace-timestamp">
{label}
</Text>
);
}
const styles = StyleSheet.create((theme) => ({
timestamp: {
height: 20,
lineHeight: 20,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
flexShrink: 0,
},
}));
@@ -34,7 +34,6 @@ export interface TerminalEmulatorProps {
fontFamily?: string;
fontSize?: number;
keyboardInset?: number;
isKeyboardVisible?: boolean;
swipeGesturesEnabled?: boolean;
onSwipeLeft?: () => void;
onSwipeRight?: () => void;
@@ -168,7 +168,6 @@ function NativeTerminalEmulator({
fontFamily,
fontSize,
keyboardInset = 0,
isKeyboardVisible = false,
initialSnapshot = null,
onInput,
onTerminalKey,
@@ -994,7 +993,6 @@ function NativeTerminalEmulator({
{terminalGrid}
<TerminalInput
ref={inputRef}
isKeyboardVisible={isKeyboardVisible}
onFocus={handleTerminalFocus}
onInput={handleTerminalInput}
onTerminalKey={handleNativeTerminalKey}
@@ -106,7 +106,6 @@ interface TerminalEmulatorProps {
fontFamily?: string;
fontSize?: number;
keyboardInset?: number;
isKeyboardVisible?: boolean;
swipeGesturesEnabled?: boolean;
onSwipeLeft?: () => void;
onSwipeRight?: () => void;
+11 -13
View File
@@ -222,7 +222,6 @@ export function TerminalPane({
enabled: isMobile,
});
const [keyboardInset, setKeyboardInset] = useState(0);
const [isKeyboardVisible, setIsKeyboardVisible] = useState(false);
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
@@ -299,8 +298,8 @@ export function TerminalPane({
}, [keyboardInset, refreshClipboardAvailability]);
useEffect(() => {
setIsKeyboardToggleVisible(isKeyboardVisible);
}, [isKeyboardVisible]);
setIsKeyboardToggleVisible(keyboardInset > 0);
}, [keyboardInset]);
const handleSelectionChange = useCallback((nextHasSelection: boolean) => {
setHasSelection(nextHasSelection);
@@ -419,13 +418,12 @@ export function TerminalPane({
);
}, [clearKeyboardRefitTimeouts, requestTerminalReflow]);
const handleKeyboardChange = useCallback(
(nextShift: number) => {
setKeyboardInset(isMobile ? nextShift : 0);
setIsKeyboardVisible(nextShift > 0);
const handleKeyboardInsetChange = useCallback(
(nextInset: number) => {
setKeyboardInset(nextInset);
pulseKeyboardRefits();
},
[isMobile, pulseKeyboardRefits],
[pulseKeyboardRefits],
);
useEffect(() => {
@@ -433,14 +431,14 @@ export function TerminalPane({
}, [clearKeyboardRefitTimeouts]);
useAnimatedReaction(
() => Math.round(keyboardShift.value),
() => (isMobile ? Math.round(keyboardShift.value) : 0),
(next, prev) => {
if (next === prev) {
return;
}
runOnJS(handleKeyboardChange)(next);
runOnJS(handleKeyboardInsetChange)(next);
},
[handleKeyboardChange],
[isMobile, handleKeyboardInsetChange],
);
useEffect(() => {
@@ -844,8 +842,9 @@ export function TerminalPane({
setIsKeyboardToggleVisible(true);
emulatorRef.current?.showKeyboard();
requestTerminalFocus();
requestTerminalReflow();
}, [isKeyboardToggleVisible, requestTerminalReflow]);
}, [isKeyboardToggleVisible, requestTerminalFocus, requestTerminalReflow]);
const handleInputModeChange = useCallback((state: TerminalInputModeState) => {
inputModeRef.current = state;
@@ -1015,7 +1014,6 @@ export function TerminalPane({
fontFamily={terminalFontFamily}
fontSize={settings.codeFontSize}
keyboardInset={keyboardInset}
isKeyboardVisible={isKeyboardVisible}
swipeGesturesEnabled={swipeGesturesEnabled}
initialSnapshot={initialSnapshot}
onRendererReadyChange={handleRendererReadyChange}
@@ -114,7 +114,6 @@ export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps)
{children}
<IsolatedBottomSheetModal
ref={bottomSheetRef}
contextBridge={null}
snapPoints={snapPoints}
index={0}
enableDynamicSizing={false}
@@ -12,15 +12,6 @@ import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
// drift apart.
export const TREE_INDENT_PER_LEVEL = 16;
export const WORKSPACE_FILE_ROW_VERTICAL_PADDING = SPACING[1.5];
export const WORKSPACE_TREE_ICON_SIZE = 16;
export const WORKSPACE_TREE_LOADING_ICON_SIZE = 14;
export const WORKSPACE_TREE_ICON_LABEL_GAP = SPACING[2];
/**
* Trailing glyph rail shared with the explorer X and Changes options chevron.
* The extra 2px is optical: text ink ends inside its layout box, while the
* header icons' strokes extend to theirs.
*/
export const WORKSPACE_FILE_ROW_TRAILING_PADDING = SPACING[4] + 2;
/** Left padding for a tree row at `depth`. Shared by folder rows and file headers
* in the Changes tree so their indentation can't drift apart. */
@@ -28,9 +19,7 @@ export function treeRowPaddingLeft(depth: number): number {
return SPACING[3] + depth * TREE_INDENT_PER_LEVEL;
}
const foregroundExtraMutedIconColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundExtraMuted,
});
const foregroundMutedIconColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const ThemedChevronRight = withUnistyles(ChevronRight);
@@ -63,17 +52,8 @@ export function TreeIndentGuides({ depth }: { depth: number }) {
/** Rotating disclosure chevron for a directory row (points right; rotates down when expanded). */
export function TreeChevron({ expanded }: { expanded: boolean }) {
return (
<View
style={
expanded
? [styles.chevron, styles.chevronExpanded]
: [styles.chevron, styles.chevronCollapsed]
}
>
<ThemedChevronRight
size={WORKSPACE_TREE_ICON_SIZE}
uniProps={foregroundExtraMutedIconColorMapping}
/>
<View style={expanded ? [styles.chevron, styles.chevronExpanded] : styles.chevron}>
<ThemedChevronRight size={16} uniProps={foregroundMutedIconColorMapping} />
</View>
);
}
@@ -87,19 +67,13 @@ const styles = StyleSheet.create((theme: Theme) => ({
backgroundColor: theme.colors.surface2,
},
chevron: {
width: WORKSPACE_TREE_ICON_SIZE,
height: WORKSPACE_TREE_ICON_SIZE,
width: 16,
height: 16,
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
},
// Lucide leaves space around the chevron path. Align its painted right edge inside the fixed
// slot; the rotated glyph is wider, so it needs half the optical offset.
chevronCollapsed: {
left: 4,
},
chevronExpanded: {
left: 2,
transform: [{ rotate: "90deg" }],
},
}));
@@ -994,7 +994,6 @@ function MobileComboboxBody(props: MobileBodyProps): ReactElement {
return (
<IsolatedBottomSheetModal
ref={props.bottomSheetRef}
contextBridge={null}
snapPoints={props.snapPoints}
index={0}
enableDynamicSizing={false}
+784 -54
View File
@@ -1,14 +1,26 @@
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import {
useCallback,
type ComponentProps,
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type PropsWithChildren,
type ReactElement,
type ReactNode,
type Ref,
} from "react";
import { useTranslation } from "react-i18next";
import {
Dimensions,
Modal,
Platform,
Pressable,
StatusBar,
Text,
View,
type GestureResponderEvent,
type PressableProps,
@@ -16,50 +28,150 @@ import {
type StyleProp,
type ViewStyle,
} from "react-native";
import { isNative, isWeb } from "@/constants/platform";
import { FadeIn, FadeOut } from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { Check, CheckCircle } from "lucide-react-native";
import { BottomSheetBackdrop, BottomSheetScrollView } from "@gorhom/bottom-sheet";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import {
MenuHint,
MenuItem,
MenuLabel,
MenuRoot,
MenuSeparator,
MenuSurface,
useMenuContext,
type MenuSurfaceProps,
type MenuTriggerState,
} from "@/components/ui/menu";
IsolatedBottomSheetModal,
useIsolatedBottomSheetVisibility,
} from "@/components/ui/isolated-bottom-sheet-modal";
import { FloatingScrollView, FloatingSurface } from "@/components/ui/floating";
import { isWeb, isNative } from "@/constants/platform";
/**
* A menu opened by a long press or a right click, anchored to the point of the gesture rather
* than to a trigger box.
*
* Everything below the trigger is the shared menu engine — see `@/components/ui/menu` and
* docs/menus.md. Only the way it opens is different from `dropdown-menu.tsx`.
*/
// Keep parity with dropdown-menu action statuses.
export type ActionStatus = "idle" | "pending" | "success";
export { MenuItem as ContextMenuItem };
export { MenuLabel as ContextMenuLabel };
export { MenuSeparator as ContextMenuSeparator };
export { MenuHint as ContextMenuHint };
export type { ActionStatus } from "@/components/ui/menu";
type Placement = "top" | "bottom" | "left" | "right";
type Alignment = "start" | "center" | "end";
type MobileMenuMode = "dropdown" | "sheet";
/**
* Context menus use the mobile menu convention by default: long press opens a bottom sheet on a
* compact layout, while right click opens an anchored popover on a wide layout.
*/
export function ContextMenu({
compactMode = "sheet",
...props
}: ComponentProps<typeof MenuRoot>): ReactElement {
return <MenuRoot {...props} compactMode={compactMode} />;
interface Rect {
x: number;
y: number;
width: number;
height: number;
}
export function ContextMenuContent(props: MenuSurfaceProps): ReactElement | null {
return <MenuSurface {...props} />;
interface ContextMenuContextValue {
open: boolean;
setOpen: (open: boolean) => void;
triggerRef: React.RefObject<View | null>;
anchorRect: Rect | null;
setAnchorRect: (rect: Rect | null) => void;
}
export function useContextMenu() {
return useMenuContext("useContextMenu");
const ContextMenuContext = createContext<ContextMenuContextValue | null>(null);
function useContextMenuContext(componentName: string): ContextMenuContextValue {
const ctx = useContext(ContextMenuContext);
if (!ctx) {
throw new Error(`${componentName} must be used within <ContextMenu />`);
}
return ctx;
}
export function useContextMenu(): ContextMenuContextValue {
return useContextMenuContext("useContextMenu");
}
function useControllableOpenState({
open,
defaultOpen,
onOpenChange,
}: {
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
}): [boolean, (next: boolean) => void] {
const [internalOpen, setInternalOpen] = useState(Boolean(defaultOpen));
const isControlled = typeof open === "boolean";
const value = isControlled ? open : internalOpen;
const setValue = useCallback(
(next: boolean) => {
if (!isControlled) setInternalOpen(next);
onOpenChange?.(next);
},
[isControlled, onOpenChange],
);
return [value, setValue];
}
function measureElement(element: View): Promise<Rect> {
return new Promise((resolve) => {
element.measureInWindow((x, y, width, height) => {
resolve({ x, y, width, height });
});
});
}
function computePosition({
triggerRect,
contentSize,
displayArea,
placement,
alignment,
offset,
}: {
triggerRect: Rect;
contentSize: { width: number; height: number };
displayArea: Rect;
placement: Placement;
alignment: Alignment;
offset: number;
}): { x: number; y: number; actualPlacement: Placement } {
const { width: contentWidth, height: contentHeight } = contentSize;
// Calculate available space
const spaceTop = triggerRect.y - displayArea.y;
const spaceBottom = displayArea.y + displayArea.height - (triggerRect.y + triggerRect.height);
// Flip if needed
let actualPlacement = placement;
if (placement === "bottom" && spaceBottom < contentHeight && spaceTop > spaceBottom) {
actualPlacement = "top";
} else if (placement === "top" && spaceTop < contentHeight && spaceBottom > spaceTop) {
actualPlacement = "bottom";
}
let x: number;
let y: number;
// Position based on placement
if (actualPlacement === "bottom") {
y = triggerRect.y + triggerRect.height + offset;
} else if (actualPlacement === "top") {
y = triggerRect.y - contentHeight - offset;
} else if (actualPlacement === "left") {
x = triggerRect.x - contentWidth - offset;
y = triggerRect.y;
} else {
x = triggerRect.x + triggerRect.width + offset;
y = triggerRect.y;
}
// Alignment
if (actualPlacement === "top" || actualPlacement === "bottom") {
if (alignment === "start") {
x = triggerRect.x;
} else if (alignment === "end") {
x = triggerRect.x + triggerRect.width - contentWidth;
} else {
x = triggerRect.x + (triggerRect.width - contentWidth) / 2;
}
}
// Constrain to screen
const padding = 8;
x = Math.max(padding, Math.min(displayArea.width - contentWidth - padding, x!));
y = Math.max(
displayArea.y + padding,
Math.min(displayArea.y + displayArea.height - contentHeight - padding, y!),
);
return { x, y, actualPlacement };
}
function isCallable(fn: unknown): fn is (...args: unknown[]) => void {
@@ -67,18 +179,18 @@ function isCallable(fn: unknown): fn is (...args: unknown[]) => void {
}
function coerceEventPoint(event: unknown): { pageX: number; pageY: number } | null {
if (typeof event !== "object" || event === null) return null;
if (typeof event !== "object" || event === null) {
return null;
}
const nativeEvent = Reflect.get(event, "nativeEvent");
const source = typeof nativeEvent === "object" && nativeEvent !== null ? nativeEvent : event;
const pageX = Reflect.get(source, "pageX");
const pageY = Reflect.get(source, "pageY");
const native = typeof nativeEvent === "object" && nativeEvent !== null ? nativeEvent : event;
const pageX = Reflect.get(native, "pageX");
const pageY = Reflect.get(native, "pageY");
if (typeof pageX === "number" && typeof pageY === "number") {
return { pageX, pageY };
}
const clientX = Reflect.get(source, "clientX");
const clientY = Reflect.get(source, "clientY");
const clientX = Reflect.get(native, "clientX");
const clientY = Reflect.get(native, "clientY");
if (typeof clientX === "number" && typeof clientY === "number") {
return { pageX: clientX, pageY: clientY };
}
@@ -86,22 +198,66 @@ function coerceEventPoint(event: unknown): { pageX: number; pageY: number } | nu
}
function assignRef<T>(ref: Ref<T> | undefined, value: T): void {
if (!ref) return;
if (typeof ref === "function") {
ref(value);
return;
}
(ref as { current: T }).current = value;
if (ref && typeof ref === "object") {
Object.assign(ref, { current: value });
}
}
type TriggerStyleProp = StyleProp<ViewStyle> | ((state: MenuTriggerState) => StyleProp<ViewStyle>);
export function ContextMenu({
open,
defaultOpen,
onOpenChange,
children,
}: PropsWithChildren<{
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
}>): ReactElement {
const triggerRef = useRef<View>(null);
const [isOpen, setIsOpen] = useControllableOpenState({
open,
defaultOpen,
onOpenChange,
});
const [anchorRect, setAnchorRect] = useState<Rect | null>(null);
useEffect(() => {
if (!isOpen) {
setAnchorRect(null);
}
}, [isOpen]);
const value = useMemo<ContextMenuContextValue>(
() => ({
open: isOpen,
setOpen: setIsOpen,
triggerRef,
anchorRect,
setAnchorRect,
}),
[anchorRect, isOpen, setAnchorRect, setIsOpen],
);
return <ContextMenuContext.Provider value={value}>{children}</ContextMenuContext.Provider>;
}
interface TriggerState {
pressed: boolean;
hovered: boolean;
open: boolean;
}
type TriggerStyleProp = StyleProp<ViewStyle> | ((state: TriggerState) => StyleProp<ViewStyle>);
export function ContextMenuTrigger({
children,
disabled,
style,
enabled = true,
enabledOnMobile = true,
enabledOnMobile = false,
enabledOnWeb = true,
longPressDelayMs,
triggerRef,
@@ -116,16 +272,19 @@ export function ContextMenuTrigger({
triggerRef?: Ref<View | null>;
}
>): ReactElement {
const ctx = useMenuContext("ContextMenuTrigger");
const ctx = useContextMenuContext("ContextMenuTrigger");
const shouldEnableOnThisPlatform = enabled && (isWeb ? enabledOnWeb : enabledOnMobile);
const openAtEvent = useCallback(
(event: unknown) => {
if (!shouldEnableOnThisPlatform || disabled) return;
if (!shouldEnableOnThisPlatform || disabled) {
return;
}
const point = coerceEventPoint(event);
if (!point) return;
if (!point) {
return;
}
const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
ctx.setAnchorRect({
x: point.pageX,
@@ -161,7 +320,9 @@ export function ContextMenuTrigger({
const handleContextMenu = useCallback(
(event: unknown) => {
if (isNative) return;
if (isNative) {
return;
}
if (typeof event === "object" && event !== null) {
const preventDefault = Reflect.get(event, "preventDefault");
const stopPropagation = Reflect.get(event, "stopPropagation");
@@ -199,3 +360,572 @@ export function ContextMenuTrigger({
</Pressable>
);
}
export function ContextMenuContent({
children,
side = "bottom",
align = "start",
offset = 4,
width,
minWidth = 180,
maxWidth,
fullWidth = false,
horizontalPadding = 16,
mobileMode = "dropdown",
testID,
}: PropsWithChildren<{
side?: Placement;
align?: Alignment;
offset?: number;
width?: number;
minWidth?: number;
maxWidth?: number;
fullWidth?: boolean;
horizontalPadding?: number;
mobileMode?: MobileMenuMode;
testID?: string;
}>): ReactElement | null {
const { t } = useTranslation();
const context = useContextMenuContext("ContextMenuContent");
const { theme } = useUnistyles();
const isMobile = useIsCompactFormFactor();
const useMobileSheet = isMobile && mobileMode === "sheet";
const { open, setOpen, triggerRef, anchorRect } = context;
const sheetSnapPoints = useMemo(() => ["30%", "55%"], []);
const [triggerRect, setTriggerRect] = useState<Rect | null>(null);
const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null);
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
const handleClose = useCallback(() => {
setOpen(false);
}, [setOpen]);
const {
sheetRef: bottomSheetRef,
handleSheetChange,
handleSheetDismiss,
} = useIsolatedBottomSheetVisibility({
visible: open,
isEnabled: useMobileSheet,
onClose: handleClose,
});
const renderSheetBackdrop = useCallback(
(props: ComponentProps<typeof BottomSheetBackdrop>) => (
<BottomSheetBackdrop {...props} appearsOnIndex={0} disappearsOnIndex={-1} opacity={0.45} />
),
[],
);
const sheetBackgroundStyle = useMemo(
() => [
styles.sheetBackground,
{
backgroundColor: theme.colors.surface0,
borderColor: theme.colors.border,
},
],
[theme.colors.surface0, theme.colors.border],
);
const sheetHandleStyle = useMemo(
() => [styles.sheetHandle, { backgroundColor: theme.colors.surface2 }],
[theme.colors.surface2],
);
// Measure trigger when opening (fallback) and capture point anchors.
useEffect(() => {
if (useMobileSheet || !open) {
setTriggerRect(null);
setContentSize(null);
setPosition(null);
return () => {};
}
if (anchorRect) {
setTriggerRect(anchorRect);
return () => {};
}
if (!triggerRef.current) {
setTriggerRect(null);
return () => {};
}
const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
let cancelled = false;
void measureElement(triggerRef.current).then((rect) => {
if (!cancelled) setTriggerRect({ ...rect, y: rect.y + statusBarHeight });
return undefined;
});
return () => {
cancelled = true;
};
}, [anchorRect, open, triggerRef, useMobileSheet]);
// Calculate position when we have both measurements
useEffect(() => {
if (useMobileSheet) return;
if (!triggerRect || !contentSize) return;
const { width: screenWidth, height: screenHeight } = Dimensions.get("window");
const displayArea = {
x: 0,
y: 0,
width: screenWidth,
height: screenHeight,
};
const result = computePosition({
triggerRect,
contentSize,
displayArea,
placement: side,
alignment: align,
offset,
});
const x = fullWidth ? horizontalPadding : result.x;
setPosition({ x, y: result.y });
}, [triggerRect, contentSize, side, align, offset, fullWidth, horizontalPadding, useMobileSheet]);
const handleContentLayout = useCallback(
(event: { nativeEvent: { layout: { width: number; height: number } } }) => {
const { width: w, height: h } = event.nativeEvent.layout;
setContentSize({ width: w, height: h });
},
[],
);
const frameStyle = useMemo(() => {
const { width: screenWidth } = Dimensions.get("window");
const resolvedWidthStyle: ViewStyle = fullWidth
? { width: screenWidth - horizontalPadding * 2 }
: {
...(typeof width === "number" ? { width } : null),
...(typeof minWidth === "number" ? { minWidth } : null),
...(typeof maxWidth === "number" ? { maxWidth } : null),
};
return [
resolvedWidthStyle,
{
position: "absolute" as const,
top: position?.y ?? -9999,
left: position?.x ?? -9999,
},
];
}, [fullWidth, horizontalPadding, width, minWidth, maxWidth, position?.y, position?.x]);
if (useMobileSheet) {
return (
<ContextMenuContext.Provider value={context}>
<IsolatedBottomSheetModal
ref={bottomSheetRef}
index={0}
snapPoints={sheetSnapPoints}
enableDynamicSizing={false}
onChange={handleSheetChange}
onDismiss={handleSheetDismiss}
backdropComponent={renderSheetBackdrop}
enablePanDownToClose
backgroundStyle={sheetBackgroundStyle}
handleIndicatorStyle={sheetHandleStyle}
keyboardBehavior="extend"
keyboardBlurBehavior="restore"
>
<BottomSheetScrollView
contentContainerStyle={styles.sheetScrollContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
testID={testID ? `${testID}-content` : undefined}
>
{children}
</BottomSheetScrollView>
</IsolatedBottomSheetModal>
</ContextMenuContext.Provider>
);
}
if (!open) return null;
return (
<Modal
visible={open}
transparent
animationType="none"
statusBarTranslucent={Platform.OS === "android"}
onRequestClose={handleClose}
>
<View style={styles.overlay}>
<Pressable
accessibilityRole="button"
accessibilityLabel={t("menu.backdrop")}
style={styles.backdrop}
onPress={handleClose}
testID={testID ? `${testID}-backdrop` : undefined}
/>
<FloatingSurface
entering={FadeIn.duration(100)}
exiting={FadeOut.duration(100)}
collapsable={false}
testID={testID}
onLayout={handleContentLayout}
style={styles.content}
frameStyle={frameStyle}
>
<FloatingScrollView
bounces={false}
showsVerticalScrollIndicator
contentContainerStyle={SCROLL_CONTENT_CONTAINER_STYLE}
>
{children}
</FloatingScrollView>
</FloatingSurface>
</View>
</Modal>
);
}
export function ContextMenuLabel({
children,
style,
testID,
}: PropsWithChildren<{ style?: ViewStyle | ViewStyle[]; testID?: string }>): ReactElement {
const containerStyle = useMemo(() => [styles.labelContainer, style], [style]);
return (
<View style={containerStyle} testID={testID}>
<Text style={styles.labelText}>{children}</Text>
</View>
);
}
export function ContextMenuSeparator({
style,
testID,
}: {
style?: ViewStyle;
testID?: string;
}): ReactElement {
const separatorStyle = useMemo(() => [styles.separator, style], [style]);
return <View style={separatorStyle} testID={testID} />;
}
export function ContextMenuHint({
children,
testID,
}: PropsWithChildren<{ testID?: string }>): ReactElement {
return (
<View style={styles.hintContainer} testID={testID}>
<Text style={styles.hintText}>{children}</Text>
</View>
);
}
function resolveLeadingContent(input: {
isPending: boolean;
isSuccess: boolean;
leading: ReactElement | null | undefined;
pendingColor: string;
successColor: string;
}): ReactElement | null {
if (input.isPending) {
return <LoadingSpinner size={16} color={input.pendingColor} />;
}
if (input.isSuccess) {
return <CheckCircle size={16} color={input.successColor} />;
}
return input.leading ?? null;
}
function resolveItemLabel(input: {
children: ReactNode;
isPending: boolean;
isSuccess: boolean;
pendingLabel: string | undefined;
successLabel: string | undefined;
}): ReactNode {
if (input.isPending && input.pendingLabel) return input.pendingLabel;
if (input.isSuccess && input.successLabel) return input.successLabel;
return input.children;
}
export function ContextMenuItem({
children,
description,
onSelect,
disabled,
destructive,
selected,
showSelectedCheck = false,
selectedVariant = "default",
leading,
trailing,
loading,
status,
pendingLabel,
successLabel,
closeOnSelect = true,
testID,
tooltip,
}: PropsWithChildren<{
description?: string;
onSelect?: () => void;
disabled?: boolean;
destructive?: boolean;
selected?: boolean;
showSelectedCheck?: boolean;
selectedVariant?: "default" | "accent";
leading?: ReactElement | null;
trailing?: ReactElement | null;
/** @deprecated Use `status` instead */
loading?: boolean;
status?: ActionStatus;
pendingLabel?: string;
successLabel?: string;
closeOnSelect?: boolean;
testID?: string;
tooltip?: string;
}>): ReactElement {
const { theme } = useUnistyles();
const { setOpen } = useContextMenuContext("ContextMenuItem");
const isPending = status === "pending" || Boolean(loading);
const isSuccess = status === "success";
const isDisabled = Boolean(disabled) || isPending || isSuccess;
const leadingContent = resolveLeadingContent({
isPending,
isSuccess,
leading,
pendingColor: theme.colors.foregroundMuted,
successColor: theme.colors.palette.green[500],
});
const label = resolveItemLabel({ children, isPending, isSuccess, pendingLabel, successLabel });
const trailingContent =
trailing ??
(!showSelectedCheck && selected ? (
<Check size={16} color={theme.colors.foregroundMuted} />
) : null);
const handleItemPress = useCallback(() => {
if (isDisabled) return;
if (closeOnSelect) {
setOpen(false);
}
onSelect?.();
}, [isDisabled, closeOnSelect, setOpen, onSelect]);
const itemPressableStyle = useCallback(
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => {
let selectedStyle: typeof styles.itemSelectedAccent | typeof styles.itemSelected | null;
if (!selected) selectedStyle = null;
else if (selectedVariant === "accent") selectedStyle = styles.itemSelectedAccent;
else selectedStyle = styles.itemSelected;
return [
styles.item,
selectedStyle,
selected && (hovered || pressed) && selectedVariant !== "accent"
? styles.itemSelectedInteractive
: null,
isDisabled ? styles.itemDisabled : null,
hovered && !pressed && !isDisabled ? styles.itemHovered : null,
pressed && !isDisabled ? styles.itemPressed : null,
];
},
[selected, selectedVariant, isDisabled],
);
const itemTextStyle = useMemo(
() => [
styles.itemText,
destructive && !isSuccess ? styles.itemTextDestructive : null,
isSuccess ? styles.itemTextSuccess : null,
selected && selectedVariant === "accent" ? styles.itemTextSelectedAccent : null,
],
[destructive, isSuccess, selected, selectedVariant],
);
const itemDescriptionStyle = useMemo(
() => [
styles.itemDescription,
selected && selectedVariant === "accent" ? styles.itemDescriptionSelectedAccent : null,
],
[selected, selectedVariant],
);
const content = (
<Pressable
testID={testID}
accessibilityRole="button"
disabled={isDisabled}
onPress={handleItemPress}
style={itemPressableStyle}
>
{showSelectedCheck ? (
<View style={styles.checkSlot}>
{selected ? <Check size={16} color={theme.colors.foreground} /> : null}
</View>
) : null}
{leadingContent ? <View style={styles.leadingSlot}>{leadingContent}</View> : null}
<View style={styles.itemContent}>
<Text numberOfLines={1} style={itemTextStyle}>
{label}
</Text>
{description && !isPending && !isSuccess ? (
<Text numberOfLines={2} style={itemDescriptionStyle}>
{description}
</Text>
) : null}
</View>
{trailingContent ? <View style={styles.trailingSlot}>{trailingContent}</View> : null}
</Pressable>
);
if (!tooltip) {
return content;
}
return (
<Tooltip delayDuration={250} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild>{content}</TooltipTrigger>
<TooltipContent side="right" align="center" offset={10}>
<Text style={styles.tooltipText}>{tooltip}</Text>
</TooltipContent>
</Tooltip>
);
}
const styles = StyleSheet.create((theme) => ({
overlay: {
flex: 1,
},
backdrop: {
position: "absolute",
top: 0,
right: 0,
bottom: 0,
left: 0,
},
content: {
backgroundColor: theme.colors.surface0,
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.lg,
...theme.shadow.md,
overflow: "hidden",
},
sheetBackground: {
backgroundColor: theme.colors.surface0,
borderTopLeftRadius: theme.borderRadius.xl,
borderTopRightRadius: theme.borderRadius.xl,
borderWidth: 1,
borderColor: theme.colors.border,
},
sheetHandle: {
backgroundColor: theme.colors.surface2,
},
sheetScrollContent: {
paddingTop: theme.spacing[1],
paddingBottom: theme.spacing[4],
},
labelContainer: {
paddingHorizontal: theme.spacing[3],
paddingTop: theme.spacing[2],
paddingBottom: theme.spacing[1],
},
labelText: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
textTransform: "uppercase",
letterSpacing: 0.6,
},
separator: {
height: 1,
backgroundColor: theme.colors.border,
},
hintContainer: {
paddingHorizontal: theme.spacing[3],
paddingBottom: theme.spacing[2],
},
hintText: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
tooltipText: {
fontSize: theme.fontSize.sm,
color: theme.colors.foreground,
},
item: {
flexDirection: "row",
alignItems: "center",
minHeight: 36,
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
borderWidth: theme.borderWidth[1],
borderColor: "transparent",
},
itemHovered: {
backgroundColor: theme.colors.surface1,
},
itemPressed: {
backgroundColor: theme.colors.surface1,
},
itemDisabled: {
opacity: 0.5,
},
itemSelected: {
backgroundColor: theme.colors.surface1,
},
itemSelectedInteractive: {
backgroundColor: theme.colors.surface1,
},
itemSelectedAccent: {
backgroundColor: theme.colors.accent,
},
checkSlot: {
width: 16,
alignItems: "center",
justifyContent: "center",
},
leadingSlot: {
width: 16,
alignItems: "center",
justifyContent: "center",
},
trailingSlot: {
marginLeft: "auto",
alignItems: "center",
justifyContent: "center",
},
itemContent: {
flexShrink: 1,
},
itemText: {
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
color: theme.colors.foreground,
},
itemTextDestructive: {
color: theme.colors.destructive,
},
itemTextSuccess: {
color: theme.colors.palette.green[500],
},
itemTextSelectedAccent: {
color: theme.colors.accentForeground,
},
itemDescription: {
marginTop: 2,
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
itemDescriptionSelectedAccent: {
color: theme.colors.accentForeground,
opacity: 0.85,
},
}));
const SCROLL_CONTENT_CONTAINER_STYLE = { flexGrow: 1 };
File diff suppressed because it is too large Load Diff
@@ -1,75 +1,34 @@
/**
* @vitest-environment jsdom
*/
import React, { act, createContext, useContext, useSyncExternalStore } from "react";
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
/**
* A faithful stand-in for `@gorhom/portal`: it renders *nothing* where it sits and hands its
* element to a host somewhere else in the tree, which is what makes the real thing lose context.
* A passthrough fake would keep context and quietly pass every test in this file — which is
* exactly what happened, and why the menu sheet shipped broken.
*/
const portalStore = vi.hoisted(() => {
const listeners = new Set<() => void>();
let node: React.ReactNode = null;
return {
put(next: React.ReactNode) {
node = next;
for (const listener of listeners) listener();
},
read: () => node,
subscribe(listener: () => void) {
listeners.add(listener);
return () => listeners.delete(listener);
},
reset() {
node = null;
listeners.clear();
},
};
});
vi.mock("@gorhom/portal", () => ({
Portal: ({ children }: { children?: React.ReactNode }) => {
React.useEffect(() => {
portalStore.put(children);
}, [children]);
return null;
},
PortalHost: () => React.createElement("div", { "data-host": true }),
Portal: ({ children, hostName }: { children?: React.ReactNode; hostName?: string }) =>
React.createElement("div", { "data-portal-host": hostName }, children),
PortalHost: ({ name }: { name?: string }) => React.createElement("div", { "data-host": name }),
}));
vi.mock("@gorhom/bottom-sheet", async () => {
const { Portal } = await import("@gorhom/portal");
return {
BottomSheetModalProvider: ({ children }: { children?: React.ReactNode }) =>
React.createElement("div", { "data-bottom-sheet-provider": true }, children),
BottomSheetModal: React.forwardRef(
({ children, stackBehavior }: { children?: React.ReactNode; stackBehavior?: string }, _ref) =>
React.createElement(
Portal,
null,
React.createElement("div", { "data-stack-behavior": stackBehavior }, children),
),
),
};
});
vi.mock("@gorhom/bottom-sheet", () => ({
BottomSheetModalProvider: ({ children }: { children?: React.ReactNode }) =>
React.createElement("div", { "data-bottom-sheet-provider": true }, children),
BottomSheetModal: React.forwardRef(
(
{
children,
stackBehavior,
}: {
children?: React.ReactNode;
stackBehavior?: string;
},
_ref,
) => React.createElement("div", { "data-stack-behavior": stackBehavior }, children),
),
}));
import { IsolatedBottomSheetModal, type ContextBridge } from ".";
/** Renders whatever the portal is currently holding, from its own place in the tree. */
function PortalHostProbe() {
const node = useSyncExternalStore(portalStore.subscribe, portalStore.read, portalStore.read);
return <div data-portal-host>{node}</div>;
}
const LabelContext = createContext("unbridged");
function Label() {
return <div data-label={useContext(LabelContext)} />;
}
import { IsolatedBottomSheetModal } from ".";
describe("IsolatedBottomSheetModal presentation", () => {
let container: HTMLDivElement;
@@ -84,94 +43,34 @@ describe("IsolatedBottomSheetModal presentation", () => {
afterEach(() => {
act(() => root.unmount());
container.remove();
portalStore.reset();
});
function stackBehaviors(): (string | null)[] {
return Array.from(container.querySelectorAll("[data-stack-behavior]")).map((node) =>
node.getAttribute("data-stack-behavior"),
);
}
it("defaults sibling top-level sheets to push instead of replacing by React ancestry", () => {
act(() => {
root.render(
<>
<IsolatedBottomSheetModal contextBridge={null}>Settings</IsolatedBottomSheetModal>
<PortalHostProbe />
<IsolatedBottomSheetModal>Settings</IsolatedBottomSheetModal>
<IsolatedBottomSheetModal>Diagnostic</IsolatedBottomSheetModal>
</>,
);
});
expect(stackBehaviors()).toEqual(["push"]);
act(() => {
root.render(
<>
<IsolatedBottomSheetModal contextBridge={null}>Diagnostic</IsolatedBottomSheetModal>
<PortalHostProbe />
</>,
);
});
expect(stackBehaviors()).toEqual(["push"]);
expect(
Array.from(container.querySelectorAll("[data-stack-behavior]")).map((node) =>
node.getAttribute("data-stack-behavior"),
),
).toEqual(["push", "push"]);
});
it("only replaces when the callsite asks for replacement", () => {
act(() => {
root.render(
<>
<IsolatedBottomSheetModal presentation="replace" contextBridge={null}>
Selector
</IsolatedBottomSheetModal>
<PortalHostProbe />
</>,
<IsolatedBottomSheetModal presentation="replace">Selector</IsolatedBottomSheetModal>,
);
});
expect(stackBehaviors()).toEqual(["replace"]);
});
});
describe("IsolatedBottomSheetModal context bridging", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
portalStore.reset();
});
function renderSheet(contextBridge: ContextBridge | null) {
act(() => {
root.render(
<>
<LabelContext.Provider value="from callsite">
<IsolatedBottomSheetModal contextBridge={contextBridge}>
<Label />
</IsolatedBottomSheetModal>
</LabelContext.Provider>
{/* Outside the provider, standing in for the app-root portal host. */}
<PortalHostProbe />
</>,
);
});
return container.querySelector("[data-label]")?.getAttribute("data-label");
}
it("loses callsite context without a bridge", () => {
expect(renderSheet(null)).toBe("unbridged");
});
it("carries callsite context across the portal when bridged", () => {
const bridge: ContextBridge = (children) => (
<LabelContext.Provider value="from callsite">{children}</LabelContext.Provider>
);
expect(renderSheet(bridge)).toBe("from callsite");
expect(
container.querySelector("[data-stack-behavior]")?.getAttribute("data-stack-behavior"),
).toBe("replace");
});
});
@@ -4,7 +4,7 @@ import {
} from "@gorhom/bottom-sheet";
import React from "react";
import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react";
import type { ElementRef, ReactNode } from "react";
import type { ElementRef } from "react";
import {
type BottomSheetController,
createBottomSheetVisibilityTracker,
@@ -12,42 +12,11 @@ import {
type GorhomBottomSheetModalMethods = ElementRef<typeof GorhomBottomSheetModal>;
/**
* Re-establishes React context on the far side of the portal.
*
* `@gorhom/portal` is not a React portal. It stores the element and a host elsewhere in the tree
* renders it, so context resolves at the *host's* position: everything provided between
* `PortalProvider` (see `app/_layout.tsx`) and this sheet is invisible to its content. React
* cannot copy contexts reflectively, so the only way across is to render the providers again —
* with values captured out here, where they are still readable.
*
* Write it as a closure over what you already have:
*
* ```tsx
* const contextBridge = useCallback<ContextBridge>(
* (content) => <ThingContext.Provider value={thing}>{content}</ThingContext.Provider>,
* [thing],
* );
* ```
*/
export type ContextBridge = (children: ReactNode) => ReactNode;
type IsolatedBottomSheetModalProps = Omit<
BottomSheetModalProps,
"enableDismissOnClose" | "stackBehavior" | "children"
"enableDismissOnClose" | "stackBehavior"
> & {
/**
* Nodes only. Gorhom also accepts a render function, but nothing here uses it and a bridge
* would have to reach around it.
*/
children?: ReactNode;
presentation?: "push" | "replace";
/**
* Required, and `null` is a real answer: a sheet that needs nothing from its call site should
* have to say so. The failure it prevents is invisible until someone adds a `useContext` deep
* inside the sheet and it throws on device only.
*/
contextBridge: ContextBridge | null;
};
export type IsolatedBottomSheetModalRef = GorhomBottomSheetModalMethods;
@@ -56,7 +25,7 @@ export const IsolatedBottomSheetModal = forwardRef<
IsolatedBottomSheetModalRef,
IsolatedBottomSheetModalProps
>(function IsolatedBottomSheetModal(props, ref) {
const { children, presentation = "push", contextBridge, ...bottomSheetProps } = props;
const { children, presentation = "push", ...bottomSheetProps } = props;
const modal = (
<GorhomBottomSheetModal
{...bottomSheetProps}
@@ -64,7 +33,7 @@ export const IsolatedBottomSheetModal = forwardRef<
enableDismissOnClose
stackBehavior={presentation}
>
{contextBridge ? contextBridge(children) : children}
{children}
</GorhomBottomSheetModal>
);
@@ -1,35 +0,0 @@
/**
* The menu engine.
*
* One state model and one item vocabulary behind two presentations — an anchored popover on
* wide screens, a bottom sheet on compact ones — and two trigger shapes, which live in the
* `dropdown-menu` and `context-menu` wrappers.
*
* See docs/menus.md.
*/
export { MenuRoot, MenuTrigger, type MenuTriggerProps, type MenuTriggerState } from "./menu-root";
export {
MenuSurface,
useMenuSurface,
type MenuPageDefinition,
type MenuSurfaceProps,
} from "./menu-surface";
export { MenuSubTrigger } from "./menu-sub";
export {
MenuHint,
MenuItem,
MenuLabel,
MenuSeparator,
type ActionStatus,
type MenuItemProps,
} from "./menu-item";
export {
useMenuContext,
useMenuDepth,
type MenuCompactMode,
type MenuContextValue,
type MenuPresentation,
} from "./menu-context";
export { currentPageId, isSubPageOpen, MENU_ROOT_PATH, type MenuPath } from "./menu-navigation";
export type { Alignment, Placement, Rect, Size } from "./menu-anchor";
@@ -1,77 +0,0 @@
import { describe, expect, it } from "vitest";
import { computePosition, getTransformOrigin, type Rect } from "./menu-anchor";
const DISPLAY: Rect = { x: 0, y: 0, width: 1000, height: 800 };
const TRIGGER: Rect = { x: 100, y: 100, width: 40, height: 20 };
const CONTENT = { width: 200, height: 100 };
function position(overrides: Partial<Parameters<typeof computePosition>[0]> = {}) {
return computePosition({
triggerRect: TRIGGER,
contentSize: CONTENT,
displayArea: DISPLAY,
placement: "bottom",
alignment: "start",
offset: 4,
...overrides,
});
}
describe("computePosition", () => {
it("sits below the trigger with room to spare", () => {
expect(position()).toEqual({ x: 100, y: 124, actualPlacement: "bottom" });
});
it("aligns to the trigger's start, center, and end edges", () => {
expect(position({ alignment: "start" }).x).toBe(100);
expect(position({ alignment: "center" }).x).toBe(20);
// 100 + 40 - 200 = -60, pulled back to the edge padding.
expect(position({ alignment: "end" }).x).toBe(8);
});
it("flips above the trigger when it does not fit below and there is more room above", () => {
const result = position({
triggerRect: { ...TRIGGER, y: 700 },
contentSize: { width: 200, height: 200 },
});
expect(result.actualPlacement).toBe("top");
expect(result.y).toBe(496);
});
it("stays put when flipping would be just as cramped", () => {
// Taller than the whole display area: neither side fits, so flipping buys nothing.
const result = position({
displayArea: { x: 0, y: 0, width: 1000, height: 300 },
contentSize: { width: 200, height: 400 },
});
expect(result.actualPlacement).toBe("bottom");
});
it("places side placements flush with the trigger top and ignores alignment", () => {
const left = position({ placement: "left", alignment: "end" });
expect(left).toEqual({ x: 8, y: 100, actualPlacement: "left" });
const right = position({ placement: "right", alignment: "end" });
expect(right).toEqual({ x: 144, y: 100, actualPlacement: "right" });
});
it("keeps the surface inside a display area that does not start at the origin", () => {
// Regression: the horizontal clamp used to ignore displayArea.x while the vertical one
// honoured displayArea.y, so an offset display area pushed the surface off its left edge.
const result = position({
displayArea: { x: 100, y: 0, width: 900, height: 800 },
alignment: "end",
});
expect(result.x).toBe(108);
});
});
describe("getTransformOrigin", () => {
it.each([
["bottom", "start", "top left"],
["top", "end", "bottom right"],
["right", "center", "center center"],
] as const)("maps %s/%s to %s", (placement, alignment, expected) => {
expect(getTransformOrigin(placement, alignment)).toBe(expected);
});
});
@@ -1,159 +0,0 @@
import type { View } from "react-native";
/**
* Where a floating menu surface goes relative to the thing that opened it.
*
* This was two byte-identical copies, one in `dropdown-menu.tsx` and one in `context-menu.tsx`.
* Submenus need a third caller, so it lives here now and the menus import it.
*/
export type Placement = "top" | "bottom" | "left" | "right";
export type Alignment = "start" | "center" | "end";
export interface Rect {
x: number;
y: number;
width: number;
height: number;
}
export interface Size {
width: number;
height: number;
}
/** Gap kept between the surface and the edge of the display area. */
const EDGE_PADDING = 8;
export function measureElement(element: View): Promise<Rect> {
return new Promise((resolve) => {
element.measureInWindow((x, y, width, height) => {
resolve({ x, y, width, height });
});
});
}
/**
* A surface asked to open below the trigger flips above it when it doesn't fit below *and*
* there is more room above. Both conditions matter: flipping into a side that is equally
* cramped just moves the clipping, so a surface taller than the whole viewport stays put.
*
* Only the vertical placements flip. A left/right submenu that doesn't fit is handled by the
* caller choosing its side up front, because flipping it mid-open would move the surface out
* from under the pointer that is travelling toward it.
*/
function flipPlacement(input: {
placement: Placement;
triggerRect: Rect;
contentHeight: number;
displayArea: Rect;
}): Placement {
const { placement, triggerRect, contentHeight, displayArea } = input;
const spaceTop = triggerRect.y - displayArea.y;
const spaceBottom = displayArea.y + displayArea.height - (triggerRect.y + triggerRect.height);
if (placement === "bottom" && spaceBottom < contentHeight && spaceTop > spaceBottom) {
return "top";
}
if (placement === "top" && spaceTop < contentHeight && spaceBottom > spaceTop) {
return "bottom";
}
return placement;
}
/** Where the surface's top-left corner lands, before it is pulled back inside the display area. */
function anchorToPlacement(input: {
placement: Placement;
triggerRect: Rect;
contentSize: Size;
alignment: Alignment;
offset: number;
}): { x: number; y: number } {
const { placement, triggerRect, contentSize, alignment, offset } = input;
if (placement === "left") {
return { x: triggerRect.x - contentSize.width - offset, y: triggerRect.y };
}
if (placement === "right") {
return { x: triggerRect.x + triggerRect.width + offset, y: triggerRect.y };
}
const y =
placement === "bottom"
? triggerRect.y + triggerRect.height + offset
: triggerRect.y - contentSize.height - offset;
// Vertical placements align horizontally against the trigger; side placements sit flush
// with its top edge and ignore alignment entirely.
if (alignment === "start") {
return { x: triggerRect.x, y };
}
if (alignment === "end") {
return { x: triggerRect.x + triggerRect.width - contentSize.width, y };
}
return { x: triggerRect.x + (triggerRect.width - contentSize.width) / 2, y };
}
function clampToDisplayArea(input: {
x: number;
y: number;
contentSize: Size;
displayArea: Rect;
}): { x: number; y: number } {
const { x, y, contentSize, displayArea } = input;
return {
x: Math.max(
displayArea.x + EDGE_PADDING,
Math.min(displayArea.x + displayArea.width - contentSize.width - EDGE_PADDING, x),
),
y: Math.max(
displayArea.y + EDGE_PADDING,
Math.min(displayArea.y + displayArea.height - contentSize.height - EDGE_PADDING, y),
),
};
}
export function computePosition({
triggerRect,
contentSize,
displayArea,
placement,
alignment,
offset,
}: {
triggerRect: Rect;
contentSize: Size;
displayArea: Rect;
placement: Placement;
alignment: Alignment;
offset: number;
}): { x: number; y: number; actualPlacement: Placement } {
const actualPlacement = flipPlacement({
placement,
triggerRect,
contentHeight: contentSize.height,
displayArea,
});
const anchored = anchorToPlacement({
placement: actualPlacement,
triggerRect,
contentSize,
alignment,
offset,
});
return { ...clampToDisplayArea({ ...anchored, contentSize, displayArea }), actualPlacement };
}
export function getTransformOrigin(placement: Placement, alignment: Alignment): string {
let vertical: string;
if (placement === "bottom") vertical = "top";
else if (placement === "top") vertical = "bottom";
else vertical = "center";
let horizontal: string;
if (alignment === "start") horizontal = "left";
else if (alignment === "end") horizontal = "right";
else horizontal = "center";
return `${vertical} ${horizontal}`;
}
@@ -1,203 +0,0 @@
import { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
import { Platform, type View } from "react-native";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useDismissKeyboardOnOpen } from "@/components/ui/keyboard-dismiss";
import {
closeSubPage,
goBack as goBackPath,
MENU_ROOT_PATH,
openSubPage,
type MenuPath,
} from "./menu-navigation";
import type { Rect } from "./menu-anchor";
/**
* How a menu draws itself once it is open.
*
* `popover` anchors a floating surface to the trigger and opens submenus as flyouts beside it.
* `sheet` presents from the bottom edge and opens submenus by replacing the page in place.
* The choice is made from form factor, not platform, so a tablet in a narrow split view sheets
* the same way a phone does.
*/
export type MenuPresentation = "popover" | "sheet";
/** What a menu does on a compact screen. Wide screens are always a popover. */
export type MenuCompactMode = "popover" | "sheet";
export interface MenuContextValue {
open: boolean;
setOpen: (open: boolean) => void;
/** Runs an item's action, closing the menu first when the item asks for it. */
selectItem: (onSelect: (() => void) | undefined, closeOnSelect: boolean) => void;
flushPendingSelect: () => void;
triggerRef: React.RefObject<View | null>;
/** Point anchor for menus opened from a gesture rather than a trigger box. */
anchorRect: Rect | null;
setAnchorRect: (rect: Rect | null) => void;
presentation: MenuPresentation;
path: MenuPath;
openSub: (sub: { id: string; depth: number }) => void;
closeSub: (depth: number) => void;
goBack: () => void;
}
const MenuContext = createContext<MenuContextValue | null>(null);
export const MenuContextProvider = MenuContext.Provider;
export function useMenuContext(componentName: string): MenuContextValue {
const ctx = useContext(MenuContext);
if (!ctx) {
throw new Error(`${componentName} must be used within a menu root`);
}
return ctx;
}
/**
* How many submenu pages sit above the component reading it. A trigger on the root page is
* depth 0. Each open submenu re-provides this one higher, which is what lets a submenu trigger
* know whether it should nest below the current page or replace a sibling branch.
*/
const MenuDepthContext = createContext(0);
export const MenuDepthProvider = MenuDepthContext.Provider;
export function useMenuDepth(): number {
return useContext(MenuDepthContext);
}
function useControllableOpenState({
open,
defaultOpen,
onOpenChange,
}: {
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
}): [boolean, (next: boolean) => void] {
const [internalOpen, setInternalOpen] = useState(Boolean(defaultOpen));
const isControlled = typeof open === "boolean";
const value = isControlled ? open : internalOpen;
const setValue = useCallback(
(next: boolean) => {
if (!isControlled) setInternalOpen(next);
onOpenChange?.(next);
},
[isControlled, onOpenChange],
);
return [value, setValue];
}
/**
* Everything one menu knows about itself. Both menu shapes build on this; they differ only in
* what opens them, which is why the trigger is the wrapper's job and nothing here.
*/
export function useMenuState({
open,
defaultOpen,
onOpenChange,
compactMode = "popover",
}: {
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
compactMode?: MenuCompactMode;
}): MenuContextValue {
const triggerRef = useRef<View>(null);
const pendingSelectRef = useRef<(() => void) | null>(null);
const [anchorRect, setAnchorRect] = useState<Rect | null>(null);
const [path, setPath] = useState<MenuPath>(MENU_ROOT_PATH);
const isCompact = useIsCompactFormFactor();
const presentation: MenuPresentation = isCompact && compactMode === "sheet" ? "sheet" : "popover";
const [isOpen, setIsOpenState] = useControllableOpenState({ open, defaultOpen, onOpenChange });
useDismissKeyboardOnOpen(isOpen);
// A menu always reopens on its root page, against a freshly measured anchor. Keeping either
// would reopen the surface three levels deep, or at the coordinates of the last right click.
const setOpen = useCallback(
(next: boolean) => {
if (!next) {
setPath(MENU_ROOT_PATH);
setAnchorRect(null);
}
setIsOpenState(next);
},
[setIsOpenState],
);
const flushPendingSelect = useCallback(() => {
const pendingSelect = pendingSelectRef.current;
pendingSelectRef.current = null;
if (!pendingSelect) return;
if (Platform.OS === "ios") {
// Native presenters such as PHPicker can hang if launched while an RN
// Modal is still completing dismissal on UIKit's side.
setTimeout(pendingSelect, 250);
return;
}
pendingSelect();
}, []);
const selectItem = useCallback(
(onSelect: (() => void) | undefined, closeOnSelect: boolean) => {
if (!closeOnSelect) {
onSelect?.();
return;
}
if (Platform.OS === "ios") {
pendingSelectRef.current = onSelect ?? null;
setOpen(false);
return;
}
setOpen(false);
onSelect?.();
},
[setOpen],
);
const openSub = useCallback((sub: { id: string; depth: number }) => {
setPath((current) => openSubPage(current, sub));
}, []);
const closeSub = useCallback((depth: number) => {
setPath((current) => closeSubPage(current, depth));
}, []);
const goBack = useCallback(() => {
setPath((current) => goBackPath(current));
}, []);
return useMemo(
() => ({
open: isOpen,
setOpen,
selectItem,
flushPendingSelect,
triggerRef,
anchorRect,
setAnchorRect,
presentation,
path,
openSub,
closeSub,
goBack,
}),
[
isOpen,
setOpen,
selectItem,
flushPendingSelect,
anchorRect,
presentation,
path,
openSub,
closeSub,
goBack,
],
);
}
@@ -1,357 +0,0 @@
import {
useCallback,
useMemo,
type PropsWithChildren,
type ReactElement,
type ReactNode,
} from "react";
import {
Pressable,
Text,
View,
type PressableStateCallbackType,
type ViewStyle,
} from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Check, CheckCircle } from "lucide-react-native";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import type { Theme } from "@/styles/theme";
import { useMenuContext } from "./menu-context";
const ThemedCheck = withUnistyles(Check);
const ThemedCheckCircle = withUnistyles(CheckCircle);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const foregroundMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const mutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const successMapping = (theme: Theme) => ({ color: theme.colors.palette.green[500] });
/**
* Height of the filled part of a row, which together with the uniform inset puts the row pitch
* back at the 36pt it was before the fill was inset at all — the space is redistributed, not
* added.
*
* It only holds if the label's line box is pinned: 18 line + 8 padding + 2 border is exactly
* this. Leave the text's `lineHeight` to the platform and the content outgrows `minHeight`,
* which then does nothing and the rows drift taller again.
*/
const MENU_ITEM_HEIGHT = 28;
const MENU_ITEM_LINE_HEIGHT = 18;
/** Action status for menu items with loading/success feedback. */
export type ActionStatus = "idle" | "pending" | "success";
export function MenuLabel({
children,
style,
testID,
}: PropsWithChildren<{ style?: ViewStyle | ViewStyle[]; testID?: string }>): ReactElement {
const labelContainerStyle = useMemo(() => [styles.labelContainer, style], [style]);
return (
<View style={labelContainerStyle} testID={testID}>
<Text style={styles.labelText}>{children}</Text>
</View>
);
}
export function MenuSeparator({
style,
testID,
}: {
style?: ViewStyle;
testID?: string;
}): ReactElement {
const separatorStyle = useMemo(() => [styles.separator, style], [style]);
return <View style={separatorStyle} testID={testID} />;
}
export function MenuHint({
children,
style,
testID,
}: PropsWithChildren<{ style?: ViewStyle | ViewStyle[]; testID?: string }>): ReactElement {
const hintContainerStyle = useMemo(() => [styles.hintContainer, style], [style]);
return (
<View style={hintContainerStyle} testID={testID}>
<Text style={styles.hintText}>{children}</Text>
</View>
);
}
function resolveLeadingContent(input: {
isPending: boolean | undefined;
isSuccess: boolean;
leading: ReactElement | null;
}): ReactElement | null {
const { isPending, isSuccess, leading } = input;
if (isPending) {
return <ThemedLoadingSpinner size={16} uniProps={mutedMapping} />;
}
if (isSuccess) {
return <ThemedCheckCircle size={16} uniProps={successMapping} />;
}
return leading;
}
function resolveItemLabel(input: {
children: ReactNode;
isPending: boolean | undefined;
isSuccess: boolean;
pendingLabel?: string;
successLabel?: string;
}): ReactNode {
const { children, isPending, isSuccess, pendingLabel, successLabel } = input;
if (isPending && pendingLabel) return pendingLabel;
if (isSuccess && successLabel) return successLabel;
return children;
}
export interface MenuItemProps {
description?: string;
onSelect?: () => void;
disabled?: boolean;
muted?: boolean;
destructive?: boolean;
/**
* This row's value is the chosen one. Draws a check and nothing else — a checked row that is
* also filled in reads as two different claims about the same state.
*/
selected?: boolean;
/** Reserves a leading check column so a group of items stays aligned whether ticked or not. */
showSelectedCheck?: boolean;
/**
* This row's submenu is open. Distinct from `selected`: it is about where you are, not what
* you picked, so it takes the background that `selected` gives up.
*/
active?: boolean;
leading?: ReactElement | null;
trailing?: ReactElement | null;
/** @deprecated Use `status` instead */
loading?: boolean;
status?: ActionStatus;
/** Label to show while pending (e.g. "Pushing…") */
pendingLabel?: string;
/** Label to show on success (e.g. "Pushed") */
successLabel?: string;
closeOnSelect?: boolean;
testID?: string;
tooltip?: string;
}
export function MenuItem({
children,
description,
onSelect,
disabled,
muted = false,
destructive,
selected,
showSelectedCheck = false,
active = false,
leading,
trailing,
loading,
status,
pendingLabel,
successLabel,
closeOnSelect = true,
testID,
tooltip,
}: PropsWithChildren<MenuItemProps>): ReactElement {
const { selectItem } = useMenuContext("MenuItem");
const isPending = status === "pending" || loading;
const isSuccess = status === "success";
const isDisabled = disabled || isPending || isSuccess;
const leadingContent = resolveLeadingContent({
isPending,
isSuccess,
leading: leading ?? null,
});
const label = resolveItemLabel({ children, isPending, isSuccess, pendingLabel, successLabel });
const trailingContent =
trailing ??
(!showSelectedCheck && selected ? <ThemedCheck size={16} uniProps={mutedMapping} /> : null);
const handleItemPress = useCallback(() => {
if (isDisabled) return;
selectItem(onSelect, closeOnSelect);
}, [isDisabled, selectItem, onSelect, closeOnSelect]);
const itemPressableStyle = useCallback(
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.item,
active ? styles.itemActive : null,
isDisabled ? styles.itemDisabled : null,
muted && !isDisabled ? styles.itemMuted : null,
hovered && !pressed && !isDisabled ? styles.itemHovered : null,
pressed && !isDisabled ? styles.itemPressed : null,
],
[active, isDisabled, muted],
);
const itemTextStyle = useMemo(
() => [
styles.itemText,
destructive && !isSuccess ? styles.itemTextDestructive : null,
isSuccess ? styles.itemTextSuccess : null,
muted && !isDisabled ? styles.itemTextMuted : null,
],
[destructive, isSuccess, muted, isDisabled],
);
const content = (
<Pressable
testID={testID}
accessibilityRole="button"
disabled={isDisabled}
onPress={handleItemPress}
style={itemPressableStyle}
>
{showSelectedCheck ? (
<View style={styles.checkSlot}>
{selected ? <ThemedCheck size={16} uniProps={foregroundMapping} /> : null}
</View>
) : null}
{leadingContent ? <View style={styles.leadingSlot}>{leadingContent}</View> : null}
<View style={styles.itemContent}>
<Text numberOfLines={1} style={itemTextStyle}>
{label}
</Text>
{description && !isPending && !isSuccess ? (
<Text numberOfLines={2} style={styles.itemDescription}>
{description}
</Text>
) : null}
</View>
{trailingContent ? <View style={styles.trailingSlot}>{trailingContent}</View> : null}
</Pressable>
);
if (!tooltip) {
return content;
}
return (
<Tooltip delayDuration={250} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild>{content}</TooltipTrigger>
<TooltipContent side="right" align="center" offset={10}>
<Text style={styles.tooltipText}>{tooltip}</Text>
</TooltipContent>
</Tooltip>
);
}
const styles = StyleSheet.create((theme) => ({
labelContainer: {
paddingHorizontal: theme.spacing[3],
paddingTop: theme.spacing[2],
paddingBottom: theme.spacing[1],
},
labelText: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
// `border` sits between surface1 and surface2, which put it within a hair of the hover fill and
// made separators vanish against a hovered row. `borderAccent` is the colour the menu surface
// already outlines itself with, so the divider reads as part of the same frame.
//
// No margin of its own: the items above and below already hold themselves off it. Giving the
// rule its own spacing on top of theirs is how you end up tuning two numbers to control one gap.
separator: {
height: 1,
backgroundColor: theme.colors.borderAccent,
},
hintContainer: {
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
},
hintText: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
tooltipText: {
fontSize: theme.fontSize.sm,
color: theme.colors.foreground,
},
// The fill is inset by the same amount on every side and rounded, so a hovered row reads as a
// chip sitting inside the menu. That inset is the only thing that spaces this menu: rows sit
// apart because each holds itself in, and a separator gets clearance from its neighbours for
// free instead of carrying its own margin.
//
// The inset is taken out of the row, not added to it. Horizontally that means padding gives up
// what margin takes — margin 4 + padding 8 + border 1 lands the label at the same 13pt it
// always sat at. Vertically the fill itself is shorter by the same 8, so the pitch is unchanged
// and the menu did not grow; it just stopped filling every row edge to edge.
item: {
flexDirection: "row",
alignItems: "center",
minHeight: MENU_ITEM_HEIGHT,
gap: theme.spacing[2],
margin: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1],
borderWidth: theme.borderWidth[1],
borderColor: "transparent",
borderRadius: theme.borderRadius.md,
},
itemHovered: {
backgroundColor: theme.colors.surface2,
},
itemPressed: {
backgroundColor: theme.colors.surface2,
},
// The row you are inside, not the value you chose. A chosen value is marked by its check.
itemActive: {
backgroundColor: theme.colors.surface2,
},
itemDisabled: {
opacity: 0.5,
},
itemMuted: {
opacity: 0.72,
},
itemText: {
fontSize: theme.fontSize.sm,
lineHeight: MENU_ITEM_LINE_HEIGHT,
color: theme.colors.foreground,
fontWeight: theme.fontWeight.normal,
},
itemTextMuted: {
color: theme.colors.foregroundMuted,
},
itemTextDestructive: {
color: theme.colors.destructive,
},
itemTextSuccess: {
color: theme.colors.palette.green[500],
},
itemDescription: {
marginTop: 2,
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
checkSlot: {
width: 16,
alignItems: "center",
justifyContent: "center",
},
leadingSlot: {
width: 16,
alignItems: "center",
justifyContent: "center",
},
trailingSlot: {
marginLeft: "auto",
alignItems: "center",
justifyContent: "center",
},
itemContent: {
flexShrink: 1,
minWidth: 0,
},
}));
@@ -1,75 +0,0 @@
import { describe, expect, it } from "vitest";
import {
closeSubPage,
currentPageId,
goBack,
isSubPageOpen,
MENU_ROOT_PATH,
openSubPage,
} from "./menu-navigation";
describe("openSubPage", () => {
it("opens a submenu from the root", () => {
expect(openSubPage(MENU_ROOT_PATH, { id: "show", depth: 0 })).toEqual(["show"]);
});
it("nests a submenu opened from inside another", () => {
expect(openSubPage(["show"], { id: "colors", depth: 1 })).toEqual(["show", "colors"]);
});
it("swaps branches instead of nesting when the trigger is a sibling", () => {
// Sliding the pointer from one root trigger to another must not stack both flyouts.
expect(openSubPage(["grouping"], { id: "show", depth: 0 })).toEqual(["show"]);
});
it("drops deeper pages when an ancestor's sibling opens", () => {
expect(openSubPage(["show", "colors"], { id: "grouping", depth: 0 })).toEqual(["grouping"]);
});
it("returns the same path when the submenu is already open", () => {
const path = ["show"];
expect(openSubPage(path, { id: "show", depth: 0 })).toBe(path);
});
});
describe("closeSubPage", () => {
it("closes the page at a depth and everything below it", () => {
expect(closeSubPage(["show", "colors"], 0)).toEqual([]);
expect(closeSubPage(["show", "colors"], 1)).toEqual(["show"]);
});
it("returns the same path when nothing is open at that depth", () => {
const path = ["show"];
expect(closeSubPage(path, 1)).toBe(path);
expect(closeSubPage(MENU_ROOT_PATH, 0)).toBe(MENU_ROOT_PATH);
});
});
describe("goBack", () => {
it("pops one page", () => {
expect(goBack(["show", "colors"])).toEqual(["show"]);
expect(goBack(["show"])).toEqual([]);
});
it("is a no-op at the root", () => {
expect(goBack(MENU_ROOT_PATH)).toBe(MENU_ROOT_PATH);
});
});
describe("currentPageId", () => {
it("is null at the root and the last id otherwise", () => {
expect(currentPageId(MENU_ROOT_PATH)).toBeNull();
expect(currentPageId(["show", "colors"])).toBe("colors");
});
});
describe("isSubPageOpen", () => {
it("matches on id and depth together", () => {
const path = ["show", "colors"];
expect(isSubPageOpen(path, { id: "show", depth: 0 })).toBe(true);
expect(isSubPageOpen(path, { id: "colors", depth: 1 })).toBe(true);
// Right id, wrong level — a different trigger that happens to share a name.
expect(isSubPageOpen(path, { id: "colors", depth: 0 })).toBe(false);
expect(isSubPageOpen(path, { id: "grouping", depth: 0 })).toBe(false);
});
});
@@ -1,48 +0,0 @@
/**
* Which submenu pages are open, as a path from the root outward.
*
* The desktop flyout chain and the mobile push stack are the same structure, which is why
* there is one model and not two: a pointer surface renders *every* page in the path as its
* own anchored surface, and a compact sheet renders only the last one. Nothing else differs.
*
* Pure and React-free on purpose — the ordering rules below are the part worth testing, and
* they don't need a renderer to exercise.
*/
/** Sub-page ids from the root outward. Empty means the root page is showing. */
export type MenuPath = readonly string[];
export const MENU_ROOT_PATH: MenuPath = [];
/**
* Opening a submenu truncates the path to the depth of the trigger that opened it, so moving
* between two triggers on the same page swaps branches instead of nesting them. Without this,
* sliding the pointer across a menu would stack up every flyout it passed.
*
* `depth` is how many sub-pages sit above the trigger's own page: a trigger on the root page
* is depth 0, one inside a first-level submenu is depth 1.
*/
export function openSubPage(path: MenuPath, sub: { id: string; depth: number }): MenuPath {
if (isSubPageOpen(path, sub)) return path;
return [...path.slice(0, sub.depth), sub.id];
}
/** Closes the page opened at `depth` and everything it opened in turn. */
export function closeSubPage(path: MenuPath, depth: number): MenuPath {
if (path.length <= depth) return path;
return path.slice(0, depth);
}
export function goBack(path: MenuPath): MenuPath {
if (path.length === 0) return path;
return path.slice(0, -1);
}
/** The page a compact surface should render. Null means the root page. */
export function currentPageId(path: MenuPath): string | null {
return path.length === 0 ? null : (path[path.length - 1] ?? null);
}
export function isSubPageOpen(path: MenuPath, sub: { id: string; depth: number }): boolean {
return path[sub.depth] === sub.id;
}
@@ -1,472 +0,0 @@
import { useCallback, useEffect, useId, useMemo, useState, type ReactElement } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import {
Dimensions,
Modal,
Platform,
Pressable,
StatusBar,
View,
type StyleProp,
type ViewStyle,
} from "react-native";
import { Keyframe, runOnJS } from "react-native-reanimated";
import { StyleSheet } from "react-native-unistyles";
import { FloatingScrollView, FloatingSurface } from "@/components/ui/floating";
import { isWeb } from "@/constants/platform";
import {
getOverlayRoot,
OverlayLayerProvider,
useOverlayLayer,
useWebOverlayRegistration,
} from "@/lib/overlay-root";
import {
computePosition,
getTransformOrigin,
measureElement,
type Alignment,
type Placement,
type Rect,
type Size,
} from "./menu-anchor";
const SCROLL_CONTENT_STYLE = { flexGrow: 1 } as const;
const CONTENT_ENTERING_DURATION_MS = 150;
const contentEntering = new Keyframe({
0: { opacity: 0, transform: [{ scale: 0.97 }] },
100: { opacity: 1, transform: [{ scale: 1 }] },
}).duration(CONTENT_ENTERING_DURATION_MS);
const contentExiting = new Keyframe({
0: { opacity: 1, transform: [{ scale: 1 }] },
100: { opacity: 0, transform: [{ scale: 0.97 }] },
}).duration(100);
function releaseFixedMenuHeight(surfaceNativeID: string): void {
if (!isWeb) return;
document.getElementById(surfaceNativeID)?.style.removeProperty("height");
}
/**
* Reanimated's web entering animation leaves an inline height snapshot on the measured surface.
* Once the menu is open, height must return to content-sized so rows can grow in place — and so
* a submenu page taller than the page it replaced is not clipped to the old height.
*
* `revision` is what makes that second case work: bump it whenever the rendered content changes
* identity (a page push, for instance) and the snapshot is released again.
*/
function useReleaseFixedMenuHeight({
contentSize,
enabled,
surfaceNativeID,
revision,
}: {
contentSize: Size | null;
enabled: boolean;
surfaceNativeID: string;
revision?: string | number;
}): void {
useEffect(() => {
if (!enabled) return undefined;
const release = () => {
releaseFixedMenuHeight(surfaceNativeID);
if (typeof requestAnimationFrame === "function") {
requestAnimationFrame(() => releaseFixedMenuHeight(surfaceNativeID));
}
};
const timers: ReturnType<typeof setTimeout>[] = [
setTimeout(release, CONTENT_ENTERING_DURATION_MS),
];
if (contentSize) {
timers.push(setTimeout(release, 0));
}
return () => {
for (const timer of timers) clearTimeout(timer);
};
}, [contentSize, enabled, surfaceNativeID, revision]);
}
/**
* Resolves where an anchored surface sits: measures the anchor, measures the content, and runs
* the two through `computePosition`. Returns nulls until both measurements have landed, which is
* why the surface renders off-screen at -9999 rather than at 0,0 on its first frame.
*/
export function useAnchoredPosition({
open,
anchorRect,
anchorRef,
side,
align,
offset,
scrollable,
maxHeight,
}: {
open: boolean;
anchorRect: Rect | null;
anchorRef: React.RefObject<View | null>;
side: Placement;
align: Alignment;
offset: number;
scrollable: boolean;
maxHeight?: number;
}) {
const [triggerRect, setTriggerRect] = useState<Rect | null>(null);
const [contentSize, setContentSize] = useState<Size | null>(null);
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
const [actualPlacement, setActualPlacement] = useState<Placement>(side);
const visibleContentSize = useMemo(() => {
if (!contentSize) return null;
if (!scrollable) return contentSize;
const { height: screenHeight } = Dimensions.get("window");
const viewportMaxHeight = Math.max(screenHeight - 16, 0);
const resolvedMaxHeight =
typeof maxHeight === "number" ? Math.min(maxHeight, viewportMaxHeight) : viewportMaxHeight;
return {
width: contentSize.width,
height: Math.min(contentSize.height, resolvedMaxHeight),
};
}, [contentSize, scrollable, maxHeight]);
useEffect(() => {
if (!open) {
setTriggerRect(null);
setContentSize(null);
setPosition(null);
return undefined;
}
if (anchorRect) {
setTriggerRect(anchorRect);
return undefined;
}
if (!anchorRef.current) {
setTriggerRect(null);
return undefined;
}
// Capture status bar height synchronously before async measurement, so it cannot change
// or read back null between the measure call and its resolution.
const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
let cancelled = false;
void measureElement(anchorRef.current).then((rect) => {
if (cancelled) return undefined;
// On Android with statusBarTranslucent, measureInWindow returns coordinates relative to
// below the status bar while Modal content starts at the screen top.
setTriggerRect({ ...rect, y: rect.y + statusBarHeight });
return undefined;
});
return () => {
cancelled = true;
};
}, [anchorRect, anchorRef, open]);
useEffect(() => {
if (!triggerRect || !visibleContentSize) return;
const { width: screenWidth, height: screenHeight } = Dimensions.get("window");
// measureInWindow and Modal both use full-screen coordinates, so the display area starts at 0.
const result = computePosition({
triggerRect,
contentSize: visibleContentSize,
displayArea: { x: 0, y: 0, width: screenWidth, height: screenHeight },
placement: side,
alignment: align,
offset,
});
setPosition({ x: result.x, y: result.y });
setActualPlacement(result.actualPlacement);
}, [triggerRect, visibleContentSize, side, align, offset]);
const onContentLayout = useCallback(
(event: { nativeEvent: { layout: { width: number; height: number } } }) => {
const { width, height } = event.nativeEvent.layout;
setContentSize((current) => {
if (current && current.width === width && current.height === height) return current;
return { width, height };
});
},
[],
);
return { position, actualPlacement, contentSize, visibleContentSize, onContentLayout };
}
export interface AnchoredSurfaceProps {
open: boolean;
onClose: () => void;
onExited?: () => void;
anchorRect: Rect | null;
anchorRef: React.RefObject<View | null>;
side?: Placement;
align?: Alignment;
offset?: number;
width?: number;
minWidth?: number;
maxWidth?: number;
maxHeight?: number;
fullWidth?: boolean;
horizontalPadding?: number;
scrollable?: boolean;
/** Bump when the rendered content changes identity, so the height snapshot is released. */
revision?: string | number;
/** A submenu sits inside its parent's overlay and must not paint a second backdrop. */
backdrop?: boolean;
/**
* Hover tracking for the surface box, per docs/hover.md: plain View, pointer events, never
* a Pressable. Web-only by nature, which is all a flyout needs.
*/
onPointerEnter?: () => void;
onPointerLeave?: () => void;
testID?: string;
children: ReactElement;
}
/**
* One floating menu surface, positioned against an anchor. The root menu renders one of these
* against its trigger; every open submenu renders another against the row that opened it.
*/
export function AnchoredSurface({
open,
onClose,
onExited,
anchorRect,
anchorRef,
side = "bottom",
align = "start",
offset = 4,
width,
minWidth = 180,
maxWidth,
maxHeight,
fullWidth = false,
horizontalPadding = 16,
scrollable = false,
revision,
backdrop = true,
onPointerEnter,
onPointerLeave,
testID,
children,
}: AnchoredSurfaceProps): ReactElement | null {
const { t } = useTranslation();
const surfaceNativeID = useId();
const { position, actualPlacement, contentSize, visibleContentSize, onContentLayout } =
useAnchoredPosition({
open,
anchorRect,
anchorRef,
side,
align,
offset,
scrollable,
maxHeight,
});
useReleaseFixedMenuHeight({
contentSize,
enabled: open,
surfaceNativeID,
revision,
});
const frameStyle = useMemo<StyleProp<ViewStyle>>(() => {
const { width: screenWidth } = Dimensions.get("window");
const resolvedWidthStyle: ViewStyle = fullWidth
? { width: screenWidth - horizontalPadding * 2 }
: {
...(typeof width === "number" ? { width } : null),
...(typeof minWidth === "number" ? { minWidth } : null),
...(typeof maxWidth === "number" ? { maxWidth } : null),
};
return [
resolvedWidthStyle,
{
position: "absolute" as const,
top: position?.y ?? -9999,
left: fullWidth ? horizontalPadding : (position?.x ?? -9999),
transformOrigin: getTransformOrigin(actualPlacement, align),
},
];
}, [
fullWidth,
horizontalPadding,
width,
minWidth,
maxWidth,
position?.x,
position?.y,
actualPlacement,
align,
]);
const scrollViewportStyle = useMemo(
() => [visibleContentSize ? { height: visibleContentSize.height } : null],
[visibleContentSize],
);
if (!open) return null;
const measured = (
<View
collapsable={false}
onLayout={onContentLayout}
onPointerEnter={onPointerEnter}
onPointerLeave={onPointerLeave}
>
{children}
</View>
);
return (
<>
{backdrop ? (
<Pressable
accessibilityRole="button"
accessibilityLabel={t("menu.backdrop")}
style={styles.backdrop}
onPress={onClose}
testID={testID ? `${testID}-backdrop` : undefined}
/>
) : null}
<FloatingSurface
collapsable={false}
tabIndex={-1}
nativeID={surfaceNativeID}
testID={testID}
style={styles.content}
frameStyle={frameStyle}
entering={contentEntering}
exiting={
isWeb || !onExited
? undefined
: contentExiting.withCallback((finished) => {
"worklet";
if (finished) {
runOnJS(onExited)();
}
})
}
>
{scrollable ? (
<FloatingScrollView
bounces={false}
showsVerticalScrollIndicator
style={scrollViewportStyle}
contentContainerStyle={SCROLL_CONTENT_STYLE}
>
{measured}
</FloatingScrollView>
) : (
measured
)}
</FloatingSurface>
</>
);
}
/**
* The full-screen layer every floating menu surface lives in: a web portal into the overlay
* root, or a transparent Modal on native. Submenus render inside their parent's layer rather
* than opening another one, so there is exactly one Modal per menu no matter how deep it goes.
*/
export function MenuOverlay({
visible,
onClose,
onDismiss,
children,
}: {
visible: boolean;
onClose: () => void;
onDismiss?: () => void;
children: ReactElement | null;
}): ReactElement | null {
const floatingLayer = useOverlayLayer("floating");
const handleWebOverlayKeyDown = useCallback(
(event: KeyboardEvent) => {
if (event.key !== "Escape") return false;
event.preventDefault();
event.stopPropagation();
onClose();
return true;
},
[onClose],
);
const setWebOverlayScope = useWebOverlayRegistration({
active: isWeb && visible,
layer: floatingLayer,
onKeyDown: handleWebOverlayKeyDown,
});
if (!visible) return null;
const overlay = (
<OverlayLayerProvider layer={floatingLayer}>
<View
ref={setWebOverlayScope}
collapsable={false}
style={[
styles.overlay,
isWeb ? styles.overlayWeb : null,
isWeb ? { zIndex: floatingLayer } : null,
]}
>
{children}
</View>
</OverlayLayerProvider>
);
if (isWeb && typeof document !== "undefined") {
return createPortal(overlay, getOverlayRoot());
}
return (
<Modal
visible={visible}
transparent
animationType="none"
statusBarTranslucent={Platform.OS === "android"}
onDismiss={onDismiss}
onRequestClose={onClose}
>
{overlay}
</Modal>
);
}
const styles = StyleSheet.create((theme) => ({
overlay: {
flex: 1,
},
overlayWeb: {
...StyleSheet.absoluteFillObject,
pointerEvents: "auto" as const,
},
backdrop: {
position: "absolute",
top: 0,
right: 0,
bottom: 0,
left: 0,
},
content: {
backgroundColor: theme.colors.surface1,
borderWidth: 1,
borderColor: theme.colors.borderAccent,
borderRadius: theme.borderRadius.lg,
overflow: "hidden",
...theme.shadow.md,
},
}));
@@ -1,95 +0,0 @@
import { useCallback, type PropsWithChildren, type ReactElement, type ReactNode } from "react";
import {
Pressable,
type PressableProps,
type PressableStateCallbackType,
type StyleProp,
type ViewStyle,
} from "react-native";
import {
MenuContextProvider,
useMenuContext,
useMenuState,
type MenuCompactMode,
} from "./menu-context";
/**
* Owns one menu's state. Wrap a trigger and a `MenuSurface` in it.
*
* The trigger is deliberately not part of this: what opens a menu is the only thing that
* differs between a dropdown (press) and a context menu (long press or right click), and it is
* the whole reason those two wrappers still exist.
*/
export function MenuRoot({
open,
defaultOpen,
onOpenChange,
compactMode,
children,
}: PropsWithChildren<{
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
compactMode?: MenuCompactMode;
}>): ReactElement {
const value = useMenuState({ open, defaultOpen, onOpenChange, compactMode });
return <MenuContextProvider value={value}>{children}</MenuContextProvider>;
}
export interface MenuTriggerState {
pressed: boolean;
hovered: boolean;
open: boolean;
}
type TriggerStyleProp = StyleProp<ViewStyle> | ((state: MenuTriggerState) => StyleProp<ViewStyle>);
export interface MenuTriggerProps extends Omit<PressableProps, "style" | "children"> {
style?: TriggerStyleProp;
children: ReactNode | ((state: MenuTriggerState) => ReactNode);
}
export function MenuTrigger({
children,
disabled,
style,
...props
}: MenuTriggerProps): ReactElement {
const ctx = useMenuContext("MenuTrigger");
const handlePress = useCallback(() => {
if (disabled) return;
ctx.setOpen(!ctx.open);
}, [disabled, ctx]);
const pressableStyle = useCallback(
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => {
if (typeof style === "function") {
return style({ pressed, hovered, open: ctx.open });
}
return style;
},
[style, ctx.open],
);
const renderChildren = useCallback(
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => {
const state: MenuTriggerState = { pressed, hovered, open: ctx.open };
return typeof children === "function" ? children(state) : children;
},
[children, ctx.open],
);
return (
<Pressable
{...props}
ref={ctx.triggerRef}
collapsable={false}
disabled={disabled}
onPress={handlePress}
style={pressableStyle}
>
{renderChildren}
</Pressable>
);
}
@@ -1,136 +0,0 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
type PropsWithChildren,
type ReactElement,
} from "react";
import { Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { ChevronRight } from "lucide-react-native";
import type { Theme } from "@/styles/theme";
import { useMenuContext, useMenuDepth } from "./menu-context";
import { isSubPageOpen } from "./menu-navigation";
import { useMenuSurface } from "./menu-surface";
import { MenuItem } from "./menu-item";
const ThemedChevronRight = withUnistyles(ChevronRight);
const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
/**
* A row that leads to a submenu page, showing the decision's current value on the way in
* ("Grouping Project ›"). Selecting it navigates rather than acting, so it never closes the
* menu.
*
* Hover lives on a plain `View` wrapping the item's `Pressable`, per docs/hover.md — a
* `Pressable` tracking its own hover would fight the one inside it. Hover only fires on web,
* which is exactly where flyouts exist; every other platform opens the page by pressing.
*/
export function MenuSubTrigger({
id,
value,
indicator = false,
disabled,
testID,
children,
}: PropsWithChildren<{
/** Matches the `id` of a `MenuPageDefinition` passed to the surface. */
id: string;
/** Current value of the decision this row leads to. */
value?: string;
/** Marks the branch as holding a non-default setting, e.g. an active filter. */
indicator?: boolean;
disabled?: boolean;
testID?: string;
}>): ReactElement {
const menu = useMenuContext("MenuSubTrigger");
const depth = useMenuDepth();
const { registerSubAnchor, hoverOpen, hoverClose, cancelHoverClose } =
useMenuSurface("MenuSubTrigger");
const anchorRef = useRef<View>(null);
useEffect(() => {
registerSubAnchor(id, anchorRef);
}, [id, registerSubAnchor]);
const isOpen = isSubPageOpen(menu.path, { id, depth });
const isPopover = menu.presentation === "popover";
const handleSelect = useCallback(() => {
menu.openSub({ id, depth });
}, [menu, id, depth]);
const handlePointerEnter = useCallback(() => {
cancelHoverClose();
hoverOpen({ id, depth });
}, [cancelHoverClose, hoverOpen, id, depth]);
const handlePointerLeave = useCallback(() => {
hoverClose(depth);
}, [hoverClose, depth]);
const trailing = useMemo(
() => <MenuSubTrailing value={value} indicator={indicator} />,
[value, indicator],
);
return (
<View
ref={anchorRef}
collapsable={false}
onPointerEnter={isPopover && !disabled ? handlePointerEnter : undefined}
onPointerLeave={isPopover && !disabled ? handlePointerLeave : undefined}
>
<MenuItem
active={isOpen}
closeOnSelect={false}
disabled={disabled}
onSelect={handleSelect}
trailing={trailing}
testID={testID}
>
{children}
</MenuItem>
</View>
);
}
function MenuSubTrailing({
value,
indicator,
}: {
value?: string;
indicator: boolean;
}): ReactElement {
return (
<View style={styles.trailing}>
{value ? (
<Text style={styles.valueText} numberOfLines={1}>
{value}
</Text>
) : null}
{indicator ? <View style={styles.indicator} testID="menu-sub-indicator" /> : null}
<ThemedChevronRight size={14} uniProps={mutedIconMapping} />
</View>
);
}
const styles = StyleSheet.create((theme) => ({
trailing: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1.5],
},
valueText: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
flexShrink: 1,
},
indicator: {
width: 6,
height: 6,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.accent,
},
}));

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