Compare commits

...
Author SHA1 Message Date
Okan YILDIRIMandpaseo-bot[bot] 781cb8d5b0 fix(server): reject plugin timeline kinds the app cannot register (#7677)
timeline.append accepted any string as the row kind, but the app registers
renderer kinds with /^[a-z][a-z0-9-]*$/. A plugin that appended a kind such as
repro.card got a successful append, then its client setup threw in
addTimelineRenderer and the whole plugin client failed to load.

The daemon now rejects an append whose kind breaks that rule and names the rule
in the error. The wire schema stays a plain string. Document the rule in the
plugin docs.

Fixes #7326

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-15 12:51:47 +00:00
paseo-bot[bot] 116b0a9190 Open the Explorer from its toggle state after a resize to phone width (#9771)
ensureExplorerSidebar clicked the toggle only when no Explorer was
visible. Right after a resize to phone width one still is for a moment:
the wide layout until the app switches, then the closed phone overlay's
first frame. The helper skipped the click and the phone Explorer, which
starts closed, never opened.

The helper now decides from the toggle's aria-expanded, and the
Explorer spec waits for the app shell to switch layouts after resizing.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-15 12:46:57 +00:00
Mohamed Boudra 5a7a60ae50 test(website): wait for hydration before the hero tests scroll (#9762)
The router scrolls the window back to the top when the page hydrates. The
hero tests scrolled to the mockup right after load, so in WebKit the reset
sometimes landed after the test's scroll and the opening scene never started.

The language button turns enabled in the render after that reset. The
changelog and plugins specs already waited on it with their own copies; the
wait now lives in the shared fixtures and the hero tests open the page with it.
2026-11-15 18:32:17 +07:00
Marcel Wysocki dd06bc0833 test(terminal): wait for the newer worker to exit after a kill stop (#9290)
Cleanup counts a worker gone once its socket refuses connections, and the
worker stops listening before its process exits. The test checked the
worker pid right after closeTerminals() resolved, so it failed whenever
the worker was still exiting. Wait for the pid to disappear, as the
adjacent shutdown tests do.
2026-11-15 10:52:01 +00:00
2eba291a8e Keep idle Claude agents running while a session cron or /loop wakeup is pending (#9744)
* fix(claude): keep an idle agent running while it has a session cron

Claude keeps session-only crons and /loop wakeups in its own process, so
stopping that process drops them. The idle runtime cleanup asked the Claude
session whether it could stop, and the session never knew about these jobs:
they appear nowhere on the message stream.

Claude lists them as session_crons on every Stop hook. The session now
records that list, and canStopRuntime() refuses while it is non-empty. The
next Stop after a fire, a CronDelete or an expiry updates the list.

Co-authored-by: KronixDev <35991992+KronixDev@users.noreply.github.com>

* refactor(claude): type the Stop hook with the SDK's hook input

Co-authored-by: KronixDev <35991992+KronixDev@users.noreply.github.com>

* fix(claude): hold the runtime after a cron tool call until the next Stop

Claude lists session crons only in the Stop hook, and runs Stop only for
turns that complete. A turn that called CronCreate and was interrupted or
failed left the count at zero, so idle cleanup could still stop the
process holding the cron. A CronCreate, CronDelete or ScheduleWakeup call
now marks the count unknown until the next Stop reports it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: KronixDev <35991992+KronixDev@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 10:01:41 +00:00
Mohamed Boudra 96a143efe9 Show orchestration and remote in the homepage hero (#9750)
* feat(website): show orchestration and remote in an animated hero mockup

The homepage hero now has five scenes: Orchestration, Remote, Code review,
Pull requests, and Plugins. Orchestration and Remote were missing before,
and they are what separates Paseo from a single-machine agent app.

Orchestration, Code review, and Plugins play a script once the mockup is 60%
on screen, and again on every pill click. Each script is a list of beats
(wait, click, type, user, agent, tools, turnEnd, mark) laid end to end. A
non-React clock owns the time and components read one fact at a time, so
only the part that changed renders. A pointer clicks the composer, the
subagents pill, and the review controls. Reduced motion shows each finished
scene.

The right panel is 300px for the file tree and grows to 424px for the pull
request and the plugin. Picking a pill scrolls the pills and the mockup
into view.

* fix(website): keep a picked hero scene playing and scroll it into view the same in every browser

A pill click before the mockup was 60% on screen played its scene, and then
the visibility start played the same scene again from zero. The player now
starts the opening scene only when no scene has played, and the hero stops
the clock when it unmounts.

Picking a pill used scrollIntoView with `nearest`. For an element taller than
the window, each engine picks its own edge, and WebKit left the pill 25%
visible in a 1280x600 window. The hero now computes the scroll itself: the
least that shows the pills and the mockup, never past the pills at the top.

* fix(website): scroll a picked hero scene by where the hero settles, not where it slides

The hero slides up 40px as it enters. A pill clicked during the slide
measured the frame 40px low, so the scroll stopped with the pills 24px above
the window once the slide ended. This was the cause of the WebKit failure in
CI. The earlier explanation, that engines differ on scrollIntoView, was wrong.

The scroll now measures the frame from layout offsets, which ignore the slide.
The test holds the page clock so the slide stays at its first frame.

* test(website): hold the hero's slide-in still when a pill is picked during it

The test stopped the page timers but let time run, so it caught the bug only
when the click landed in the first second after load. It now pauses the clock
before the page loads and checks that the slide is still in progress at the
click.

* test(website): put the hero's 40px slide-in back by hand in the slide-in test

The test held the page clock to freeze the slide, but WebKit did not hold it,
so the test failed on its own precondition. It now waits for the slide to
end, puts the 40px offset on the container, picks a scene, and removes the
offset. Reduced motion makes the scroll instant, so the test asserts the
result without waiting for a scroll to settle.

* fix(website): start the opening hero scene in a window shorter than most of the mockup

The scene waited for 60% of the mockup to be on screen. A window shorter than
60% of the mockup, such as a wide browser with devtools docked at the bottom,
never reaches that, so the scene never started. It now starts when a line 60%
of the way down the mockup is on screen. That is the same moment in an
ordinary window, and a short window reaches it as the page scrolls past.
2026-11-15 16:39:08 +07:00
paseo-bot[bot] fe59a6c1a2 Keep an archived worktree restorable when reconciliation races its removal (#9753)
Archiving a worktree archives its record, then deletes the directory. A
reconciliation pass that listed the workspace before the archive went on
to read its checkout while the directory was being deleted, saw no Git
checkout, and wrote kind "directory" onto the archived record. Undo then
refused to recreate the worktree: "The archived workspace directory no
longer exists and cannot be recreated."

Reconciliation now skips a record that is archived when it re-reads it
and when it writes the update, as it already does when listing.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-15 09:34:41 +00:00
Mohamed Boudra a5015dba80 Pick the Usage host in Usage settings, and name it under the title (#9740)
* Share the host menu row, a plain sheet subtitle, and settings rows on a menu's rail

- HostMenuItem: a host as a menu option (status dot, check), extracted from the
  sidebar Display menu so other host pages reuse it.
- SheetHeaderSubtitle: one muted, truncating line under a sheet title.
- MenuSettingsRows: settings rows that share a menu surface with menu rows put
  their labels on the menu rows' label rail (menuLabelInset).

* Pick the Usage host in Usage settings, and name it under the title

The host picker leaves the top of the Usage modal body. With more than one
connected host, the cog's options lead with a Host row showing the current
host; it opens a page of the same surface (a flyout when wide, an in-place page
with Back in the compact sheet), so no sheet opens over another. The modal
names the shown host in the header subtitle, muted and truncating, so a long
host name never crowds the title. With one host, neither shows.

Picking a host on a phone no longer dismisses Usage: only the body is keyed by
host, so the compact sheet no longer remounts (and dismisses) on a switch.

* A Usage host switch shows the new host loading, not the old host's cards

The host's reports list was read with a list shape, which keeps the previous
key's data while the next loads. Its key differs only by host, so after a
switch the old host's cards showed under the new host's name until its reports
arrived. Read it as a value. The title-row controls are keyed by host too, so a
failed setting change on one host never shows on another.

* Put menu settings rows on the menu's existing content rail

menuRowContentInset already names the menu row's label rail next to the style
it mirrors; drop the duplicate menuLabelInset.
2026-11-15 14:37:14 +07:00
Mohamed Boudra da3be528e9 Open the project chooser with Cmd+N on New workspace (#9741)
* feat(app): open the project chooser with the new workspace shortcut

* fix(app): claim project shortcuts while projects load

* fix(app): keep project shortcuts owned by the focused screen
2026-11-15 14:30:58 +07:00
Adam C. Stephens dc2dfdab93 fix(client): recover routed hosts when their gateway reconnects (#9558)
* client: recover routed hosts when their gateway reconnects

Assisted-By: OpenAI GPT-6.1 Sol high in omp/18.16.0

* client: wake routed clients before waiting for probes

Assisted-By: OpenAI GPT-6.1 Sol high in omp/18.18.9
2026-11-15 07:06:52 +00:00
Mohamed Boudra fee32c9594 docs(hub): rename the GitHub connect button to Connect GitHub (#9738)
* docs(hub): explain connecting existing GitHub installations

* docs(hub): reduce the change to the Connect GitHub button label
2026-11-15 14:03:37 +07:00
5b1baa0e72 Mark a closed agent as read even when its provider session is gone (#9737)
* fix(server): clear attention without resuming closed agents

* fix(server): serialize attention clearing with reloads

* fix(server): keep cleared attention when a queued resume lands

Clearing attention on a closed agent no longer resumes its provider
session, so a resume that the loader queued behind the clear must not
restore the attention it read before the clear. Resume now reads
attention from the record inside the agent's lifecycle lane, as it
already does for metadata.

Adds a daemon regression test for a closed agent whose provider session
is gone: on main the clear_agent_attention request never gets a response
and the agent stays unread.

Co-authored-by: luke <luopeihuan@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: luke <luopeihuan@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 06:10:47 +00:00
fed8624516 fix(claude): state why a permission prompt appeared (#3950)
* fix(claude): state why a permission prompt appeared

### Linked issue

Closes #

No open issue tracks this. The behavior appeared in a live session on a
remote host.

### Type of change

- [x] Bug fix
- [ ] New feature
- [ ] Enhancement
- [ ] Refactor
- [ ] Docs

### Reasoning

A user selects Bypass and expects no prompts. A `permissions.ask` rule or a
`PreToolUse` hook outranks the mode, so the agent asks anyway, and the card
gives no cause. The user reads this as a broken mode.

Claude explains each prompt in `canUseTool`'s `decisionReason`. Paseo dropped
it. The card now shows that sentence, so a user can find the hook that owns
the rule instead of guessing.

Codex already puts its approval reason in `description`. Claude now matches,
so no new field and no protocol change are needed. `description` was unset on
every Claude permission request except `AskUserQuestion`, which keeps its own
summary because that spread still runs after this line.

### Goals

- The card shows the provider's reason when the provider sends one.
  Acceptance: a hook-forced prompt in Bypass shows the hook's
  `permissionDecisionReason`.
- A card without a reason looks exactly as it does today. Acceptance: no new
  row, no layout shift.
- Claude and Codex carry an approval reason the same way. Acceptance: one
  field, no provider-specific rendering.

### Non-goals

- Changing the CLI's precedence. A hook that asks still asks. Paseo shows
  the cause; it does not overrule the hook.
- Auto-approving hook-forced tools in Bypass. `f998ee169` did that and was
  reverted the same day. A blanket allow defeats a real safety hook.

### QA

Real Claude, web (browser), one live session in `bypassPermissions`, driven
through the app by Playwright.

```
npx playwright test --project=real-provider e2e/browser/permission-reason-card.real.spec.ts
1 passed (52.7s)
```

The run forces two prompts in the same session and captures both.

Forced by a `permissions.ask` rule, which sends no reason. This is the card
before this change:

![before-rule-forced-no-reason.png](https://github.com/user-attachments/assets/75d30b5f-b26c-48bb-a3dd-82074ae30d6c)

Forced by a `PreToolUse` hook that returns a reason, same session:

![after-hook-forced-with-reason.png](https://github.com/user-attachments/assets/78b8c01b-6adb-4107-92a7-7e45cd4357e8)

The spec asserts both states, so the screenshots are not the only proof: the
hook's sentence is absent from the first card and visible on the second.

Both cards come from one session on this branch. The pair shows the two
render paths, with a reason and without. It is not an old build beside a new
one. A card without a reason renders exactly as it did before this change,
which is why the first shot stands in for the old card.

The spec lives at
`packages/app/e2e/browser/permission-reason-card.real.spec.ts`. It is not
part of this commit.

Unit:

```
npx vitest run packages/server/src/server/agent/providers/claude/agent.test.ts
Test Files  1 passed (1)      Tests  75 passed (75)
```

Platforms: the daemon fills a field the card already rendered, so every
platform gets it through the existing path. Web verified above with real
Claude.

### Checklist

- [x] One focused change
- [x] `npm run typecheck` passes
- [x] `npm run lint` passes
- [x] `npm run format` passes
- [x] QA evidence
- [x] Tests added or updated where it made sense

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: preserve context for Claude permission reasons

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-11-15 12:01:44 +07:00
5f89e666d0 Keep New workspace plugin metadata when the first agent start is retried (#9734)
When New workspace hands its draft to the ready workspace's draft tab,
the text and setup move with it but the draft's plugin metadata did
not: it was cleared with the New workspace draft and never written to
the destination draft. A send after a failed first start then created
the agent without the metadata.

Copy the metadata into the destination draft at handoff, before the
New workspace draft is cleared.

Closes #9716

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Michael Wu <michaelmwu@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 05:00:54 +00:00
Mohamed Boudra 41d6dc9cd7 Reorder and turn off Usage cards, with matching sheet header and host picker controls (#9660)
* feat(app): reorder and turn off usage cards

Usage cards default to alphabetical order. Drag a card by its handle to
reorder it; the handle shows on hover on web and is always visible on
native and compact layouts. A card's switch turns it off: the card
collapses to its header and its windows leave the sidebar summary. Order
and off state are device-local usage preferences.

Inside a native bottom sheet, a drag gesture can now keep the touch:
the sheet's content pan waits for the list's drag pan
(react-native-draggable-flatlist gains `dragGestureRef`), and the sheet
scroll stops while a drag is active.

* feat(app): one icon button for every sheet header

The sheet header's back arrow, close X and actions now render through
SheetHeaderIconButton, which takes the header island chrome CompactHeader
uses: 26pt frame and 16pt glyph on pointer, 44pt and 20pt on touch, muted
colour, hover fill and tooltip. Edge buttons hang into the row padding so
their glyphs sit on the content rail; the row keeps its 64pt height and the
trailing actions shrink before the title collapses.

Migrates the diagnostic, import session, model browser, changelog, plugin
logs and sheet pane actions, and moves the tool-call and tool-call overview
sheets onto SheetHeaderView. The Usage refresh and options buttons size
their glyph from the header scope. adaptive-modal-sheet and the migrated
sheets leave the raw-pressable burn-down list.

* feat(app): host picker trigger and Usage host placement

HostFilter renders the shared ComboboxTrigger, as in Settings, and
truncates long host names. Sessions, Schedules, plugin surfaces, the
labels manager and Usage pick this up together.

The Usage modal puts the host picker at the top of its body on every
form factor. The header is title, Refresh all, Settings, X, so the
title stays whole on a phone.
2026-11-15 11:56:24 +07:00
Mohamed Boudra 6f685d37be Command center tabs, recent workspaces, and archived workspace search (#9689)
* feat(app): command center tabs, recent workspaces, and agents in workspace search

The command center gets All · Workspaces · Files · Actions tabs under the search
field. Tab and Shift+Tab rotate them on web, a tab keeps the query and starts at
its top, and Backspace on an empty field returns to All. ⌘P opens on Files in
place of the Files scope chip.

All leads with the three most recent workspaces other than the open one. Agent
rows are gone: an agent's title now finds its workspace, which shows the agent on
a third line and opens that agent's tab.

The selection now follows the first result until the user moves it, so rows that
load above it do not leave it on the row below.

* feat(app): archived workspaces in command center search

A query in All or Workspaces now also finds archived workspaces through
agent history, appended after the active matches. Archived rows show the
archive time and an Archived badge, and open the workspace's latest agent
where it can be unarchived. Hosts without history search contribute
nothing. History's archived badge moves into a shared ArchivedBadge.

* fix(app): put the command center divider between the field and the tabs

The tabs belong to the results, not the search field, so the rule now
separates the field from the tab bar. The tab-rotation test also pins the
list's top, since the tabs no longer live inside the header it measured.

* fix(app): rank agent matches by quality, keep background workspaces out of Recent, explain Files off-workspace

- Score each agent together with its workspace's title and subtitle, so a
  query split across them ("payments login") finds the row, and pick the
  workspace's own match only when it is as good as its best agent match.
  Across workspaces, match quality now ranks before own-versus-agent.
- Carry the workspace's background flag through the project summary and
  leave background workspaces out of Recent workspaces, as the sidebar
  does. A query still finds them.
- The Files tab outside a workspace says "Open a workspace to search
  files" instead of "No matches".

* fix(app): settle command center selection, name agents only for loose matches, drop Files off-workspace

- Name a workspace's agent only when the workspace's own text does not match
  contiguously and the agent does; a change-request hit is always an own match.
- Follow the first row only until every source for the query and tab has
  loaded, then keep the selection by ID so reorders do not move it.
- Projects stay loading until an online host's workspaces have arrived; the
  agent directory reports ready first.
- Outside a workspace there is no Files tab and All lists no files; the All
  placeholder names only what the available tabs search.
- The unqueried Workspaces list follows the sidebar's Show background setting.

* fix(app): keep Projects loading honest, rank own-name matches by their agents too, refresh archived search after archive

- The command center waits for online hosts' workspaces itself instead of
  through useProjects. useProjects held isLoading until workspaces
  hydrated, but a failed workspace request never hydrates, so the
  Projects screen could spin forever.
- A workspace that matches on its own name is ranked by the better of
  that match and its agents', so typing into an agent title shared with
  another workspace no longer drops it below that workspace and back.
- Archiving a workspace marks agent history stale. Archived search reused
  a cached page that still listed the agents as active, so a workspace
  archived in the last 30 seconds matched in neither list.

* fix(app): decide command center agent matches by match class

One rule now picks both what a workspace row shows and how it ranks. A
match falls in a class: every word at a word boundary, a word inside a
word, or scattered/misspelled. A row names its agent only when the agent
matches in a closer class than the workspace's own text, never on a
change-request hit. Rows rank by class, then own text ahead of an agent,
then score, then recency.

The previous rules chose the ranking candidate and the displayed
candidate separately, so each fix moved the edge case: positional tier
gaps named agents mid-word, and ranking by a hidden agent let a mid-word
name outrank a word-boundary one with nothing on screen to explain it.

* style(app): format command center workspace search import

* fix(app): settle archived search when no history host is reachable

With no searchable host online, the history query is disabled under a new
key, and keepPreviousData kept serving the old key's rows as placeholder
data. Archived search read that as still waiting for results, so the
command center selection never settled and followed every re-rank.
Previous results now stand in only while the query can run.
2026-11-15 10:58:15 +07:00
paseo-bot[bot]andClaude Opus 5.5 286d528b71 A full agent id never resolves to a different agent titled with it (#9731)
* fix(server): resolve a full agent id only to the agent with that id

The daemon's agent lookup tries the exact id, then a unique id prefix,
then an exact title. A full id that matched no agent fell through to the
title step, so `paseo agent delete <id>` hard-deleted a different agent
whose title was that id, and inspect, stop, update, mode, logs and open
acted on it the same way. Stop at the exact-id step when the identifier
is a complete agent id.

Closes #9727

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(cli): tag the full-agent-id guard as compatibility for older daemons

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(cli): say which commands the full-agent-id compatibility guard covers

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 03:51:15 +00:00
Mohamed Boudra 8ff657cd4a Command and file popovers stay against the composer, appear sooner, and stop jumping as results change (#9657)
* fix(app): keep the command popover against the composer as it resizes

The popover lives in a portal and positioned itself from an asynchronous
copy of the composer's rect. Any composer layout change painted first: a
wrapped line overlapped the popover, cleared text left it floating above
the composer, and a window resize left it at the old width, for one frame
on web and up to seconds on Android under JS load.

The popover now follows the composer's live size between measurements.
Native reads the anchor's height on the UI thread every frame with a
Reanimated frame callback; web commits the placement from a
ResizeObserver with flushSync, before paint. The keyboard translate view
in the portal becomes host-sized and box-none, so the popover moves
inside it without leaving its parent's bounds (Android hit-testing).

* docs(app): state where the native popover's fixed-bottom tracking stops holding

The native frame follows only the composer's height so it never depends on whether iOS's native-driver keyboard lift has reached the shadow tree. The centered New workspace composer and width changes still wait for the rest measurement, as before.

* fix(app): stop the native popover frame measuring a composer that left the tree

When the composer unmounts with the popover open, the portal removes the frame a commit later. A busy JS thread holds that commit for many frames, and every failed measure() logged a Reanimated warning: 31 to 111 per layout swap on Android. A gate written from the anchor's ref callback arrives on the UI thread only after that same JS task, so the frame now stops on its first failed measure and falls back to the rest measurement.

* fix(app): close the autocomplete popover while its retained panel is hidden

A workspace stays mounted with display: none while another route shows, and the popover stayed open inside it. On native its frame then failed to measure the hidden composer, stopped tracking, and kept the stale placement after the user came back. The popover now closes with its panel and reopens with a fresh measurement.

* fix(app): open the autocomplete list where it rests, with only the rows it shows

The list mounted every result and then scrolled itself to the composer edge.
On Android the scroll command reaches the screen one or two frames after the
rows, so each open and each result change painted the far end of the list, or
the selection 8 px low, before the resting frame. Mounting 115 commands also
held the popover's first frame for 500 ms to 1 s.

The list is now an inverted, virtualized list: its resting scroll position is
the composer edge, and the first commit holds the rows that fill the popover.
Each result set gets its own list, so a result change starts at the composer
edge with the same bounded commit. Arrow-key selection is revealed through the
list's own viewability and scroll-to-index.

* fix(app): rest the autocomplete list at the far end when the selection wraps there

Wrapping the arrow-key selection from the best match to the far end of a long
list scrolled there over several frames, because the rows in between were not
mounted. The list now gets a scroller that rests at the end the selection
wrapped to, so the jump is one frame and mounts only the rows it shows.

* fix(app): keep the selected autocomplete row in view when the popover gets shorter

The row the arrow keys selected was brought into view once. A popover that then
lost height, because the composer grew or the window shrank, cut that row off
while Enter still chose it. The list checks the selected row again whenever its
height changes.

The test that counted scroll calls through patched Element prototypes now reads
the list's resting scroll position instead.

* fix(app): keep the autocomplete list when the same results arrive again

The composer hands the popover a new list on every keystroke and refetch, often
holding the results it already showed. The list started over for each one: a
second mount before the filtered results, and a list the user had scrolled
returned to its rest. A result set is now its options in order.
2026-11-15 10:42:13 +07:00
paseo-bot[bot] 45a991415f Keep compiled workspace packages out of the Playwright transform (#9733)
Tests import packages/*/dist at run time. On a cold cache each worker
transpiled the same files and wrote them into Playwright's shared
transform cache without locking, so a worker could read an entry
mid-write, load an empty module, and fail with "does not provide an
export named".

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-15 03:39:17 +00:00
Mohamed Boudra 1ee13fff51 Open image previews without a flash (#9698)
* fix(app): open the image preview without a flash

Opening an image preview showed "Couldn't load image", then the image
stretched to the whole window, then a blank frame, before the fitted image.

- The attachment preview URL hook returned null both while resolving and on
  failure, so the lightbox showed the error while the URL resolved. It now
  reports loading, ready, or failed.
- ZoomableImage rendered the image unfitted until its natural size arrived.
  It now renders nothing until it can fit the image, and keeps it hidden until
  it has loaded, so the image appears with its toolbar instead of after it.
- On Android, React Native lays a modal out at the screen minus the system
  bars for its first frames, then grows it, so the backdrop snapped down; the
  Modal wrapper sizes its content to the window from the first frame. The
  lightbox's own SafeAreaProvider measured the dialog window, whose bottom
  inset flips 24, 0, 24; it now reads the window insets via WindowSafeArea.

* fix(app): keep a reopened attachment preview from reading its released URL

Closing a preview releases its URL (revoked on web and desktop), but the hook kept the result for that attachment. Reopening the same attachment returned the revoked URL until the new lookup landed, so the image failed to load and "Couldn't load image" showed for a frame. The result is now dropped when its URL is released. The frame test also counts an image as visible only once it is painted, so a preview that never appears fails.

* test(app): wait for the stale file-link lookup to land before asserting

The test resolved the lookup and ended. React Query delivers the result on a timer, which could fire after jsdom was torn down ("window is not defined" failed app-tests in CI), and the no-open assertion could run before the stale result arrived at all.

* fix(app): show images the browser reports no natural size for

ZoomableImage waits for the image's size before painting it. React Native Web's Image.getSize never answers for an image that loads with a natural size of 0 (an SVG without width and height in some browsers), so the preview stayed blank with no error. The web build now measures the image itself and falls back to the browser's default replaced-element size, 300x150.

* fix(app): retry a reopened failed attachment preview from loading

The hook kept a failed result after its lookup was torn down, so reopening the same attachment showed "Couldn't load image" until the retry landed. Every cleanup now drops the result for its key.

* test(app): name the wait for a file-link lookup's delivery
2026-11-15 10:12:53 +07:00
Mohamed Boudra 635b63a22b fix(app): open the command popover in place on native (#9647)
Reanimated computes a style's first value when its hook first runs and sends
later values from an effect after the mounting commit, which native paints.
The popover's height bound lived in the component that renders while the
popover is closed, so it started at 0. Every open painted one frame with the
popover at its minimum height, hanging down over the composer, before the
measured bound arrived. The bound now lives in a frame that mounts only with
a measured anchor.

Browser tests could not apply animated style updates at all: Reanimated loads
its react-native-web style helpers with require, which Vite's ESM serving
lacks, so the first update after mount crashed the component.
2026-11-15 10:12:42 +07:00
paseo-bot[bot]andClaude Opus 5.5 008eeaf18e Wait for the shortcut's New tab before counting New tabs (#9725)
A workspace opens on a New tab of its own, so waiting for the first
New tab to show passed before the Ctrl+T tab rendered. On a slow runner
the count was read too early, and the plus-menu check then saw the
shortcut's tab arrive as an extra one.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 03:01:32 +00:00
ed8aa9ada8 Archive, detach and reload agents that agent ls leaves out (#9726)
* fix(cli): fall back to fetchAgent in archive, reload, and detach (#9696)

* fix(cli): keep a full agent id exact when archive falls back to the daemon

The fallback added for agents missing from the listing asks the daemon
with fetch_agent, which tries the id, then an id prefix, then an exact
title. A full id that matches no agent could therefore resolve to a
different agent titled with that id, and archive, detach, or reload
acted on it. Accept the daemon's answer for a full id only when it is
that id, as the listing lookup already does since #9680.

Co-authored-by: xunWhale <2338923971@qq.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(cli): cover detach past the listing page and archive with a missing provider

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cli): report a failed detach lookup as DETACH_FAILED

detach now asks the daemon when the listing has no match, and the
daemon rejects an ambiguous prefix. Wrap that failure in DETACH_FAILED
as archive and reload already do, instead of letting it out raw.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: xunWhale <2338923971@qq.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 01:54:43 +00:00
paseo-bot[bot]andClaude Opus 5.5 7286d3ef81 Wait for Command Center rows to settle before counting renders (#9714)
The live-search test reset the row render counter after a fixed 600ms.
On a loaded runner FlatList was still mounting overscan rows after that,
and each late mount counted as a render of an unchanged row.

Reset the counter only after a quiet period with no row render.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 00:01:22 +00:00
paseo-bot[bot]andmagiclaw a349856600 Keep desktop startup pending while its daemon becomes ready (#9043)
* fix(desktop): wait for an already-starting daemon to become ready

* test(desktop): cover a daemon stopping during startup

Keep incomplete host registration out of startup recovery when a delayed daemon exits before readiness.

Co-authored-by: magiclaw <930198090@qq.com>

---------

Co-authored-by: magiclaw <930198090@qq.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-14 23:55:17 +00:00
6016d3582e Show each ACP model's own thinking levels when the provider reports them (#9717)
* 修复 ACP 模型档位按精确型号读取

Co-authored-by: Alfred <alfred@mkiio.com>
Alfred-Model: auto/gpt-6.1-sol
Alfred-Thinking-Effort: high

* fix(acp): keep shared thinking options for models without their own

A model that reports its own reasoning levels in its ACP metadata uses
them. A model that reports none keeps the session's thinking options, as
before, instead of losing its thinking picker.

Co-authored-by: sj <doskoi.panda@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: sj <doskoi.panda@gmail.com>
Co-authored-by: Alfred <alfred@mkiio.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 22:58:09 +00:00
222 changed files with 10516 additions and 2085 deletions
+1 -6
View File
@@ -530,7 +530,7 @@
}
},
// Press feedback is owned by the primitives in components/ui. Raw pressable burn-down:
// 94 files. They may only leave this list.
// 85 files. They may only leave this list.
{
"files": [
"packages/app/src/components/ui/**/*.{ts,tsx}",
@@ -539,12 +539,9 @@
"packages/app/src/agent-skills/selection-sheet.tsx",
"packages/app/src/agent-stream/chat-outline/rail.web.tsx",
"packages/app/src/agent-stream/view.tsx",
"packages/app/src/changelog/internal/changelog-sheet.tsx",
"packages/app/src/command-center/command-center.tsx",
"packages/app/src/components/adaptive-modal-sheet.tsx",
"packages/app/src/components/add-project-flow.tsx",
"packages/app/src/components/agent-list.tsx",
"packages/app/src/components/app-diagnostic-sheet.tsx",
"packages/app/src/components/attachment-lightbox.tsx",
"packages/app/src/components/attachment-pill.tsx",
"packages/app/src/components/combined-model-selector.tsx",
@@ -583,7 +580,6 @@
"packages/app/src/components/terminal-emulator-native-grid.native.tsx",
"packages/app/src/components/terminal-pane.tsx",
"packages/app/src/components/toast-host.tsx",
"packages/app/src/components/tool-call-sheet.tsx",
"packages/app/src/components/welcome-screen.tsx",
"packages/app/src/components/workspace-hover-card.tsx",
"packages/app/src/components/zoomable-viewport/toolbar.tsx",
@@ -619,7 +615,6 @@
"packages/app/src/screens/workspace/scripts/workspace-scripts-button.tsx",
"packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx",
"packages/app/src/screens/workspace/workspace-tab-presentation.tsx",
"packages/app/src/tool-calls/detail-level/overview/sheet.tsx",
"packages/app/src/usage/sidebar-item.tsx",
"packages/app/src/usage/window-bar.tsx",
"packages/app/src/workspace-labels/manager-modal.tsx",
+3 -1
View File
@@ -114,6 +114,8 @@ Five primitives. The pick is determined by option count, the need to search, and
`<AdaptiveModalSheet>` owns the presentation. Its content inset — the gutter that puts sheet content on the same rails as the sheet header — and compact bottom safe-area padding are the sheet's, not the caller's. A caller declares layout intent through `contentStyle` and never branches on form factor to add its own margins. If a sheet's first snap point is shorter than its header, content, and safe-area clearance, raise that snap point rather than moving the sheet container.
The sheet header (`SheetHeaderView`) is the only sheet title row. A sheet that brings its own Gorhom modal still renders it. Back, Close and every action passed through `header.actions` are `SheetHeaderIconButton`, so all of them get the header island's chrome, as `CompactHeader` does: one frame, glyph, colour and hover. The first and last buttons hang into the row's padding, so their glyphs sit on the content rail.
`confirmDialog` is for destructive yes/no and imperative confirmation. Promise-based: `await confirmDialog({ destructive: true, ... })`. Anything where a wrong click loses work.
Three themes is `DropdownMenu`. Thirty hosts is `Combobox`. A label and a value is `AdaptiveModalSheet`. "Are you sure?" is `confirmDialog`.
@@ -134,7 +136,7 @@ Cards inside a section sit closer than sections. Rows inside a card touch — on
Rows have generous vertical padding: roughly 16px of content plus 16px of vertical padding for settings rows, 8–12px for sidebar list items where many rows must fit. Compressing rows below the established density to fit more on the screen is wrong. Too many rows means more cards or more sections, not smaller rows.
Rows revealed under a `<SettingsCollapsibleRow>` are nested rows, one step smaller than card rows: 8px of vertical padding, `fontSize.sm` text, `sm` controls, and one fixed height so rows with and without a trailing button line up. The collapsible row sets this for everything under it (`packages/app/src/components/settings/row-density.tsx`). Never pass a density to a row, and never pad or resize a nested row by hand.
Rows revealed under a `<SettingsCollapsibleRow>` are nested rows, one step smaller than card rows: 8px of vertical padding, `fontSize.sm` text, `sm` controls, and one fixed height so rows with and without a trailing button line up. The collapsible row sets this for everything under it (`packages/app/src/components/settings/row-density.tsx`). Never pass a density to a row, and never pad or resize a nested row by hand. Settings rows that share a menu surface with menu rows, such as Usage's options, go inside `<MenuSettingsRows>`, which puts their labels on the menu rows' label rail (`menuRowContentInset`).
The whitespace is the design.
+39 -9
View File
@@ -7,12 +7,12 @@ canonical files and are trying to add or change one.
## Canonical files
| File | Use case |
| ---------------------------------------- | ----------------------------------------------------------------- |
| `components/ui/combobox.tsx` | Anchored picker with search; mobile falls back to bottom sheet |
| `components/ui/tooltip.tsx` | Non-interactive hover/long-press tooltip |
| `components/ui/hover-card.tsx` | Desktop-web hover card the pointer can move onto; Portal |
| `components/ui/autocomplete-popover.tsx` | Slash-command autocomplete anchored to the focused composer input |
| File | Use case |
| ------------------------------------- | ----------------------------------------------------------------- |
| `components/ui/combobox.tsx` | Anchored picker with search; mobile falls back to bottom sheet |
| `components/ui/tooltip.tsx` | Non-interactive hover/long-press tooltip |
| `components/ui/hover-card.tsx` | Desktop-web hover card the pointer can move onto; Portal |
| `components/ui/autocomplete-popover/` | Slash-command autocomplete anchored to the focused composer input |
Each handles a different mix of concerns: combobox owns input focus, tooltip is
non-interactive, hover-card is web-only desktop, autocomplete keeps the composer
@@ -65,6 +65,10 @@ Two escape hatches in the codebase:
Android can detach the IME from an underlying TextInput. Fine for combobox
(it has its own input) and tooltip (no input). **Not** fine for autocomplete
(the composer's input must stay focused so the user keeps typing).
Use `@/components/ui/modal`, not React Native's `Modal`. On Android, React Native lays out a
modal's first frames at the screen size minus the status and navigation bars, then grows it to
the full edge-to-edge window, so a full-screen backdrop stops short of the bottom and snaps
down. The wrapper sizes the content to the window from the first frame.
- **`<Portal>` from `@gorhom/portal`** (hover-card, autocomplete-popover) —
hoists the React subtree to a fixed mount point whose bounds cover the
screen. Same window, same IME, hit-test works because the new parent is
@@ -280,6 +284,22 @@ popover or leaves it behind the keyboard. `measureAtRest` measures through
Fabric's transform-free `measureLayout` instead. Re-measure when `isMoving`
settles; the composer's resting position changes as the draft grows.
A measured rect is an asynchronous copy, so it lands after the anchor's new
layout has painted. The composer resizes while the autocomplete is open: a
wrapped line, cleared text, a window resize. On web a `ResizeObserver` reads
the anchor's rect against the layer and commits the placement with `flushSync`,
which runs after layout and before paint. On native a Reanimated frame callback
reads only the anchor's height on the UI thread and holds its bottom edge at
the rest measurement. A height carries no transform. On iOS the absolute
position does not reliably include one: the keyboard lift runs on the native
driver and reaches the shadow tree only when the view re-renders. The
fixed-bottom model is wrong for the centered New workspace composer on tablets,
which grows around its middle, and for width or x changes. The popover is off
there by half the growth, or by the old width, until the next rest measurement.
Keep the portal's `KeyboardTranslateView` host-sized with
`pointerEvents="box-none"` and position the popover inside it, so the popover
moves without leaving its parent's bounds (Gotcha 1).
The provider also reconciles iOS from the controller's native `onEnd` event.
The controller's stock iOS shared values update at move start and during an
interactive move, but not at the terminal event, so JS contention can otherwise
@@ -422,9 +442,11 @@ gets that 0 and draws over the navigation bar.
- A Gorhom sheet reads the inset inside its children, which render in the app's sheet host. See
`CompactSheetBody`, `MenuSheetScrollView` and `ComboboxSheetFrame`.
- An RN `Modal` passes the call site's context straight through, so put a `SafeAreaProvider`
inside it and read the inset below that. The modal is its own window, and the provider measures
it. See `attachment-lightbox.tsx`.
- An RN `Modal` passes the call site's context straight through, so wrap its content in
`WindowSafeArea` from `@/window-layout` and read the inset below that. It provides the insets of
the whole window, published by `WindowLayoutProvider`. Do not put a `SafeAreaProvider` inside the
modal: on Android it measures the dialog window, whose bottom inset reads 24, then 0, then 24
again over the first frames, and everything padded by it jumps. See `attachment-lightbox.tsx`.
## Recipe for a new anchored panel
@@ -453,6 +475,14 @@ Before you write a new one, ask:
`top` paints over its anchor until the next placement lands, about 150 ms
on Android. Web hides this because React commits the resize and the
re-placement in one task.
7. **Does the panel hold a list that reads away from its anchor?** Make the
anchor edge the list's resting scroll position: an inverted list, as in
`components/ui/autocomplete.tsx`. On Android a scroll command issued after
the rows commit reaches the screen one or two frames after them, so a list
scrolled to its anchor edge paints its far end first. Give each result set
its own list with a `key`, so it starts at the anchor edge with the rows
that fill the panel. A list that mounts every result holds the panel's
first frame until the last row is ready.
Then copy the closest canonical file and trim.
+2
View File
@@ -59,6 +59,8 @@ Register keyboard handling with `useOverlayRegistration` from `@/keyboard`.
Pass the surface's open state, layer, handled keys and `onKeyDown`. The highest
layer gets first refusal; the latest registration wins a tie. Native registration
does not need a DOM ref. Web adds focus trapping and restoration through its adapter.
Declaring Tab takes it from the web focus trap and hands it to `onKeyDown`; the command
center rotates its tabs with it. Native sources drop Tab from the declarations.
Capture a global overlay’s layer in state for its opening lifetime. Memo caches can
be discarded or removed by React Compiler; recomputing against the registry lets an
+4 -3
View File
@@ -873,9 +873,10 @@ await paseo.agents.ref(agentId).timeline.append({
The daemon stamps the runtime `pluginId`; plugin code never supplies it. Reusing the same `id`
replaces the previous row from that plugin on live clients and fresh timeline fetches. Rows live in
the daemon's in-memory timeline and survive scroll, refetch, and reconnect, but not a daemon
restart. A row without an installed renderer shows an unavailable placeholder. Serialized `data`
must be at most 64 KiB; the daemon rejects a larger append instead of storing a payload that cannot
be rendered intact. The daemon advertises this RPC through
restart. A row without an installed renderer shows an unavailable placeholder. `kind` must match
`/^[a-z][a-z0-9-]*$/`, the rule `addTimelineRenderer` enforces; the daemon rejects any other kind.
Serialized `data` must be at most 64 KiB; the daemon rejects a larger append instead of storing a
payload that cannot be rendered intact. The daemon advertises this RPC through
`server_info.features.pluginTimelineItems`.
## Contribute client slash commands
@@ -5,7 +5,7 @@ import { seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { openAgentRoute } from "../support/helpers/mock-agent";
import { resetSeededPageState, openSessions } from "../support/helpers/archive-tab";
test("agent list age advances and command center says just now for a fresh agent", async ({
test("agent list age advances and command center says just now for a fresh workspace", async ({
page,
}) => {
const session = await seedMockAgentWorkspace({
@@ -23,10 +23,10 @@ test("agent list age advances and command center says just now for a fresh agent
const commandCenter = await openCommandCenter(page);
await commandCenter.getByTestId("command-center-input").fill("Relative time agent");
const agentResult = commandCenter.getByTestId(
`command-center-agent-${getServerId()}:${session.agentId}`,
const workspaceResult = commandCenter.getByTestId(
`command-center-workspace-${getServerId()}:${session.workspaceId}`,
);
await expect(agentResult.getByTestId("command-center-result-timestamp")).toContainText(
await expect(workspaceResult.getByTestId("command-center-result-timestamp")).toContainText(
"just now",
);
await page.keyboard.press("Escape");
@@ -1,5 +1,12 @@
import { expect, test } from "../support/fixtures";
import { openCommandCenter } from "../support/helpers/command-center";
import { openSettings } from "../support/helpers/app";
import {
closeCommandCenter,
expectCommandCenterTab,
expectCommandCenterTabs,
openCommandCenter,
selectCommandCenterTab,
} from "../support/helpers/command-center";
import {
delayDirectorySuggestionResponses,
holdDirectorySuggestionResponses,
@@ -17,6 +24,7 @@ import {
} from "../support/helpers/command-center-scroll";
import { expectFileTabOpen } from "../support/helpers/file-explorer";
import { gotoWorkspace } from "../support/helpers/launcher";
import { getServerId } from "../support/helpers/server-id";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
const FILE_PATH =
@@ -67,7 +75,7 @@ test("workspace file search stays geometrically stable through delayed loading a
const panel = page.getByTestId("command-center-panel");
await expect(panel).toBeVisible({ timeout: 30_000 });
await expect(panel.getByRole("button", { name: "Files" })).toBeVisible();
await expectCommandCenterTab(panel, "Files");
await startCommandCenterLayoutObservation(page);
await panel.getByTestId("command-center-input").fill(FILE_NAME);
@@ -97,26 +105,90 @@ test("workspace file search stays geometrically stable through delayed loading a
}
});
test("dropping the files scope leaves the search row the same height", async ({ page }) => {
test("command center tabs rotate with Tab and never move the header", async ({ page }) => {
const seeded = await seedWorkspace({
repoPrefix: "command-center-file-search-scope-",
title: "Scope chip height",
repoPrefix: "command-center-tabs-",
title: "Tab rotation",
});
try {
await gotoWorkspace(page, seeded.workspaceId);
await page.keyboard.press("Meta+P");
const panel = page.getByTestId("command-center-panel");
await expect(panel).toBeVisible({ timeout: 30_000 });
const header = panel.getByTestId("command-center-header");
const scoped = await header.boundingBox();
const input = panel.getByTestId("command-center-input");
await page.getByTestId("command-center-files-scope").click();
await expect(page.getByTestId("command-center-files-scope")).toHaveCount(0);
const unscoped = await header.boundingBox();
await test.step("⌘P opens on Files and Backspace on an empty field returns to All", async () => {
await page.keyboard.press("Meta+P");
await expect(panel).toBeVisible({ timeout: 30_000 });
await expectCommandCenterTab(panel, "Files");
await page.keyboard.press("Backspace");
await expectCommandCenterTab(panel, "All");
});
expect(scoped?.height).toBe(unscoped?.height);
const results = panel.getByTestId("command-center-results");
const headerOnAll = await header.boundingBox();
const resultsTopOnAll = (await results.boundingBox())?.y;
await test.step("Tab walks forward and wraps; Shift+Tab walks back", async () => {
await page.keyboard.press("Tab");
await expectCommandCenterTab(panel, "Workspaces");
await page.keyboard.press("Tab");
await expectCommandCenterTab(panel, "Files");
await page.keyboard.press("Tab");
await expectCommandCenterTab(panel, "Actions");
expect(await header.boundingBox()).toEqual(headerOnAll);
expect((await results.boundingBox())?.y).toBe(resultsTopOnAll);
await page.keyboard.press("Tab");
await expectCommandCenterTab(panel, "All");
await page.keyboard.press("Shift+Tab");
await expectCommandCenterTab(panel, "Actions");
await page.keyboard.press("Shift+Tab");
await expectCommandCenterTab(panel, "Files");
});
await test.step("rotating keeps the query", async () => {
await input.fill("tab rot");
await page.keyboard.press("Tab");
await expectCommandCenterTab(panel, "Actions");
await expect(input).toHaveValue("tab rot");
});
await test.step("clicking a tab keeps typing in the search field", async () => {
await selectCommandCenterTab(panel, "Workspaces");
await page.keyboard.press("Backspace");
await expect(input).toHaveValue("tab ro");
await expect(
panel.getByTestId(`command-center-workspace-${getServerId()}:${seeded.workspaceId}`),
).toBeVisible();
});
await test.step("⌘K opens on All", async () => {
await page.keyboard.press("Escape");
await expect(panel).not.toBeVisible();
await page.keyboard.press("Meta+K");
await expectCommandCenterTab(panel, "All");
});
await test.step("in a workspace every tab is offered", async () => {
await expectCommandCenterTabs(panel, ["All", "Workspaces", "Files", "Actions"]);
});
await test.step("outside a workspace there is no Files tab, and Tab skips it", async () => {
const outside = { workspaceOpen: false };
await closeCommandCenter(page);
await openSettings(page);
await page.keyboard.press("Meta+K");
await expectCommandCenterTab(panel, "All", outside);
await expectCommandCenterTabs(panel, ["All", "Workspaces", "Actions"]);
await page.keyboard.press("Tab");
await expectCommandCenterTab(panel, "Workspaces", outside);
await page.keyboard.press("Tab");
await expectCommandCenterTab(panel, "Actions", outside);
await page.keyboard.press("Tab");
await expectCommandCenterTab(panel, "All", outside);
await page.keyboard.press("Shift+Tab");
await expectCommandCenterTab(panel, "Actions", outside);
});
} finally {
await seeded.cleanup();
}
@@ -7,8 +7,8 @@ import {
searchCommandCenter,
warmCommandCenterRows,
expectOnlyCommandCenterRowRendered,
expectCommandCenterAgentWindow,
scrollToOldestCommandCenterAgent,
expectCommandCenterWorkspaceWindow,
scrollToOldestCommandCenterWorkspace,
} from "../support/helpers/command-center-performance";
import { gotoAppShell } from "../support/helpers/app";
import {
@@ -19,7 +19,7 @@ import {
expectPrimaryCommandCenterActions,
expectTightTwoLineResultSpacing,
expectVisibleKeyboardNavigationNotToScroll,
expectWorkspaceResultsToBeLimitedUntilSearch,
expectRecentWorkspacesUntilSearch,
observeCommandCenterScroll,
openCommandCenterWithKeyboard,
} from "../support/helpers/command-center-scroll";
@@ -63,9 +63,9 @@ test("Command Center preserves manual scrolling through live updates and resumes
await expectCommandCenterToRemainAtTheTop(page);
await expectPrimaryCommandCenterActions(panel);
await expectVisibleKeyboardNavigationNotToScroll(page, panel);
// The empty query lists the most recent workspaces, so the last one seeded leads it.
// All leads with the most recent workspaces, so the last one seeded leads it.
await expectTightTwoLineResultSpacing(panel, "Command Center Scroll 08");
await expectWorkspaceResultsToBeLimitedUntilSearch(panel);
await expectRecentWorkspacesUntilSearch(panel);
const offset = await scrollCommandCenterAwayFromSelection(page, panel);
await seeded.client.setWorkspaceTitle(newestWorkspaceId, "Command Center Scroll renamed");
await expect(panel.getByText("Command Center Scroll renamed", { exact: true })).toHaveCount(1);
@@ -76,42 +76,50 @@ test("Command Center preserves manual scrolling through live updates and resumes
}
});
test("live search updates render only the changed row and keep agents virtualized", async ({
test("live search updates render only the changed row and keep workspaces virtualized", async ({
page,
}) => {
test.setTimeout(120_000);
const seeded = await seedSearchableCommandCenter();
const agentWorkspaceRowId = `workspace:${getServerId()}:${seeded.workspaceId}`;
try {
await observeCommandCenterRowRenders(page);
await gotoAppShell(page);
const panel = await openCommandCenterWithKeyboard(page);
await searchCommandCenter(panel, "Search agent");
await expect(panel.getByText("Search agent 059", { exact: true })).toBeVisible();
await warmCommandCenterRows(page);
await expectCommandCenterAgentWindow(panel, seeded.agents.length);
await seeded.client.updateAgent(seeded.agents[59].id, { name: "Search agent 059 revised" });
await expect(panel.getByText("Search agent 059 revised", { exact: true })).toBeVisible();
await expectOnlyCommandCenterRowRendered(
page,
`agent:${getServerId()}:${seeded.agents[59].id}`,
);
await test.step("renaming a matched agent re-renders only its workspace", async () => {
await searchCommandCenter(panel, "Search agent 002");
const agentLine = panel
.getByTestId(`command-center-workspace-${getServerId()}:${seeded.workspaceId}`)
.getByTestId("command-center-workspace-agent");
await expect(agentLine).toHaveText("Search agent 002");
await warmCommandCenterRows(page);
await seeded.client.updateAgent(seeded.agents[2].id, { name: "Search agent 002 revised" });
await expect(agentLine).toHaveText("Search agent 002 revised");
await expectOnlyCommandCenterRowRendered(page, agentWorkspaceRowId);
});
await searchCommandCenter(panel, "Search workspace");
await expect(panel.getByText("Search workspace 039", { exact: true })).toBeVisible();
await warmCommandCenterRows(page);
await seeded.client.setWorkspaceTitle(seeded.workspaces[39].id, "Search workspace 039 revised");
await expect(panel.getByText("Search workspace 039 revised", { exact: true })).toBeVisible();
await expectOnlyCommandCenterRowRendered(
page,
`workspace:${getServerId()}:${seeded.workspaces[39].id}`,
);
await test.step("renaming a workspace re-renders only its row", async () => {
await searchCommandCenter(panel, "Search workspace");
await expect(panel.getByText("Search workspace 039", { exact: true })).toBeVisible();
await warmCommandCenterRows(page);
await seeded.client.setWorkspaceTitle(
seeded.workspaces[39].id,
"Search workspace 039 revised",
);
await expect(panel.getByText("Search workspace 039 revised", { exact: true })).toBeVisible();
await expectOnlyCommandCenterRowRendered(
page,
`workspace:${getServerId()}:${seeded.workspaces[39].id}`,
);
});
await searchCommandCenter(panel, "Search agent");
await warmCommandCenterRows(page);
await scrollToOldestCommandCenterAgent(panel);
await expect(panel.getByText("Search agent 000", { exact: true })).toBeVisible();
await expectCommandCenterAgentWindow(panel, seeded.agents.length);
await test.step("a long list mounts only a window of rows", async () => {
await expectCommandCenterWorkspaceWindow(panel, seeded.workspaces.length + 1);
await scrollToOldestCommandCenterWorkspace(panel);
await expect(panel.getByText("Search workspace 000", { exact: true })).toBeVisible();
await expectCommandCenterWorkspaceWindow(panel, seeded.workspaces.length + 1);
});
} finally {
await seeded.cleanup();
}
@@ -4,7 +4,12 @@ import { test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { createIdleAgent } from "../support/helpers/archive-tab";
import { expectAgentTabActive } from "../support/helpers/launcher";
import { openCommandCenter } from "../support/helpers/command-center";
import {
commandCenterSection,
expectCommandCenterTab,
openCommandCenter,
selectCommandCenterTab,
} from "../support/helpers/command-center";
import { addOfflineHostAndReload } from "../support/helpers/hosts";
import { expectAppRoute } from "../support/helpers/route-assertions";
import { seedWorkspace } from "../support/helpers/seed-client";
@@ -21,9 +26,7 @@ const AGENT_TITLE = "Fix checkout retries";
test.describe("Command center workspaces", () => {
test.describe.configure({ timeout: 180_000 });
test("agent and workspace results retain their icons and overlay live status", async ({
page,
}) => {
test("workspace results overlay live status", async ({ page }) => {
const workspace = await seedRunningMockAgentWorkspace({
repoPrefix: "command-center-status-",
title: "Review checkout retries",
@@ -34,15 +37,13 @@ test.describe("Command center workspaces", () => {
await openAgentRoute(page, workspace);
await expectAgentTabActive(page, workspace.agentId);
const panel = await openCommandCenter(page);
const agent = panel.getByTestId(`command-center-agent-${getServerId()}:${workspace.agentId}`);
// Recent workspaces leaves out the open one; the Workspaces tab lists every workspace.
await selectCommandCenterTab(panel, "Workspaces");
const row = panel.getByTestId(
`command-center-workspace-${getServerId()}:${workspace.workspaceId}`,
);
await expect(agent.getByRole("progressbar", { name: "Agent running" })).toBeVisible();
await expect(row.getByRole("progressbar", { name: "Agent running" })).toBeVisible();
await expect(agent.getByRole("img", { name: "Mock agent" })).toBeVisible();
await workspace.client.cancelAgent(workspace.agentId);
await expect(agent.getByRole("progressbar")).toHaveCount(0);
await expect(row.getByRole("progressbar")).toHaveCount(0);
} finally {
await workspace.cleanup();
@@ -80,9 +81,11 @@ test.describe("Command center workspaces", () => {
});
const panel = await openCommandCenter(page);
await selectCommandCenterTab(panel, "Workspaces");
const row = panel.getByTestId(
`command-center-workspace-${getServerId()}:${seeded.workspaceId}`,
);
const agentLine = row.getByTestId("command-center-workspace-agent");
await expect(row).toBeVisible({ timeout: 30_000 });
await expect(row).toContainText(WORKSPACE_TITLE);
await expect(row).toContainText(WORKSPACE_BRANCH);
@@ -92,65 +95,151 @@ test.describe("Command center workspaces", () => {
await expect(subtitle).toContainText(PRIMARY_HOST_LABEL);
await expect(subtitle).toContainText(seeded.projectDisplayName);
await expect(subtitle).toContainText(WORKSPACE_BRANCH);
await expect(agentLine).toHaveCount(0);
// The agent subtitle is unchanged by the shared-helper refactor.
const agentRow = panel.getByTestId(`command-center-agent-${getServerId()}:${agent.id}`);
await expect(agentRow).toContainText(AGENT_TITLE);
await expect(agentRow).toContainText(PRIMARY_HOST_LABEL);
await expect(agentRow).toContainText(WORKSPACE_TITLE);
await expect(agentRow).not.toContainText(seeded.repoPath);
const workspaceSectionTop = await panel
.getByText("Workspaces", { exact: true })
.evaluate((element) => element.getBoundingClientRect().top);
const agentSectionTop = await panel
.getByText("Agents", { exact: true })
.evaluate((element) => element.getBoundingClientRect().top);
expect(workspaceSectionTop).toBeLessThan(agentSectionTop);
const input = panel.getByTestId("command-center-input");
await input.fill(PRIMARY_HOST_LABEL);
await expect(row).toBeVisible();
await expect(agentRow).toBeVisible();
await input.fill(WORKSPACE_BRANCH);
await expect(row).toBeVisible();
await expect(agentRow).not.toBeVisible();
await input.fill(WORKSPACE_TITLE);
await expect(row).toBeVisible();
await expect(agentRow).toBeVisible();
await input.fill(seeded.repoPath);
await expect(agentRow).toBeVisible();
await expect(row).not.toBeVisible();
// The project name is now part of the workspace searchText.
await input.fill(seeded.projectDisplayName);
await expect(row).toBeVisible();
await input.fill(AGENT_TITLE);
await expect(agentRow).toBeVisible();
await expect(row).not.toBeVisible();
await input.fill(WORKSPACE_TITLE);
await page.keyboard.press("Enter");
await expectAppRoute(page, buildHostWorkspaceRoute(getServerId(), seeded.workspaceId), {
timeout: 30_000,
await test.step("a workspace matches on its own fields", async () => {
const input = panel.getByTestId("command-center-input");
for (const query of [
PRIMARY_HOST_LABEL,
WORKSPACE_BRANCH,
WORKSPACE_TITLE,
seeded.projectDisplayName,
]) {
await input.fill(query);
await expect(row).toBeVisible();
await expect(agentLine).toHaveCount(0);
}
// `cwd` is never shown, so it must never match.
await input.fill(seeded.repoPath);
await expect(row).not.toBeVisible();
});
await test.step("find an agent and open its workspace tab", async () => {
const agentPanel = await openCommandCenter(page);
await agentPanel.getByTestId("command-center-input").fill(AGENT_TITLE);
await test.step("an agent title finds its workspace and names the agent", async () => {
await panel.getByTestId("command-center-input").fill(AGENT_TITLE);
await expect(agentLine).toHaveText(AGENT_TITLE);
await expect(panel.getByText("Agents", { exact: true })).toHaveCount(0);
await page.keyboard.press("Enter");
await expectAgentTabActive(page, agent.id);
});
await test.step("a workspace match opens the workspace", async () => {
const searchAgain = await openCommandCenter(page);
await expectCommandCenterTab(searchAgain, "All");
await searchAgain.getByTestId("command-center-input").fill(WORKSPACE_TITLE);
await page.keyboard.press("Enter");
await expectAppRoute(page, buildHostWorkspaceRoute(getServerId(), seeded.workspaceId), {
timeout: 30_000,
});
});
} finally {
await seeded.cleanup();
}
});
test("All leads with the three most recent workspaces other than the open one", async ({
page,
}) => {
const older = [];
for (const title of ["Recent oldest", "Recent third", "Recent second", "Recent newest"]) {
older.push(await seedWorkspace({ repoPrefix: "command-center-recent-", title }));
}
const current = await seedWorkspace({
repoPrefix: "command-center-recent-",
title: "Open one",
});
try {
await page.goto(buildHostWorkspaceRoute(getServerId(), current.workspaceId));
const panel = await openCommandCenter(page);
await expectCommandCenterTab(panel, "All");
await expect(commandCenterSection(panel, "Recent workspaces")).toBeVisible();
const rowOf = (workspaceId: string) =>
panel.getByTestId(`command-center-workspace-${getServerId()}:${workspaceId}`);
await expect(rowOf(older[3].workspaceId)).toBeVisible({ timeout: 30_000 });
await expect(rowOf(older[2].workspaceId)).toBeVisible();
await expect(rowOf(older[1].workspaceId)).toBeVisible();
await expect(rowOf(older[0].workspaceId)).toHaveCount(0);
await expect(rowOf(current.workspaceId)).toHaveCount(0);
await expect(commandCenterSection(panel, "Workspaces")).toHaveCount(0);
// The newest recent workspace is selected, so Enter goes straight back to it.
await page.keyboard.press("Enter");
await expectAppRoute(page, buildHostWorkspaceRoute(getServerId(), older[3].workspaceId), {
timeout: 30_000,
});
} finally {
for (const seeded of [current, ...older]) await seeded.cleanup();
}
});
test("archived workspaces follow active matches and open where they can be unarchived", async ({
page,
}) => {
const active = await seedWorkspace({
repoPrefix: "command-center-archived-active-",
title: "Billing dashboard",
});
const archived = await seedWorkspace({
repoPrefix: "command-center-archived-",
title: "Billing cleanup",
});
try {
await createIdleAgent(archived.client, {
cwd: archived.repoPath,
workspaceId: archived.workspaceId,
title: "Audit invoice totals",
});
expect((await archived.client.archiveWorkspace(archived.workspaceId)).error).toBeNull();
await gotoAppShell(page);
const panel = await openCommandCenter(page);
const input = panel.getByTestId("command-center-input");
const activeRow = panel.getByTestId(
`command-center-workspace-${getServerId()}:${active.workspaceId}`,
);
const archivedRow = panel.getByTestId(
`command-center-workspace-${getServerId()}:${archived.workspaceId}`,
);
await test.step("an empty query never lists archived workspaces", async () => {
await selectCommandCenterTab(panel, "Workspaces");
await expect(activeRow).toBeVisible({ timeout: 30_000 });
await expect(archivedRow).toHaveCount(0);
});
await test.step("a query lists the archived workspace after the active one", async () => {
await input.fill("billing");
await expect(archivedRow).toBeVisible({ timeout: 30_000 });
await expect(archivedRow).toContainText("Archived");
await expect(activeRow).not.toContainText("Archived");
const activeTop = (await activeRow.boundingBox())?.y ?? Number.NaN;
const archivedTop = (await archivedRow.boundingBox())?.y ?? Number.NaN;
expect(archivedTop).toBeGreaterThan(activeTop);
});
await test.step("an agent title finds the archived workspace on All", async () => {
// Opened from the app shell, outside any workspace.
await selectCommandCenterTab(panel, "All", { workspaceOpen: false });
await input.fill("invoice totals");
await expect(archivedRow.getByTestId("command-center-workspace-agent")).toHaveText(
"Audit invoice totals",
{ timeout: 30_000 },
);
await expect(activeRow).toHaveCount(0);
});
await test.step("opening it lands where the workspace can be unarchived", async () => {
await page.keyboard.press("Enter");
await expect(page.getByText("Workspace archived", { exact: true })).toBeVisible({
timeout: 30_000,
});
await expect(page.getByRole("button", { name: "Unarchive", exact: true })).toBeVisible();
});
} finally {
await active.cleanup();
await archived.cleanup();
}
});
test("single-host workspace subtitle omits the host and shows project · branch", async ({
page,
}) => {
@@ -229,7 +318,7 @@ test.describe("Compact sidebar inspection", () => {
title: "Compact destination",
});
try {
const agent = await createIdleAgent(seeded.client, {
await createIdleAgent(seeded.client, {
cwd: seeded.repoPath,
workspaceId: seeded.workspaceId,
title: "Compact root agent",
@@ -246,7 +335,10 @@ test.describe("Compact sidebar inspection", () => {
await openSidebar(page);
const agentSearch = await openCommandCenter(page);
await agentSearch.getByTestId("command-center-input").fill("Compact root agent");
await agentSearch.getByTestId(`command-center-agent-${getServerId()}:${agent.id}`).click();
await agentSearch
.getByTestId(`command-center-workspace-${getServerId()}:${seeded.workspaceId}`)
.getByTestId("command-center-workspace-agent")
.click();
await expect(page.getByRole("button", { name: "Open menu", exact: true })).toBeVisible();
await expect(
page.getByRole("textbox", { name: "Message agent...", exact: true }),
@@ -148,6 +148,23 @@ for (const scenario of [
});
}
test("retrying failed agent initialization keeps the plugin metadata set on New workspace", async ({
creation,
startup,
composerMetadata,
}) => {
await creation.openWorkspaceForm("local");
await composerMetadata.set({ "composer-metadata.tag": "kept" });
await creation.submitPrompt("Start the tagged task.", "Create");
await creation.expectAgentStillStarting();
await startup.fail();
await creation.expectStartupFailure();
await creation.expectOneCreatedWorkspace();
await creation.submitPrompt("Start the tagged task.");
await creation.expectAgentCount(1);
await creation.expectAgentMetadata({ "composer-metadata.tag": "kept" });
});
test("an edited agent draft starts after its first start failed", async ({
creation,
startup,
@@ -20,6 +20,21 @@ function explorerSidebar(page: Parameters<typeof ensureExplorerSidebar>[0]) {
return page.getByTestId("workspace-explorer-sidebar").filter({ visible: true });
}
const LAYOUT_VIEWPORTS = {
compact: { width: 390, height: 844 },
wide: { width: 1280, height: 900 },
};
// The previous layout stays on screen for a few renders after the resize. Wait for the shell to
// switch so the next step reads the layout it resized to.
async function resizeToLayout(
page: Parameters<typeof ensureExplorerSidebar>[0],
layout: keyof typeof LAYOUT_VIEWPORTS,
) {
await page.setViewportSize(LAYOUT_VIEWPORTS[layout]);
await expect(page.getByTestId(`${layout}-shell`)).toBeVisible();
}
async function expectExplorerActiveTabForeground(
page: Parameters<typeof ensureExplorerSidebar>[0],
) {
@@ -244,7 +259,7 @@ test("tablet prototype keeps Explorer tabs when resizing down to a phone", async
await page.screenshot({ path: testInfo.outputPath("tablet-951.png") });
await openExplorerAgentDraft(page);
await page.screenshot({ path: testInfo.outputPath("tablet-agent-draft.png") });
await page.setViewportSize({ width: 390, height: 844 });
await resizeToLayout(page, "compact");
await revealPhoneExplorer(page);
await expect(
explorerSidebar(page).getByRole("button", { name: "New tab", exact: true }),
@@ -289,10 +304,10 @@ test("retains the Explorer host through appearance changes and folding", async (
.poll(() => explorer.evaluate((element) => getComputedStyle(element).backgroundColor))
.not.toBe(previousColor);
expect(await host.evaluate((element) => element.isConnected)).toBe(true);
await page.setViewportSize({ width: 390, height: 844 });
await resizeToLayout(page, "compact");
await ensureExplorerSidebar(page);
expect(await host.evaluate((element) => element.isConnected)).toBe(true);
await page.setViewportSize({ width: 1280, height: 900 });
await resizeToLayout(page, "wide");
await expect(explorer).toBeVisible();
expect(await host.evaluate((element) => element.isConnected)).toBe(true);
} finally {
@@ -327,7 +342,7 @@ test("reorders compact Explorer tabs with a pointer without changing selection",
const workspace = await seedWorkspace({ repoPrefix: "explorer-compact-drag-" });
try {
await gotoWorkspace(page, workspace.workspaceId);
await page.setViewportSize({ width: 390, height: 844 });
await resizeToLayout(page, "compact");
await openFilesPanel(page);
await reorderCompactExplorerTabs(page);
await expect(
@@ -394,9 +409,9 @@ test("updates populated code and diff appearance without losing the editor insta
.getByTestId("file-source-editor")
.elementHandle();
await text.fill(`${source}\n// retained Explorer edit`);
await page.setViewportSize({ width: 390, height: 844 });
await resizeToLayout(page, "compact");
await ensureExplorerSidebar(page);
await page.setViewportSize({ width: 1280, height: 900 });
await resizeToLayout(page, "wide");
expect(await explorerEditor!.evaluate((node) => node.isConnected)).toBe(true);
await expect(text).toContainText("retained Explorer edit");
} finally {
@@ -461,9 +476,9 @@ test("retains populated Explorer chat and terminal attachments through appearanc
const terminalNode = await terminal.elementHandle();
await page.getByTestId("workspace-explorer-toggle").first().click();
await ensureExplorerSidebar(page);
await page.setViewportSize({ width: 390, height: 844 });
await resizeToLayout(page, "compact");
await ensureExplorerSidebar(page);
await page.setViewportSize({ width: 1280, height: 900 });
await resizeToLayout(page, "wide");
await cycleWorkspaceTheme(page);
expect(await terminalNode!.evaluate((node) => node.isConnected)).toBe(true);
expect(attaches).toHaveLength(1);
@@ -483,7 +498,7 @@ test("compact file sheets cover Explorer and return to its retained file list",
try {
await writeFile(`${workspace.repoPath}/sheet-marker.txt`, "Explorer sheet marker\n");
await gotoWorkspace(page, workspace.workspaceId);
await page.setViewportSize({ width: 390, height: 844 });
await resizeToLayout(page, "compact");
const explorer = await ensureExplorerSidebar(page);
await openFilesPanel(page);
const host = await explorer.elementHandle();
@@ -125,12 +125,15 @@ test.describe("Tab creation", () => {
}) => {
await gotoWorkspace(page, workspace.workspaceId);
await assertNewTabMenuTriggerVisible(page);
await pressNewTabShortcut(page);
const newTabs = page
.locator('[data-testid^="workspace-tab-tab_"]')
.filter({ hasText: "New tab" });
// The workspace opens on a New tab of its own; count from the tab the shortcut adds.
await expect(newTabs.first()).toBeVisible();
const countBefore = await newTabs.count();
const openedCount = await newTabs.count();
await pressNewTabShortcut(page);
await expect(newTabs).toHaveCount(openedCount + 1);
const countBefore = openedCount + 1;
const draftCount = await countTabsOfKind(page, "draft");
await test.step("opening the plus menu leaves the current tabs intact", async () => {
@@ -160,6 +160,22 @@ test.describe("New workspace entry points", () => {
}
});
test("Ctrl+N opens the project picker with search focused", async ({ page }) => {
const seeded: SeededWorkspace = await seedWorkspace({ repoPrefix: "entry-shortcut-" });
try {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openGlobalNewWorkspaceComposer(page);
await openGlobalNewWorkspaceComposer(page);
await page.goBack();
await openNewWorkspaceProjectPickerWithShortcut(page, "Control+N");
} finally {
await seeded.cleanup();
}
});
test("keeps the in-progress form when the remembered workspace is archived elsewhere", async ({
page,
}) => {
+123 -17
View File
@@ -8,6 +8,7 @@ import { gotoAppShell, openSettings } from "../support/helpers/app";
import { addConnectedHostAndReload } from "../support/helpers/hosts";
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
import { openSettingsSection } from "../support/helpers/settings";
import { gate } from "../support/helpers/context-window";
import { getServerId } from "../support/helpers/server-id";
import { seedSidebarFooterPreferences } from "../support/helpers/sidebar-nav-settings";
import {
@@ -33,7 +34,12 @@ import {
pinRow,
openUsageOptions,
closeUsageOptions,
pickUsageHost,
usageModalHost,
dragUsageCardAbove,
expectUsageCardOrder,
expectUsageOptionsFitContent,
setUsageCardOn,
showUsageAs,
refreshAllUsage,
togglePin,
@@ -93,10 +99,6 @@ async function qaScreenshot(page: Page, name: string, target: "page" | "summary"
await page.screenshot({ path: file });
}
function hostFilter(page: Page) {
return page.locator('[data-testid="usage-host-filter-trigger"]:visible');
}
function weeklyReport(sourceId: string, usedPct: number): UsageReportEntry {
return {
id: `${sourceId}:a`,
@@ -108,6 +110,14 @@ function weeklyReport(sourceId: string, usedPct: number): UsageReportEntry {
};
}
function accountReport(sourceId: string, account: string, usedPct: number): UsageReportEntry {
return {
...weeklyReport(sourceId, usedPct),
id: `${sourceId}:${account}`,
account: { label: account },
};
}
function installReversedUsageRefresh(page: Page) {
const initial = [weeklyReport("opencode", 31), weeklyReport("openai", 12)];
const refreshed = [weeklyReport("openai", 22), weeklyReport("opencode", 41)];
@@ -120,15 +130,15 @@ async function openUsageSummaryAtWidth(page: Page, width: number) {
await page.setViewportSize({ width, height: 900 });
await gotoAppShell(page);
if (width === 390) await openCompactSidebar(page);
await expectPinnedUsage(page, ["31% Weekly", "12% Weekly"]);
await expectPinnedUsage(page, ["12% Weekly", "31% Weekly"]);
await openUsageFromItem(page);
}
async function expectUsageProviderOrder(page: Page, summary: string[]) {
const group = page.getByTestId(`usage-host-${getServerId()}`);
await expect(group.getByText(/^(opencode|openai) plan$/)).toHaveText([
"opencode plan",
"openai plan",
"opencode plan",
]);
await expectPinnedUsage(page, summary);
}
@@ -143,7 +153,7 @@ async function refreshUsageAndWaitForReports(page: Page, usage: UsageReportsFixt
}
test.describe("usage modal", () => {
test("opens from the sidebar on the host's reports, with no host filter for one host", async ({
test("opens from the sidebar on the host's reports, with no host named for one host", async ({
page,
}) => {
test.setTimeout(120_000);
@@ -183,7 +193,7 @@ test.describe("usage modal", () => {
const group = page.getByTestId(`usage-host-${serverId}`);
await expect(group.getByText("Alpha plan", { exact: true })).toBeVisible({ timeout: 10_000 });
await expect(hostFilter(page)).toHaveCount(0);
await expect(page.getByTestId("usage-modal-host")).toHaveCount(0);
await qaScreenshot(page, "usage-modal-one-host");
await expect(group.getByText("31% used")).toBeVisible();
await expect(group.getByText("Beta plan", { exact: true })).toBeVisible();
@@ -200,15 +210,61 @@ test.describe("usage modal", () => {
}) => {
const usage = await installReversedUsageRefresh(page);
await openUsageSummaryAtWidth(page, width);
await expectUsageProviderOrder(page, ["31% Weekly", "12% Weekly"]);
// Reports arrive opencode first; cards and the summary stay alphabetical.
await expectUsageProviderOrder(page, ["12% Weekly", "31% Weekly"]);
await qaScreenshot(page, `usage-order-${width}-before-refresh`);
await refreshUsageAndWaitForReports(page, usage);
await expectUsageProviderOrder(page, ["41% Weekly", "22% Weekly"]);
await expectUsageProviderOrder(page, ["22% Weekly", "41% Weekly"]);
await qaScreenshot(page, `usage-order-${width}-after-refresh`);
});
}
test("keeps a card turned off and a dragged order after a reload", async ({ page }) => {
test.setTimeout(120_000);
await installUsageReportsFixture(page, {
lists: [
[
accountReport("beta", "work", 12),
weeklyReport("alpha", 31),
accountReport("beta", "home", 20),
],
],
});
await gotoAppShell(page);
// Three windows leave no room for labels in the summary, so it reads percents.
await expectPinnedUsage(page, ["31%", "20%", "12%"]);
await openUsageFromItem(page);
await expectUsageCardOrder(page, ["alpha:a", "beta:home", "beta:work"]);
// Without the pointer over the card, keyboard focus on its handle shows the handle.
await page.mouse.move(0, 0);
const alphaHandle = page
.getByTestId("usage-report-alpha:a")
.getByRole("button", { name: "Reorder alpha plan", exact: true });
await expect(alphaHandle).toHaveCSS("opacity", "0");
await alphaHandle.focus();
await expect(alphaHandle).toHaveCSS("opacity", "1");
await alphaHandle.blur();
// The off card collapses to its header, which still names the account.
await setUsageCardOn(page, "beta plan (work)", false);
const work = page.getByTestId("usage-report-beta:work");
await expect(work.getByText("12% used")).toHaveCount(0);
await expect(work.getByText("work", { exact: true })).toBeVisible();
await expectPinnedUsage(page, ["31% Weekly", "20% Weekly"]);
await dragUsageCardAbove(page, "beta:home", "alpha:a");
await expectUsageCardOrder(page, ["beta:home", "alpha:a", "beta:work"]);
await expectPinnedUsage(page, ["20% Weekly", "31% Weekly"]);
await page.reload();
await expectPinnedUsage(page, ["20% Weekly", "31% Weekly"]);
await openUsageFromItem(page);
await expectUsageCardOrder(page, ["beta:home", "alpha:a", "beta:work"]);
await expect(page.getByTestId("usage-report-beta:work").getByText("12% used")).toHaveCount(0);
});
test("refreshes one report from its card", async ({ page }) => {
test.setTimeout(120_000);
const serverId = getServerId();
@@ -320,9 +376,11 @@ test.describe("usage modal", () => {
);
try {
await installUsageReportsFixture(page, { lists: [[weeklyReport("alpha", 31)]] });
// The second host's reports wait until the test lets them through, like a slow host.
const secondaryReports = gate();
await installUsageReportsFixture(page, {
port: secondary.port,
lists: [[weeklyReport("beta", 12)]],
lists: [{ stream: [secondaryReports.promise, weeklyReport("beta", 12)] }],
});
await gotoAppShell(page);
await addConnectedHostAndReload(page, {
@@ -336,12 +394,15 @@ test.describe("usage modal", () => {
await openUsageFromItem(page);
await expect(page.getByTestId(`usage-host-${primaryServerId}`)).toBeVisible();
await hostFilter(page).click();
await page.getByTestId(`usage-host-filter-item-${secondary.serverId}`).click();
await expect(
page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12% used"),
).toBeVisible({ timeout: 30_000 });
await expect(hostFilter(page)).toContainText("Secondary box");
await expect(usageModalHost(page)).toHaveText("localhost");
await pickUsageHost(page, secondary.serverId);
// Until its reports arrive, the picked host loads; it never shows the first host's cards.
const secondaryBody = page.getByTestId(`usage-host-${secondary.serverId}`);
await expect(secondaryBody.getByText("Loading usage...")).toBeVisible();
await expect(page.getByTestId("usage-modal-body").getByText("31% used")).toHaveCount(0);
await expect(usageModalHost(page)).toHaveText("Secondary box");
secondaryReports.open();
await expect(secondaryBody.getByText("12% used")).toBeVisible({ timeout: 30_000 });
await qaScreenshot(page, "usage-modal-picked-host");
await expectPinnedUsage(page, ["12% Weekly"]);
@@ -360,6 +421,51 @@ test.describe("usage modal", () => {
await secondary.close().catch(() => undefined);
}
});
test("on a phone, picking a host in Settings keeps Usage open on that host", async ({ page }) => {
test.setTimeout(420_000);
const longName = "build-box-eu-west-gpu-runner-02.internal";
const secondary = await startIsolatedHostDaemon(
`srv_usage_${randomUUID().replaceAll("-", "").slice(0, 12)}`,
);
try {
await installUsageReportsFixture(page, { lists: [[weeklyReport("alpha", 31)]] });
await installUsageReportsFixture(page, {
port: secondary.port,
lists: [[weeklyReport("beta", 12)]],
});
await page.setViewportSize({ width: 375, height: 812 });
await gotoAppShell(page);
await addConnectedHostAndReload(page, {
serverId: secondary.serverId,
label: longName,
port: secondary.port,
});
await openCompactSidebar(page);
await openUsageFromItem(page);
await pickUsageHost(page, secondary.serverId);
// The sheet stays up once Settings has closed over it, showing the picked host's cards.
await expect(
page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12% used"),
).toBeVisible({ timeout: 30_000 });
await expect(usageModalHost(page)).toHaveText(longName);
// The long name truncates under the title; the title itself reads in full.
const title = page.locator('[data-testid="usage-modal-title"]:visible');
await expect(title).toHaveText("Usage");
expect(await title.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(
true,
);
const host = usageModalHost(page);
expect(await host.evaluate((element) => element.scrollWidth > element.clientWidth)).toBe(
true,
);
await qaScreenshot(page, "usage-phone-picked-long-host");
} finally {
await secondary.close().catch(() => undefined);
}
});
});
const loginWindows: UsageReportEntry["report"] = {
@@ -318,10 +318,11 @@ test.describe("Usage item", () => {
await test.step("pinning Claude 5-hour and Codex weekly shows both in the Usage item", async () => {
await expect(screen.getByText("Claude", { exact: true })).toBeVisible({ timeout: 10_000 });
// One host: no host filter, as on History.
await expect(page.locator('[data-testid="usage-host-filter-trigger"]:visible')).toHaveCount(
0,
);
// One host: Settings has no Host row, and the title no host under it.
await expect(page.getByTestId("usage-modal-host")).toHaveCount(0);
await openUsageOptions(page);
await expect(page.getByTestId("usage-options-host")).toHaveCount(0);
await closeUsageOptions(page);
await togglePin(screen, "Claude", "Weekly");
await expectPinnedUsage(page, ["31% 5h", "7% 5h", "12% wk"]);
await expect(pinRow(screen, "Claude", "Session")).toBeChecked();
+50 -2
View File
@@ -1,6 +1,8 @@
import { writeFile, rm, rename } from "node:fs/promises";
import { mkdtemp, writeFile, rm, rename } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { copyPluginExample } from "./helpers/plugin-fixture";
import type { Page } from "@playwright/test";
import { copyPluginExample, pluginRequirements } from "./helpers/plugin-fixture";
import { connectNewWorkspaceDaemonClient } from "./helpers/new-workspace";
import { test as base } from "./fixtures";
import {
@@ -14,6 +16,7 @@ import { delayBrowserAgentCreatedStatus } from "./helpers/new-workspace";
export const test = base.extend<{
startup: { release(): Promise<void>; fail(): Promise<void> };
promptRejection: void;
composerMetadata: { set(patch: Record<string, string>): Promise<void> };
creation: Awaited<ReturnType<typeof createCreationScenario>>;
promptRetry: ReturnType<typeof createPromptRetryScenario>;
agentRetries: Awaited<ReturnType<typeof retryNextAgentCreation>>;
@@ -98,6 +101,39 @@ export default function contribute(server) {
await client.close();
}
},
composerMetadata: async ({ page }, provide) => {
const client = await connectNewWorkspaceDaemonClient();
const previous = await client.getDaemonConfig();
const directory = await mkdtemp(path.join(tmpdir(), "paseo-composer-metadata-"));
try {
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: "composer-metadata", requirements: pluginRequirements }),
);
await writeFile(
path.join(directory, "index.client.tsx"),
`import { useComposer } from "@getpaseo/plugin/client";
function Holder() {
globalThis.metadataComposer = useComposer();
return null;
}
export default function contribute(client) {
client.addComposerItem({ id: "holder", Component: Holder });
return () => {};
}`,
);
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(directory);
await provide({ set: (patch) => setComposerMetadata(page, patch) });
} finally {
await client.removePlugin("composer-metadata");
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
await rm(directory, { recursive: true, force: true });
await client.close();
}
},
creation: async ({ page }, provide) => {
const creation = await createCreationScenario(page);
try {
@@ -126,3 +162,15 @@ export default function contribute(server) {
}
},
});
type MetadataComposerGlobal = typeof globalThis & {
metadataComposer?: { setMetadata(patch: Record<string, string>): Promise<void> };
};
async function setComposerMetadata(page: Page, patch: Record<string, string>) {
await page.waitForFunction(() => (globalThis as MetadataComposerGlobal).metadataComposer);
await page.evaluate(
(value) => (globalThis as MetadataComposerGlobal).metadataComposer!.setMetadata(value),
patch,
);
}
@@ -26,7 +26,7 @@ const SURFACE_SELECTORS = {
input: '[data-testid="command-center-input"]',
fileSearchStatus: '[data-testid="command-center-file-search-status"]',
results: '[data-testid="command-center-results"]',
scope: '[data-testid="command-center-files-scope"]',
tabs: '[data-testid="command-center-tabs"]',
fileRow: '[data-testid^="command-center-file-row-"]',
fileIcon: '[data-testid="command-center-file-icon"]',
fileLine: '[data-testid="command-center-file-line"]',
@@ -11,7 +11,7 @@ export async function seedSearchableCommandCenter() {
});
try {
const agents = [];
for (let index = 0; index < 60; index += 1) {
for (let index = 0; index < 3; index += 1) {
agents.push(
await createMockIdleAgent(seed.client, {
cwd: seed.repoPath,
@@ -49,11 +49,27 @@ export async function searchCommandCenter(panel: Locator, query: string) {
}
export async function warmCommandCenterRows(page: Page) {
// FlatList fills its overscan window in timed batches after the first visible frame.
await page.waitForTimeout(600);
await page.evaluate(() => {
globalThis.__PASEO_RENDER_PROFILE_REASONS__ = {};
});
// FlatList fills its overscan window in timed batches after the first visible frame,
// and a slow runner can still be mounting rows long after it. Start counting only
// once a quiet period passes with no row render.
await expect
.poll(() => page.evaluate(countRowRendersOverQuietPeriod), {
intervals: [0],
timeout: 15_000,
})
.toBe(0);
}
// Runs in the page: resets the render profile and counts row renders over two frames and 300ms.
async function countRowRendersOverQuietPeriod() {
globalThis.__PASEO_RENDER_PROFILE_REASONS__ = {};
await new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
);
await new Promise((resolve) => setTimeout(resolve, 300));
return Object.keys(globalThis.__PASEO_RENDER_PROFILE_REASONS__ ?? {}).filter((id) =>
id.startsWith("CommandCenterResultRow:"),
).length;
}
export async function expectOnlyCommandCenterRowRendered(page: Page, resultId: string) {
@@ -73,16 +89,17 @@ export async function expectOnlyCommandCenterRowRendered(page: Page, resultId: s
expect(renders).toEqual({ [`CommandCenterResultRow:${resultId}`]: { render: 1 } });
}
export async function expectCommandCenterAgentWindow(panel: Locator, total: number) {
const rows = panel.locator(
'[data-testid^="command-center-agent-"]:not([data-testid="command-center-agent-subtitle"])',
);
const count = await rows.count();
const WORKSPACE_ROWS =
'[data-testid^="command-center-workspace-"]:not([data-testid="command-center-workspace-subtitle"]):not([data-testid="command-center-workspace-agent"])';
/** The list mounts a window of rows, not every match. */
export async function expectCommandCenterWorkspaceWindow(panel: Locator, total: number) {
const count = await panel.locator(WORKSPACE_ROWS).count();
expect(count).toBeGreaterThan(0);
expect(count).toBeLessThan(total);
}
export async function scrollToOldestCommandCenterAgent(panel: Locator) {
export async function scrollToOldestCommandCenterWorkspace(panel: Locator) {
await panel.getByTestId("command-center-results").evaluate((element) => {
element.scrollTop = element.scrollHeight;
element.dispatchEvent(new Event("scroll"));
@@ -22,14 +22,9 @@ export async function observeCommandCenterScroll(page: Page): Promise<void> {
const discoverPanel = new MutationObserver(() => {
const panel = document.querySelector<HTMLElement>('[data-testid="command-center-panel"]');
const firstResult = panel?.querySelector<HTMLElement>('[role="button"]');
if (!panel || !firstResult) return;
let viewport = firstResult.parentElement;
while (viewport && viewport !== panel && viewport.scrollHeight <= viewport.clientHeight) {
viewport = viewport.parentElement;
}
if (!viewport || viewport === panel) return;
const viewport = panel?.querySelector<HTMLElement>('[data-testid="command-center-results"]');
const firstResult = viewport?.querySelector<HTMLElement>('[role="button"]');
if (!viewport || !firstResult) return;
discoverPanel.disconnect();
let frame = 0;
@@ -119,15 +114,8 @@ export async function expectVisibleKeyboardNavigationNotToScroll(
for (let index = 0; index < 4; index += 1) await page.keyboard.press("ArrowDown");
const scrollSamples = await panel
.getByRole("button")
.first()
.evaluate(async (firstResult) => {
let viewport = firstResult.parentElement;
while (viewport && viewport.scrollHeight <= viewport.clientHeight) {
viewport = viewport.parentElement;
}
if (!viewport) throw new Error("Could not find the command center scroll viewport");
.getByTestId("command-center-results")
.evaluate(async (viewport) => {
const samples: number[] = [];
for (let frame = 0; frame < 20; frame += 1) {
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
@@ -139,7 +127,8 @@ export async function expectVisibleKeyboardNavigationNotToScroll(
expect(Math.max(...scrollSamples)).toBe(0);
}
export async function expectWorkspaceResultsToBeLimitedUntilSearch(panel: Locator): Promise<void> {
/** All leads with three recent workspaces; a query searches every workspace. */
export async function expectRecentWorkspacesUntilSearch(panel: Locator): Promise<void> {
const workspaces = panel.locator(
'[data-testid^="command-center-workspace-"]:not([data-testid="command-center-workspace-subtitle"])',
);
@@ -150,9 +139,11 @@ export async function expectWorkspaceResultsToBeLimitedUntilSearch(panel: Locato
),
).size;
await expect.poll(distinctWorkspaceCount).toBe(5);
await expect(panel.getByText("Recent workspaces", { exact: true })).toBeVisible();
await expect.poll(distinctWorkspaceCount).toBe(3);
await panel.getByTestId("command-center-input").fill("Command Center Scroll");
await expect(panel.getByText("Recent workspaces", { exact: true })).toHaveCount(0);
await expect.poll(distinctWorkspaceCount).toBe(9);
}
@@ -1,5 +1,20 @@
import { expect, type Locator, type Page } from "@playwright/test";
export type CommandCenterTabName = "All" | "Workspaces" | "Files" | "Actions";
const TAB_PLACEHOLDERS: Record<CommandCenterTabName, string> = {
All: "Search workspaces, files, and actions...",
Workspaces: "Search workspaces...",
Files: "Search files...",
Actions: "Search actions...",
};
/** Outside a workspace there are no files to search, so All does not offer them. */
const TAB_PLACEHOLDERS_WITHOUT_WORKSPACE: Record<CommandCenterTabName, string> = {
...TAB_PLACEHOLDERS,
All: "Search workspaces and actions...",
};
// Opens the command center / global search palette from the sidebar and returns its panel.
export async function openCommandCenter(page: Page): Promise<Locator> {
await page.getByTestId("sidebar-search").click();
@@ -12,3 +27,46 @@ export async function closeCommandCenter(page: Page): Promise<void> {
await page.keyboard.press("Escape");
await expect(page.getByTestId("command-center-panel")).not.toBeVisible();
}
function commandCenterTab(panel: Locator, name: CommandCenterTabName): Locator {
return panel.getByTestId("command-center-tabs").getByRole("button", { name, exact: true });
}
export async function selectCommandCenterTab(
panel: Locator,
name: CommandCenterTabName,
options: { workspaceOpen?: boolean } = {},
): Promise<void> {
await commandCenterTab(panel, name).click();
await expectCommandCenterTab(panel, name, options);
}
/**
* The tab is selected and the search field asks for what that tab lists. Pass
* `{ workspaceOpen: false }` when the command center was opened outside a workspace.
*/
export async function expectCommandCenterTab(
panel: Locator,
name: CommandCenterTabName,
options: { workspaceOpen?: boolean } = {},
): Promise<void> {
const placeholders =
options.workspaceOpen === false ? TAB_PLACEHOLDERS_WITHOUT_WORKSPACE : TAB_PLACEHOLDERS;
await expect(commandCenterTab(panel, name)).toHaveAttribute("aria-selected", "true");
await expect(panel.getByTestId("command-center-input")).toHaveAttribute(
"placeholder",
placeholders[name],
);
}
/** Exactly these tabs are offered, in this order. */
export async function expectCommandCenterTabs(
panel: Locator,
names: readonly CommandCenterTabName[],
): Promise<void> {
await expect(panel.getByTestId("command-center-tabs").getByRole("button")).toHaveText([...names]);
}
export function commandCenterSection(panel: Locator, title: string): Locator {
return panel.getByTestId("command-center-results").getByText(title, { exact: true });
}
@@ -258,6 +258,16 @@ export async function createCreationScenario(page: Page) {
.filter(({ agent }) => agent.workspaceId === workspaceId)
.map(({ agent }) => agent.id);
},
async expectAgentMetadata(metadata: Record<string, unknown>) {
await expect
.poll(
async () =>
(await project.client.fetchAgents()).entries.find(
({ agent }) => agent.workspaceId === workspaceId,
)?.agent.metadata,
)
.toEqual(metadata);
},
async expectAgentTitle(title: string) {
await expect
.poll(
@@ -33,8 +33,10 @@ async function createRunningMockAgent(
return { id: agent.id, title };
}
async function openCommandCenter(page: Page): Promise<void> {
// Agents are found through their workspace: the query names the agent, the row shows it.
async function searchCommandCenter(page: Page, query: string): Promise<void> {
await page.getByTestId("sidebar-search").click();
await page.getByTestId("command-center-input").fill(query);
}
export class DirectoryBootstrapScenario {
@@ -78,8 +80,10 @@ export class DirectoryBootstrapScenario {
const workspaceLink = this.page.getByText(workspace.projectDisplayName, { exact: true });
await expect(workspaceLink).toHaveCount(1);
await expect(workspaceLink).toBeVisible();
await openCommandCenter(this.page);
const agentLink = this.page.getByText(agent.title, { exact: true });
await searchCommandCenter(this.page, agent.title);
const agentLink = this.page
.getByTestId("command-center-workspace-agent")
.getByText(agent.title, { exact: true });
await expect(agentLink).toHaveCount(1);
await expect(agentLink).toBeVisible();
await this.page.keyboard.press("Escape");
@@ -111,8 +115,10 @@ export class DirectoryBootstrapScenario {
const workspaceLink = this.page.getByText(workspace.projectDisplayName, { exact: true });
await expect(workspaceLink).toHaveCount(1);
await expect(workspaceLink).toBeVisible();
await openCommandCenter(this.page);
const agentLink = this.page.getByText(agent.title, { exact: true });
await searchCommandCenter(this.page, agent.title);
const agentLink = this.page
.getByTestId("command-center-workspace-agent")
.getByText(agent.title, { exact: true });
await expect(agentLink).toHaveCount(1);
await expect(agentLink).toBeVisible();
await agentLink.click();
@@ -232,8 +232,11 @@ export async function expectNewWorkspaceControlsEnabled(page: Page): Promise<voi
});
}
export async function openNewWorkspaceProjectPickerWithShortcut(page: Page): Promise<void> {
await page.keyboard.press("Control+P");
export async function openNewWorkspaceProjectPickerWithShortcut(
page: Page,
shortcut = "Control+P",
): Promise<void> {
await page.keyboard.press(shortcut);
const searchInput = page.getByPlaceholder("Search projects");
await expect(searchInput).toBeVisible({ timeout: 30_000 });
@@ -131,6 +131,7 @@ export interface SeedDaemonClient {
currentModeId: string | null;
status: string;
title?: string | null;
metadata?: Record<string, unknown>;
};
}>;
}>;
@@ -147,6 +147,46 @@ export function usageModal(page: Page): Locator {
return visible(page, "usage-modal-body");
}
/** The Usage modal's cards, top to bottom, by report id. */
export async function expectUsageCardOrder(page: Page, reportIds: string[]): Promise<void> {
const cards = usageModal(page).locator('[data-testid^="usage-report-"]');
await expect(cards).toHaveCount(reportIds.length);
await expect
.poll(() =>
cards.evaluateAll((nodes) =>
nodes.map((node) => node.getAttribute("data-testid")?.slice("usage-report-".length)),
),
)
.toEqual(reportIds);
}
/** Turns a card on or off from its header switch, named by its source and account. */
export async function setUsageCardOn(page: Page, name: string, on: boolean): Promise<void> {
const cardSwitch = usageModal(page).getByRole("switch", { name: `Show ${name}`, exact: true });
await cardSwitch.click();
await expect(cardSwitch).toBeChecked({ checked: on });
}
/** Drags a card by its handle until it sits above another card, with the mouse. */
export async function dragUsageCardAbove(
page: Page,
reportId: string,
targetReportId: string,
): Promise<void> {
const card = usageModal(page).getByTestId(`usage-report-${reportId}`);
const target = usageModal(page).getByTestId(`usage-report-${targetReportId}`);
await card.hover();
const handle = await card.getByTestId("usage-card-drag-handle").boundingBox();
const targetBox = await target.boundingBox();
if (!handle || !targetBox) throw new Error("Both cards must be on screen to drag.");
const x = handle.x + handle.width / 2;
await page.mouse.move(x, handle.y + handle.height / 2);
await page.mouse.down();
await page.mouse.move(x, handle.y + handle.height / 2 - 10, { steps: 3 });
await page.mouse.move(x, targetBox.y + 4, { steps: 12 });
await page.mouse.up();
}
/** Each pinned window as it reads: its percent and short label, "31% 5h". */
export async function expectPinnedUsage(page: Page, windows: string[]): Promise<void> {
const pinned = usageItem(page).getByTestId("sidebar-usage-pinned-window");
@@ -167,14 +207,18 @@ export function pinRow(scope: Locator, source: string, window: string): Locator
/**
* A report card whose window rows do not pin: no pin toggle, no pin glyph, and nothing focusable
* but buttons, so no row presses or highlights on hover.
* but the header's buttons and on/off switch, so no row presses or highlights on hover.
*/
export async function expectUnpinnableRows(card: Locator): Promise<void> {
await expect(card.getByRole("checkbox")).toHaveCount(0);
await expect(card.locator('[data-testid^="usage-pin-"]')).toHaveCount(0);
const focusable = await card
.locator("[tabindex]")
.evaluateAll((nodes) => nodes.filter((node) => node.getAttribute("role") !== "button").length);
.evaluateAll(
(nodes) =>
nodes.filter((node) => !["button", "switch"].includes(node.getAttribute("role") ?? ""))
.length,
);
expect(focusable).toBe(0);
}
@@ -273,6 +317,19 @@ export async function openUsageOptions(page: Page): Promise<void> {
await expect(visible(page, "usage-display-used")).toBeVisible();
}
/** Picks the host Usage shows, from the Host row of its Settings. */
export async function pickUsageHost(page: Page, serverId: string): Promise<void> {
await openUsageOptions(page);
await visible(page, "usage-options-host").click();
await visible(page, `usage-options-host-${serverId}`).click();
await expect(page.getByTestId("usage-display-used")).toHaveCount(0);
}
/** The host Usage shows, under its title; absent with a single host. */
export function usageModalHost(page: Page): Locator {
return visible(page, "usage-modal-host");
}
export function usageOptionsSurface(page: Page): Locator {
return page
.locator(
@@ -91,10 +91,13 @@ async function selectWorkspaceTab(tab: Locator): Promise<void> {
export async function ensureExplorerSidebar(page: Page): Promise<Locator> {
const toggle = page.getByTestId("workspace-explorer-toggle").first();
await expect(toggle).toBeVisible({ timeout: 30_000 });
const explorer = explorerSidebar(page);
if ((await explorer.count()) === 0) {
// Decide from the toggle, which reports the open state. The sidebar's own visibility lags it:
// a compact layout draws its closed Explorer for a frame before hiding it.
if ((await toggle.getAttribute("aria-expanded")) !== "true") {
await toggle.click();
}
await expect(toggle).toHaveAttribute("aria-expanded", "true");
const explorer = explorerSidebar(page);
await expect(explorer).toBeVisible({ timeout: 30_000 });
return explorer;
}
+6
View File
@@ -23,6 +23,12 @@ export default defineConfig({
// spec on one worker avoids repeating its file-level setup across daemons.
fullyParallel: false,
workers: Number(process.env.E2E_WORKERS ?? (process.env.CI ? "2" : "1")),
// Workspace packages are already compiled. Tests import them at run time, so
// on a cold cache every worker transpiles the same files and writes them into
// Playwright's shared transform cache without locking. A worker that reads an
// entry mid-write loads an empty module and fails with "does not provide an
// export named".
build: { external: ["**/packages/*/dist/**"] },
retries: process.env.CI ? 1 : 0,
reporter: [["list"]],
use: {
@@ -0,0 +1,5 @@
export type { ArchivedWorkspace, ArchivedWorkspaceAgent } from "./internal/group";
export {
useArchivedWorkspaceSearch,
type ArchivedWorkspaceSearch,
} from "./internal/use-archived-workspace-search";
@@ -0,0 +1,128 @@
import { describe, expect, it } from "vitest";
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { groupArchivedWorkspaces } from "./group";
const T0 = Date.parse("2026-11-01T10:00:00.000Z");
const minutes = (count: number) => new Date(T0 + count * 60_000);
function historyAgent(input: {
id: string;
serverId?: string;
workspaceId?: string | null;
title?: string | null;
activeAt: number;
archivedAt?: number | null;
workspaceName?: string | null;
branch?: string | null;
cwd?: string;
}): AggregatedAgent {
const cwd = input.cwd ?? "/repos/payments";
return {
id: input.id,
serverId: input.serverId ?? "host-a",
serverLabel: input.serverId ?? "host-a",
title: input.title ?? null,
status: "closed",
turn: { phase: "open", turnId: null, startedAt: null, cancellationRequestId: null },
lastActivityAt: minutes(input.activeAt),
cwd,
workspaceId: input.workspaceId === undefined ? "ws-1" : input.workspaceId,
provider: "claude",
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
archivedAt: input.archivedAt == null ? null : minutes(input.archivedAt),
createdAt: minutes(0),
labels: {},
projectPlacement: {
projectKey: "prj-payments",
projectName: "payments",
workspaceName: input.workspaceName === undefined ? "Billing cleanup" : input.workspaceName,
checkout: {
cwd,
isGit: true,
currentBranch: input.branch === undefined ? "billing-cleanup" : input.branch,
remoteUrl: null,
worktreeRoot: cwd,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},
},
} as AggregatedAgent;
}
const NO_ACTIVE = new Map<string, ReadonlySet<string>>();
describe("groupArchivedWorkspaces", () => {
it("folds a workspace's agents into one entry led by its most recent agent", () => {
const [workspace] = groupArchivedWorkspaces({
agents: [
historyAgent({ id: "older", title: "Audit invoices", activeAt: 1, archivedAt: 5 }),
historyAgent({ id: "newer", title: "Fix totals", activeAt: 3, archivedAt: 4 }),
],
activeIds: NO_ACTIVE,
});
expect(workspace).toMatchObject({
key: "host-a:ws-1",
serverId: "host-a",
workspaceId: "ws-1",
name: "Billing cleanup",
projectName: "payments",
projectKey: "prj-payments",
branch: "billing-cleanup",
archivedAt: minutes(5),
});
expect(workspace.agents.map((agent) => agent.id)).toEqual(["newer", "older"]);
});
it("leaves out workspaces that are still active on their host", () => {
const workspaces = groupArchivedWorkspaces({
agents: [historyAgent({ id: "a", activeAt: 1, archivedAt: 2 })],
activeIds: new Map([["host-a", new Set(["ws-1"])]]),
});
expect(workspaces).toEqual([]);
});
it("leaves out an inactive workspace none of whose agents were archived", () => {
const workspaces = groupArchivedWorkspaces({
agents: [historyAgent({ id: "a", activeAt: 1, archivedAt: null })],
activeIds: NO_ACTIVE,
});
expect(workspaces).toEqual([]);
});
it("keeps the same workspace ID on two hosts apart and lists the latest archive first", () => {
const workspaces = groupArchivedWorkspaces({
agents: [
historyAgent({ id: "a", serverId: "host-a", activeAt: 1, archivedAt: 2 }),
historyAgent({ id: "b", serverId: "host-b", activeAt: 1, archivedAt: 9 }),
],
activeIds: NO_ACTIVE,
});
expect(workspaces.map((workspace) => workspace.key)).toEqual(["host-b:ws-1", "host-a:ws-1"]);
});
it("skips agents with no workspace and names an unnamed workspace after its folder", () => {
const workspaces = groupArchivedWorkspaces({
agents: [
historyAgent({ id: "loose", workspaceId: null, activeAt: 1, archivedAt: 2 }),
historyAgent({
id: "a",
workspaceName: null,
branch: null,
cwd: "/repos/receipts",
activeAt: 1,
archivedAt: 2,
}),
],
activeIds: NO_ACTIVE,
});
expect(workspaces).toHaveLength(1);
expect(workspaces[0]).toMatchObject({ name: "receipts", branch: null });
});
});
@@ -0,0 +1,87 @@
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
export interface ArchivedWorkspaceAgent {
id: string;
title: string | null;
provider: string;
}
/** A workspace the user archived, as agent history describes it. */
export interface ArchivedWorkspace {
/** `serverId:workspaceId` */
key: string;
serverId: string;
workspaceId: string;
name: string;
projectName: string | null;
/** The host's project ID. */
projectKey: string | null;
branch: string | null;
/** The latest archive time among its agents. */
archivedAt: Date;
/** Its agents in this history page, most recently active first. Never empty. */
agents: ArchivedWorkspaceAgent[];
}
/** Which workspaces each host still lists as active. */
export type ActiveWorkspaceIdsByServer = ReadonlyMap<string, ReadonlySet<string>>;
function lastPathSegment(path: string): string {
const parts = path.split(/[\\/]/).filter(Boolean);
return parts[parts.length - 1] ?? path;
}
function checkoutBranch(agent: AggregatedAgent): string | null {
const checkout = agent.projectPlacement?.checkout;
return checkout?.isGit ? checkout.currentBranch : null;
}
/**
* Folds agent history into one entry per archived workspace, newest archive first. A
* workspace its host still lists is active, so it is left out. So is an inactive workspace
* with no archived agent: the user never put it away (a background workspace, for example).
*/
export function groupArchivedWorkspaces(input: {
agents: readonly AggregatedAgent[];
activeIds: ActiveWorkspaceIdsByServer;
}): ArchivedWorkspace[] {
const byKey = new Map<string, ArchivedWorkspace & { archivedAtOrNull: Date | null }>();
const newestFirst = [...input.agents].sort(
(left, right) => right.lastActivityAt.getTime() - left.lastActivityAt.getTime(),
);
for (const agent of newestFirst) {
const workspaceId = agent.workspaceId;
if (!workspaceId || input.activeIds.get(agent.serverId)?.has(workspaceId)) continue;
const key = `${agent.serverId}:${workspaceId}`;
const archivedAt = agent.archivedAt ? new Date(agent.archivedAt) : null;
const summary = { id: agent.id, title: agent.title ?? null, provider: agent.provider };
const existing = byKey.get(key);
if (existing) {
existing.agents.push(summary);
if (archivedAt && (!existing.archivedAtOrNull || archivedAt > existing.archivedAtOrNull)) {
existing.archivedAtOrNull = archivedAt;
}
continue;
}
// The most recent agent describes the workspace: it saw its latest name and branch.
const placement = agent.projectPlacement;
byKey.set(key, {
key,
serverId: agent.serverId,
workspaceId,
name: placement?.workspaceName || lastPathSegment(agent.cwd),
projectName: placement?.projectName ?? null,
projectKey: placement?.projectKey ?? null,
branch: checkoutBranch(agent),
archivedAt: archivedAt ?? new Date(0),
archivedAtOrNull: archivedAt,
agents: [summary],
});
}
const archived: ArchivedWorkspace[] = [];
for (const { archivedAtOrNull, ...workspace } of byKey.values()) {
if (archivedAtOrNull) archived.push({ ...workspace, archivedAt: archivedAtOrNull });
}
return archived.sort((left, right) => right.archivedAt.getTime() - left.archivedAt.getTime());
}
@@ -0,0 +1,83 @@
import { useMemo } from "react";
import { useShallow } from "zustand/shallow";
import { useAgentHistory } from "@/hooks/use-agent-history";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import {
groupArchivedWorkspaces,
type ActiveWorkspaceIdsByServer,
type ArchivedWorkspace,
} from "./group";
const SEARCH_DEBOUNCE_MS = 200;
const NO_WORKSPACES: Record<string, ReadonlyMap<string, WorkspaceDescriptor>> = {};
const NO_ARCHIVED_WORKSPACES: ArchivedWorkspace[] = [];
export interface ArchivedWorkspaceSearch {
/** Newest archive first. Empty without a query. */
workspaces: ArchivedWorkspace[];
/**
* The host matched these for the current query. Its search forgives typos, so its matches
* can include rows the caller cannot match itself. False while the previous query's
* results stand in for the next one; those rows must still match the query on their own.
*/
matchedByHost: boolean;
/** Still searching for the current query, debounce included. */
pending: boolean;
}
function useActiveWorkspaceIds(enabled: boolean): ActiveWorkspaceIdsByServer {
// Each host's workspace map is replaced only when its workspaces change, so this does not
// re-render on agent activity.
const workspacesByServer = useSessionStore(
useShallow((state) =>
enabled
? Object.fromEntries(
Object.entries(state.sessions).map(([serverId, session]) => [
serverId,
session.workspaces,
]),
)
: NO_WORKSPACES,
),
);
return useMemo(
() =>
new Map(
Object.entries(workspacesByServer).map(([serverId, workspaces]) => [
serverId,
new Set(workspaces.keys()),
]),
),
[workspacesByServer],
);
}
/**
* Archived workspaces whose name, project, branch, or agent titles match `query`, searched on
* every host that supports history search. Hosts that predate it contribute nothing.
*/
export function useArchivedWorkspaceSearch(
query: string,
options: { enabled: boolean },
): ArchivedWorkspaceSearch {
const trimmed = query.trim();
const search = useDebouncedValue(trimmed, SEARCH_DEBOUNCE_MS);
const enabled = options.enabled && trimmed.length > 0;
const activeIds = useActiveWorkspaceIds(enabled);
const history = useAgentHistory({
search,
enabled: enabled && search.length > 0,
searchableHostsOnly: true,
keepPreviousResults: true,
});
const workspaces = useMemo(
() =>
enabled
? groupArchivedWorkspaces({ agents: history.agents, activeIds })
: NO_ARCHIVED_WORKSPACES,
[activeIds, enabled, history.agents],
);
const matchedByHost = search === trimmed && !history.isLoading && !history.isPreviousResults;
return { workspaces, matchedByHost, pending: enabled && !matchedByHost };
}
@@ -39,6 +39,21 @@ function createDeferred<T>() {
return { promise, resolve, reject };
}
/**
* Resolves a pending lookup and waits until React Query has delivered the result. It delivers on a
* timer, which would otherwise fire after the test, when jsdom is gone.
*/
async function resolveLookupAndDeliver(
lookup: ReturnType<typeof createDeferred<DirectorySuggestionResult>>,
result: DirectorySuggestionResult,
): Promise<void> {
await act(async () => {
lookup.resolve(result);
await lookup.promise;
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
interface OpenedFile {
target: InlinePathTarget;
disposition: OpenFileDisposition;
@@ -322,11 +337,12 @@ describe("useFileLink", () => {
act(() => {
result.current.setWorkspaceRoot("/Users/test/other");
});
deferred.resolve(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]));
await resolveLookupAndDeliver(
deferred,
resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]),
);
await waitFor(() => {
expect(getDirectorySuggestions).toHaveBeenCalledTimes(1);
});
expect(getDirectorySuggestions).toHaveBeenCalledTimes(1);
expect(openedFiles).toEqual([]);
});
});
@@ -0,0 +1,71 @@
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { persistAttachmentFromBlob } from "@/attachments/service";
import type { AttachmentMetadata } from "@/attachments/types";
import { type AttachmentPreviewUrl, useAttachmentPreviewUrl } from "./use-attachment-preview-url";
let root: Root;
let container: HTMLDivElement;
let latest: AttachmentPreviewUrl | null = null;
function PreviewProbe({ attachment }: { attachment: AttachmentMetadata | null }) {
latest = useAttachmentPreviewUrl(attachment);
return null;
}
async function render(attachment: AttachmentMetadata | null) {
await act(async () => root.render(<PreviewProbe attachment={attachment} />));
}
async function waitForReady(): Promise<string> {
await vi.waitFor(() => expect(latest?.status).toBe("ready"));
if (latest?.status !== "ready") throw new Error("preview never resolved");
return latest.url;
}
beforeEach(() => {
vi.stubGlobal("React", React);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
latest = null;
});
afterEach(() => {
act(() => root.unmount());
container.remove();
vi.unstubAllGlobals();
});
it("never hands back a URL it already released when the same attachment returns", async () => {
const attachment = await persistAttachmentFromBlob({
blob: new Blob([new Uint8Array([1, 2, 3])], { type: "image/png" }),
mimeType: "image/png",
fileName: "shot.png",
});
await render(attachment);
const released = await waitForReady();
await render(null);
await render(attachment);
expect(latest).not.toEqual({ status: "ready", url: released });
expect(await waitForReady()).not.toBe(released);
});
it("retries a failed attachment from loading when it returns", async () => {
const stored = await persistAttachmentFromBlob({
blob: new Blob([new Uint8Array([1, 2, 3])], { type: "image/png" }),
mimeType: "image/png",
fileName: "shot.png",
});
const missing = { ...stored, id: "missing-attachment", storageKey: "missing-attachment" };
await render(missing);
await vi.waitFor(() => expect(latest?.status).toBe("failed"));
await render(null);
await render(missing);
expect(latest).toEqual({ status: "loading" });
});
@@ -2,62 +2,75 @@ import { useEffect, useRef, useState } from "react";
import type { AttachmentMetadata } from "@/attachments/types";
import { releaseAttachmentPreviewUrl, resolveAttachmentPreviewUrl } from "@/attachments/service";
export type AttachmentPreviewUrl =
| { status: "loading" }
| { status: "ready"; url: string }
| { status: "failed" };
const LOADING: AttachmentPreviewUrl = { status: "loading" };
const FAILED: AttachmentPreviewUrl = { status: "failed" };
function attachmentKey(attachment: AttachmentMetadata | null | undefined): string | null {
if (!attachment) return null;
return [attachment.id, attachment.storageType, attachment.storageKey, attachment.mimeType].join(
"\u0000",
);
}
/**
* Resolves a displayable URL for a stored attachment. Stays `loading` until the URL for the
* current attachment resolves, so a caller never shows a failure, or the previous attachment's
* URL, while the next one is still resolving. A missing attachment stays `loading`.
*/
export function useAttachmentPreviewUrl(
attachment: AttachmentMetadata | null | undefined,
): string | null {
const [url, setUrl] = useState<string | null>(null);
const activeAttachmentRef = useRef<AttachmentMetadata | null>(null);
): AttachmentPreviewUrl {
const [resolved, setResolved] = useState<{
key: string;
preview: AttachmentPreviewUrl;
} | null>(null);
const attachmentRef = useRef(attachment);
attachmentRef.current = attachment;
const id = attachment?.id;
const storageType = attachment?.storageType;
const storageKey = attachment?.storageKey;
const mimeType = attachment?.mimeType;
const key = attachmentKey(attachment);
useEffect(() => {
let disposed = false;
let currentUrl: string | null = null;
const current = attachmentRef.current;
activeAttachmentRef.current = current ?? null;
if (!current) {
setUrl(null);
if (!current || key === null) {
return;
}
let disposed = false;
let currentUrl: string | null = null;
void (async () => {
try {
const resolved = await resolveAttachmentPreviewUrl(current);
const url = await resolveAttachmentPreviewUrl(current);
if (disposed) {
await releaseAttachmentPreviewUrl({ attachment: current, url: resolved });
await releaseAttachmentPreviewUrl({ attachment: current, url });
return;
}
currentUrl = resolved;
setUrl(resolved);
currentUrl = url;
setResolved({ key, preview: { status: "ready", url } });
} catch (error) {
console.error("[attachments] Failed to resolve preview URL", {
attachmentId: current.id,
error,
});
if (!disposed) {
setUrl(null);
setResolved({ key, preview: FAILED });
}
}
})();
return () => {
disposed = true;
const activeAttachment = activeAttachmentRef.current;
if (!currentUrl || !activeAttachment) {
return;
if (currentUrl) {
void releaseAttachmentPreviewUrl({ attachment: current, url: currentUrl });
}
void releaseAttachmentPreviewUrl({
attachment: activeAttachment,
url: currentUrl,
});
// A result lives only as long as its lookup. Kept, the same attachment coming back would
// read a released URL, or an old failure, until the next lookup lands.
setResolved((previous) => (previous?.key === key ? null : previous));
};
}, [id, storageType, storageKey, mimeType]);
}, [key]);
return url;
return resolved !== null && resolved.key === key ? resolved.preview : LOADING;
}
@@ -1,18 +1,18 @@
import { memo, useCallback, useMemo, useState } from "react";
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import { Text, View } from "react-native";
import { ExternalLink, Gift } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import {
AdaptiveModalSheet,
SheetHeaderIconButton,
type SheetHeader,
} from "@/components/adaptive-modal-sheet";
import { MarkdownRenderer } from "@/components/markdown/renderer";
import { Alert } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import {
iconButtonChromeGlyphSize,
iconButtonChromeStyle,
} from "@/components/ui/icon-button-chrome";
import { StatusBadge } from "@/components/ui/status-badge";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import { resolveAppVersion } from "@/utils/app-version";
@@ -28,12 +28,9 @@ import {
const WEBSITE_CHANGELOG_URL = "https://paseo.sh/changelog";
const ThemedGift = withUnistyles(Gift);
const ThemedExternalLink = withUnistyles(ExternalLink);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const sheetLeadingIcon = <ThemedGift size={ICON_SIZE.md} uniProps={mutedColorMapping} />;
const websiteButtonStyle = (state: PressableStateCallbackType & { hovered?: boolean }) =>
iconButtonChromeStyle({ size: "large", state });
interface ChangelogSheetProps {
visible: boolean;
@@ -54,19 +51,12 @@ export function ChangelogSheet({ visible, onClose }: ChangelogSheetProps) {
title: t("changelog.title"),
leading: sheetLeadingIcon,
actions: (
<Pressable
<SheetHeaderIconButton
icon={ExternalLink}
label={t("changelog.openWebsite")}
onPress={handleOpenWebsite}
hitSlop={8}
style={websiteButtonStyle}
accessibilityRole="button"
accessibilityLabel={t("changelog.openWebsite")}
testID="changelog-open-website"
>
<ThemedExternalLink
size={iconButtonChromeGlyphSize("large")}
uniProps={mutedColorMapping}
/>
</Pressable>
/>
),
}),
[handleOpenWebsite, t],
+412 -295
View File
@@ -24,10 +24,12 @@ import {
useSyncExternalStore,
} from "react";
import { useTranslation } from "react-i18next";
import { Check, ChevronRight, Folder, X } from "lucide-react-native";
import { Check, ChevronRight } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { BottomSheetFlatList, type BottomSheetFlatListMethods } from "@gorhom/bottom-sheet";
import { useAgentDescriptor, resolveAgentLabel } from "@/agent-presentation";
import { resolveAgentLabel } from "@/agent-presentation";
import { useArchivedWorkspaceSearch, type ArchivedWorkspace } from "@/archived-workspaces";
import { ArchivedBadge } from "@/components/archived-badge";
import { StatusIcon } from "@/components/status-icon";
import { useWorkspaceStatus } from "@/workspace-status";
import type { SurfaceBackdrop } from "@/styles/surface-backdrop";
@@ -35,12 +37,14 @@ import type { Theme } from "@/styles/theme";
import { HighlightedText, type HighlightedTextProps } from "@/components/ui/highlighted-text";
import { findHighlightRanges } from "@/components/ui/highlighted-text-segments";
import { MaterialFileIcon } from "@/components/material-file-icon";
import { useProviderIcon } from "@/components/provider-icons";
import { ProjectIconView } from "@/components/project-icon-view";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import {
EditingTextInput as TextInput,
type EditingTextInputHandle,
} from "@/components/ui/text-input";
import { SegmentedControl, type SegmentedControlOption } from "@/components/ui/segmented-control";
import { Shortcut } from "@/components/ui/shortcut";
import {
IsolatedBottomSheetModal,
@@ -48,18 +52,20 @@ import {
} from "@/components/ui/isolated-bottom-sheet-modal";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative, isWeb } from "@/constants/platform";
import { useAggregatedAgents } from "@/hooks/use-aggregated-agents";
import { useAggregatedAgents, type AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { useProjects } from "@/hooks/use-projects";
import { useTimeAgo } from "@/hooks/use-time-ago";
import { createProjectIconTarget } from "@/projects/icon-target";
import { useProjectIcons } from "@/projects/icons";
import { OverlayLayerProvider, useGlobalOverlayLayer } from "@/lib/overlay-root";
import { useHosts } from "@/runtime/host-runtime";
import { useHostRuntimeConnectionStatuses, useHosts } from "@/runtime/host-runtime";
import { useHydratedWorkspaceServerIds } from "@/stores/session-store-hooks";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { useSidebarViewStore } from "@/stores/sidebar-view-store";
import {
useKeyboardShortcutsStore,
type CommandCenterScope,
} from "@/stores/keyboard-shortcuts-store";
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
navigateToWorkspace,
useActiveWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store";
import { useKeyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher-context";
import {
clearCommandCenterFocusRestoreElement,
@@ -67,8 +73,8 @@ import {
} from "@/utils/command-center-focus-restore";
import { focusWithRetries } from "@/utils/web-focus";
import { navigateToAgent } from "@/utils/navigate-to-agent";
import { shortenPath } from "@/utils/shorten-path";
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
import { getProjectSummaryForHostProject } from "@/utils/projects";
import {
COMMAND_CENTER_LINE_HEIGHT,
COMMAND_CENTER_LIST_METRICS,
@@ -76,21 +82,23 @@ import {
type CommandCenterListMetrics,
} from "./list-metrics";
import { useCommandCenterContributions } from "./provider";
import { filterAndRankWorkspaces } from "./workspace-search";
import { createWorkspaceRanker } from "./workspace-search";
import {
buildContributionSections,
filterAndRankBuiltInResults,
joinSubtitleParts,
PINNED_SECTION_BAND,
projectCommandCenterRows,
type CommandCenterAgentResult,
type CommandCenterFileResult,
type CommandCenterListRow,
type CommandCenterResult,
type CommandCenterResultSection,
type CommandCenterSearchFields,
type CommandCenterWorkspaceAgent,
type CommandCenterWorkspaceResult,
} from "./results";
import { buildCommandCenterSections } from "./sections";
import {
availableCommandCenterTabs,
resolveCommandCenterTab,
rotateCommandCenterTab,
type CommandCenterTab,
} from "./tabs";
import { createCommandCenterInteraction, type CommandCenterInteraction } from "./interaction";
import { useWorkspaceFileSearch } from "./workspace-file-search";
@@ -100,23 +108,16 @@ const ThemedBottomSheetTextInput = withUnistyles(TextInput, (theme) => ({
const ThemedTextInput = withUnistyles(TextInput, (theme) => ({
placeholderTextColor: theme.colors.foregroundMuted,
}));
const ThemedFolder = withUnistyles(Folder, (theme) => ({ color: theme.colors.foregroundMuted }));
const ThemedCheck = withUnistyles(Check, (theme) => ({ color: theme.colors.foreground }));
const ThemedChevronRight = withUnistyles(ChevronRight, (theme) => ({
color: theme.colors.foregroundMuted,
}));
const ThemedX = withUnistyles(X, (theme) => ({ color: theme.colors.foregroundMuted }));
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner, (theme) => ({
color: theme.colors.foregroundMuted,
}));
const COMMAND_CENTER_SNAP_POINTS = ["60%", "90%"];
const KEYBOARD_SHOULD_PERSIST_TAPS = "always" as const;
/** `cwd` is not rendered, so a path match must never outrank a match on text the user can see. */
function agentSearchFields(result: CommandCenterAgentResult): CommandCenterSearchFields {
return { visible: [result.title, result.subtitle], hidden: [result.agent.cwd] };
}
function createWorkspaceResultAction(serverId: string, workspaceId: string) {
return () => {
clearCommandCenterFocusRestoreElement();
@@ -124,28 +125,73 @@ function createWorkspaceResultAction(serverId: string, workspaceId: string) {
};
}
function createAgentResultAction(serverId: string, agentId: string) {
return () => {
function createAgentResultAction(serverId: string) {
return (agentId: string) => {
clearCommandCenterFocusRestoreElement();
navigateToAgent({ serverId, agentId });
};
}
/**
* Build every pinned row. Deliberately not keyed on `query`: none of this work depends on what
* was typed. Ordering belongs to `filterAndRankBuiltInResults`, which ranks on every keystroke.
*/
function useBuiltInRows(open: boolean): {
workspaces: CommandCenterWorkspaceResult[];
agents: CommandCenterAgentResult[];
} {
const { t } = useTranslation();
const previousRows = useRef(
new Map<string, CommandCenterAgentResult | CommandCenterWorkspaceResult>(),
const NO_WORKSPACE_AGENTS: readonly CommandCenterWorkspaceAgent[] = [];
/** Each workspace's titled agents, most recent first, keyed `serverId:workspaceId`. */
function groupWorkspaceAgents(
agents: readonly AggregatedAgent[],
): Map<string, CommandCenterWorkspaceAgent[]> {
const byWorkspace = new Map<string, CommandCenterWorkspaceAgent[]>();
const newestFirst = [...agents].sort(
(left, right) => right.lastActivityAt.getTime() - left.lastActivityAt.getTime(),
);
const { agents } = useAggregatedAgents({ demand: open });
const { projects } = useProjects({ enabled: open });
const showHost = useHosts().length > 1;
for (const agent of newestFirst) {
const title = resolveAgentLabel(agent.title);
if (!agent.workspaceId || !title) continue;
const key = `${agent.serverId}:${agent.workspaceId}`;
const list = byWorkspace.get(key) ?? [];
list.push({ id: agent.id, title, provider: agent.provider });
byWorkspace.set(key, list);
}
return byWorkspace;
}
function createArchivedAgentAction(serverId: string, workspaceId: string) {
// Opening on an agent with the tab pinned is what offers Unarchive, as History does.
return (agentId: string) => {
clearCommandCenterFocusRestoreElement();
navigateToAgent({ serverId, agentId, workspaceId, pin: true });
};
}
function titledAgents(
agents: readonly { id: string; title: string | null; provider: string }[],
): CommandCenterWorkspaceAgent[] {
return agents.flatMap((agent) => {
const title = resolveAgentLabel(agent.title);
return title ? [{ id: agent.id, title, provider: agent.provider }] : [];
});
}
interface WorkspaceRows {
active: CommandCenterWorkspaceResult[];
archived: CommandCenterWorkspaceResult[];
/** The workspace list has not loaded yet, so rows are still arriving. */
loading: boolean;
}
/**
* Build every workspace row. Deliberately not keyed on `query`: none of this work depends on
* what was typed. Ordering belongs to the workspace ranker, which ranks on every keystroke.
* Archived rows come from the host's history search, so they change with its results.
*/
function useWorkspaceRows(
open: boolean,
archivedWorkspaces: readonly ArchivedWorkspace[],
): WorkspaceRows {
const previousRows = useRef(new Map<string, CommandCenterWorkspaceResult>());
const { agents, isInitialLoad: agentsLoading } = useAggregatedAgents({ demand: open });
const { projects, isLoading: projectsLoading } = useProjects({ enabled: open });
const hosts = useHosts();
const workspacesArriving = useWorkspacesArriving(open, hosts);
const showHost = hosts.length > 1;
const iconTargets = useMemo(
() =>
open
@@ -163,24 +209,26 @@ function useBuiltInRows(open: boolean): {
);
const iconDataByProjectViewKey = useProjectIcons({ projects: iconTargets });
return useMemo(() => {
const active = useMemo(() => {
if (!open) {
previousRows.current.clear();
return { workspaces: [], agents: [] };
return [];
}
// Built-in IDs identify immutable navigation targets. Reuse their callbacks and unchanged
// Workspace IDs identify immutable navigation targets. Reuse their callbacks and unchanged
// row data so a directory update does not invalidate every mounted ResultRow.
const nextRows = new Map<string, CommandCenterAgentResult | CommandCenterWorkspaceResult>();
const preserve = <T extends CommandCenterAgentResult | CommandCenterWorkspaceResult>(
row: T,
): T => {
const nextRows = new Map<string, CommandCenterWorkspaceResult>();
const preserve = (row: CommandCenterWorkspaceResult): CommandCenterWorkspaceResult => {
const previous = previousRows.current.get(row.id);
if (previous) row.run = previous.run;
const stable = previous && equal(previous, row) ? (previous as T) : row;
if (previous) {
row.run = previous.run;
row.openAgent = previous.openAgent;
}
const stable = previous && equal(previous, row) ? previous : row;
nextRows.set(row.id, stable);
return stable;
};
const allWorkspaces: CommandCenterWorkspaceResult[] = [];
const agentsByWorkspace = groupWorkspaceAgents(agents);
const rows: CommandCenterWorkspaceResult[] = [];
for (const project of projects) {
const workspaceProject = {
viewKey: project.viewKey,
@@ -190,10 +238,11 @@ function useBuiltInRows(open: boolean): {
for (const host of project.hosts) {
for (const workspace of host.workspaces) {
if (workspace.archivingAt) continue;
allWorkspaces.push(
const key = `${host.serverId}:${workspace.id}`;
rows.push(
preserve({
kind: "workspace",
id: `workspace:${host.serverId}:${workspace.id}`,
id: `workspace:${key}`,
title: workspace.title ?? workspace.name,
subtitle: joinSubtitleParts([
showHost ? host.serverName : null,
@@ -205,66 +254,93 @@ function useBuiltInRows(open: boolean): {
project: workspaceProject,
changeRequestNumber: workspace.changeRequestNumber,
activityAt: workspace.statusEnteredAt,
agents: agentsByWorkspace.get(key) ?? NO_WORKSPACE_AGENTS,
matchedAgent: null,
archived: false,
background: workspace.background,
run: createWorkspaceResultAction(host.serverId, workspace.id),
openAgent: createAgentResultAction(host.serverId),
}),
);
}
}
}
const workspaceTitleByKey = new Map(
allWorkspaces.map((workspace) => [workspace.id.slice("workspace:".length), workspace.title]),
);
const agentRows = agents.map<CommandCenterAgentResult>((agent) => {
const workspaceTitle = agent.workspaceId
? workspaceTitleByKey.get(`${agent.serverId}:${agent.workspaceId}`)
: undefined;
return preserve({
kind: "agent",
id: `agent:${agent.serverId}:${agent.id}`,
agent,
title: resolveAgentLabel(agent.title) ?? t("workspace.tabs.loading"),
subtitle: joinSubtitleParts([
showHost ? agent.serverLabel : null,
workspaceTitle ?? shortenPath(agent.cwd),
]),
activityAt: agent.lastActivityAt,
run: createAgentResultAction(agent.serverId, agent.id),
});
});
previousRows.current = nextRows;
return { workspaces: allWorkspaces, agents: agentRows };
}, [agents, iconDataByProjectViewKey, open, projects, showHost, t]);
return rows;
}, [agents, iconDataByProjectViewKey, open, projects, showHost]);
const archived = useMemo(() => {
const activeIds = new Set(active.map((row) => row.id));
const hostLabels = new Map(hosts.map((host) => [host.serverId, host.label]));
return archivedWorkspaces.flatMap<CommandCenterWorkspaceResult>((workspace) => {
const id = `workspace:${workspace.key}`;
// The host's history can trail its workspace list; the active row wins.
if (activeIds.has(id)) return [];
const project = workspace.projectKey
? getProjectSummaryForHostProject(projects, workspace.serverId, workspace.projectKey)
: undefined;
const projectName = project?.projectName ?? workspace.projectName ?? workspace.name;
const openAgent = createArchivedAgentAction(workspace.serverId, workspace.workspaceId);
const latestAgentId = workspace.agents[0].id;
return [
{
kind: "workspace",
id,
title: workspace.name,
subtitle: joinSubtitleParts([
showHost ? (hostLabels.get(workspace.serverId) ?? workspace.serverId) : null,
projectName,
workspace.branch,
]),
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
project: {
viewKey: project?.viewKey ?? `archived:${workspace.key}`,
name: projectName,
iconDataUri: project ? (iconDataByProjectViewKey.get(project.viewKey) ?? null) : null,
},
changeRequestNumber: null,
activityAt: workspace.archivedAt,
agents: titledAgents(workspace.agents),
matchedAgent: null,
archived: true,
background: false,
run: () => openAgent(latestAgentId),
openAgent,
},
];
});
}, [active, archivedWorkspaces, hosts, iconDataByProjectViewKey, projects, showHost]);
return {
active,
archived,
// Opening a workspace route loads that workspace before the rest of the directory, so rows
// are no sign that the list is complete.
loading: agentsLoading || projectsLoading || workspacesArriving,
};
}
function useBuiltInSections(open: boolean, query: string): CommandCenterResultSection[] {
const { t } = useTranslation();
const rows = useBuiltInRows(open);
return useMemo(() => {
if (!open) return [];
return [
{
id: "workspaces",
band: PINNED_SECTION_BAND,
rank: 2,
title: t("shell.commandCenter.workspaces"),
results: filterAndRankWorkspaces(rows.workspaces, query),
},
{
id: "agents",
band: PINNED_SECTION_BAND,
rank: 3,
title: t("shell.commandCenter.agents"),
results: filterAndRankBuiltInResults(rows.agents, query, agentSearchFields),
},
];
}, [open, query, rows, t]);
/**
* An online host's workspaces have not arrived yet. Its agent directory reports ready before
* the workspace snapshot lands, so until then the workspace rows are incomplete.
*/
function useWorkspacesArriving(open: boolean, hosts: readonly { serverId: string }[]): boolean {
const serverIds = useMemo(() => (open ? hosts.map((host) => host.serverId) : []), [hosts, open]);
const connectionStatuses = useHostRuntimeConnectionStatuses(serverIds);
const hydratedServerIds = useHydratedWorkspaceServerIds(serverIds);
return serverIds.some(
(serverId) =>
connectionStatuses.get(serverId) === "online" && !hydratedServerIds.includes(serverId),
);
}
interface CommandCenterState {
open: boolean;
scope: CommandCenterScope;
clearScope(): void;
/** The tabs on offer, in the order Tab walks them. */
tabs: readonly CommandCenterTab[];
tab: CommandCenterTab;
selectTab(tab: CommandCenterTab): void;
query: string;
setQuery(query: string): void;
activeId: string | null;
@@ -275,7 +351,7 @@ interface CommandCenterState {
fileSearchError: string | null;
close(): void;
select(result: CommandCenterResult): void;
key(key: string): boolean;
key(event: { key: string; shiftKey?: boolean }): boolean;
}
function useCommandCenterState(
@@ -285,62 +361,95 @@ function useCommandCenterState(
const keyboardActionDispatcher = useKeyboardActionDispatcher();
const { t } = useTranslation();
const open = useKeyboardShortcutsStore((state) => state.commandCenterOpen);
const scope = useKeyboardShortcutsStore((state) => state.commandCenterScope);
const requestedTab = useKeyboardShortcutsStore((state) => state.commandCenterTab);
const setOpen = useKeyboardShortcutsStore((state) => state.setCommandCenterOpen);
const setScope = useKeyboardShortcutsStore((state) => state.setCommandCenterScope);
const setTab = useKeyboardShortcutsStore((state) => state.setCommandCenterTab);
const snapshot = useCommandCenterContributions();
const inputRef = useRef<EditingTextInputHandle>(null);
const previousOpenRef = useRef(open);
const { query, activeId } = useSyncExternalStore(interaction.subscribe, interaction.getSnapshot);
const builtInSections = useBuiltInSections(open, query);
const activeWorkspace = useActiveWorkspaceSelection();
const hasWorkspace = activeWorkspace !== null;
const tabs = useMemo(() => availableCommandCenterTabs({ hasWorkspace }), [hasWorkspace]);
const tab = resolveCommandCenterTab(requestedTab, tabs);
const showBackground = useSidebarViewStore((state) => state.showBackground);
const listsWorkspaces = tab === "all" || tab === "workspaces";
const archivedSearch = useArchivedWorkspaceSearch(query, {
enabled: open && listsWorkspaces,
});
const workspaceRows = useWorkspaceRows(open, archivedSearch.workspaces);
const [rankWorkspaces] = useState(createWorkspaceRanker);
const currentWorkspaceId = activeWorkspace
? `workspace:${activeWorkspace.serverId}:${activeWorkspace.workspaceId}`
: null;
const {
entries: fileSearchEntries,
loading: fileSearchLoading,
error: fileSearchError,
openFile,
} = useWorkspaceFileSearch({
enabled: open && (scope === "files" || Boolean(query.trim())),
enabled:
open &&
tabs.includes("files") &&
(tab === "files" || (tab === "all" && Boolean(query.trim()))),
query,
});
const fileSections = useMemo<CommandCenterResultSection[]>(() => {
const results = fileSearchEntries.map<CommandCenterFileResult>((entry) => ({
kind: "file",
id: `file:${entry.path}`,
filePath: entry.path,
title: entry.name,
subtitle: entry.directory,
run: () => openFile(entry.path),
}));
// File rows are matched by the daemon, so they carry no client-side score and cannot be
// relevance-compared against the rows that do. Pinning them keeps the section comparator
// total: band is decided before any score is read.
return [
{
id: "files",
band: PINNED_SECTION_BAND,
rank: 4,
title: t("shell.commandCenter.files"),
results,
},
];
}, [fileSearchEntries, openFile, t]);
const contributionSections = useMemo(
() => buildContributionSections(snapshot.contributions, query),
[query, snapshot.contributions],
const files = useMemo(
() =>
fileSearchEntries.map<CommandCenterFileResult>((entry) => ({
kind: "file",
id: `file:${entry.path}`,
filePath: entry.path,
title: entry.name,
subtitle: entry.directory,
run: () => openFile(entry.path),
})),
[fileSearchEntries, openFile],
);
const sections = useMemo(
() =>
open
? buildCommandCenterSections({
tab,
query,
rankWorkspaces,
workspaces: workspaceRows.active,
archivedWorkspaces: workspaceRows.archived,
archivedMatchedByHost: archivedSearch.matchedByHost,
showBackground,
currentWorkspaceId,
contributions: snapshot.contributions,
files,
titles: {
recentWorkspaces: t("shell.commandCenter.recentWorkspaces"),
workspaces: t("shell.commandCenter.workspaces"),
files: t("shell.commandCenter.files"),
},
})
: [],
[
archivedSearch.matchedByHost,
currentWorkspaceId,
files,
open,
query,
rankWorkspaces,
showBackground,
snapshot.contributions,
t,
tab,
workspaceRows,
],
);
const projection = useMemo(
() =>
projectCommandCenterRows(
scope === "files"
? fileSections
: [...contributionSections, ...fileSections, ...builtInSections],
listMetrics,
),
[builtInSections, contributionSections, fileSections, listMetrics, scope],
() => projectCommandCenterRows(sections, listMetrics),
[listMetrics, sections],
);
const loading =
fileSearchLoading || (listsWorkspaces && (archivedSearch.pending || workspaceRows.loading));
useLayoutEffect(() => {
interaction.update({ open, scope, query, projection, loading: fileSearchLoading });
}, [interaction, open, scope, query, projection, fileSearchLoading]);
interaction.update({ open, tab, query, projection, loading });
}, [interaction, open, tab, query, projection, loading]);
const close = useCallback(() => setOpen(false), [setOpen]);
const select = useCallback(
@@ -350,16 +459,29 @@ function useCommandCenterState(
},
[setOpen],
);
const selectTab = useCallback(
(next: CommandCenterTab) => {
setTab(next);
// A tab click must not take typing away from the search field.
inputRef.current?.focus();
},
[setTab],
);
const key = useCallback(
(pressed: string): boolean => {
(event: { key: string; shiftKey?: boolean }): boolean => {
if (!open) return false;
const pressed = event.key;
const results = projection.selectableResults;
if (pressed === "Escape") {
close();
return true;
}
if (pressed === "Backspace" && !query.trim() && scope) {
setScope(null);
if (pressed === "Tab") {
setTab(rotateCommandCenterTab(tab, event.shiftKey ? "previous" : "next", tabs));
return true;
}
if (pressed === "Backspace" && !query.trim() && tab !== "all") {
setTab("all");
return true;
}
if (pressed === "Enter") {
@@ -377,9 +499,10 @@ function useCommandCenterState(
projection.selectableResults,
query,
activeId,
scope,
tab,
tabs,
select,
setScope,
setTab,
interaction,
],
);
@@ -405,8 +528,9 @@ function useCommandCenterState(
return {
open,
scope,
clearScope: () => setScope(null),
tabs,
tab,
selectTab,
query,
setQuery: interaction.setQuery,
activeId,
@@ -536,55 +660,38 @@ function SubtitleWithTimestamp({
);
}
const providerIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
function AgentResultContent({
result,
/**
* The third line of a workspace that matched only through one of its agents: which agent, so the
* user can tell why the row is here. It starts on the title's rail.
*/
function MatchedAgentLine({
agent,
serverId,
query,
backdrop,
}: {
result: CommandCenterAgentResult;
agent: CommandCenterWorkspaceAgent;
serverId: string;
query: string;
backdrop: SurfaceBackdrop;
}) {
const agent = result.agent;
const descriptor = useAgentDescriptor(
{ kind: "agent", agentId: agent.id },
{ serverId: agent.serverId },
);
const Icon = useMemo(
() => withUnistyles(descriptor.icon, providerIconMapping),
[descriptor.icon],
);
const ProviderIcon = useProviderIcon(agent.provider, serverId);
const Icon = useMemo(() => withUnistyles(ProviderIcon, providerIconMapping), [ProviderIcon]);
return (
<View style={styles.rowContent} testID={`command-center-agent-${agent.serverId}:${agent.id}`}>
<View style={styles.rowMain}>
<View style={styles.iconSlot}>
<StatusIcon statusBucket={descriptor.statusBucket} size={16} backdrop={backdrop}>
<View accessibilityRole="image" accessibilityLabel={descriptor.subtitle}>
<Icon size={16} />
</View>
</StatusIcon>
</View>
<View style={styles.textContent}>
<QueryHighlightedText
query={query}
text={result.title}
style={styles.title}
numberOfLines={1}
/>
<SubtitleWithTimestamp
query={query}
subtitle={result.subtitle}
activityAt={result.activityAt}
testID="command-center-agent-subtitle"
/>
</View>
<View style={styles.agentLine} testID="command-center-workspace-agent">
<View style={styles.agentLineIcon}>
<Icon size={12} />
</View>
<QueryHighlightedText
query={query}
text={agent.title}
style={[styles.subtitle, styles.subtitleText]}
numberOfLines={1}
/>
</View>
);
}
const providerIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
function WorkspaceResultIcon({
result,
backdrop,
@@ -595,24 +702,27 @@ function WorkspaceResultIcon({
const statusBucket = useWorkspaceStatus(result.serverId, result.workspaceId);
return (
<StatusIcon statusBucket={statusBucket} size={16} backdrop={backdrop}>
<View accessibilityElementsHidden importantForAccessibility="no-hide-descendants" aria-hidden>
<WorkspaceProjectIcon project={result.project} />
</View>
<DecorativeProjectIcon project={result.project} />
</StatusIcon>
);
}
/** The same project icon the sidebar's workspace rows lead with: its image, or a lettered square. */
function WorkspaceProjectIcon({ project }: { project: CommandCenterWorkspaceResult["project"] }) {
/**
* The same project icon the sidebar's workspace rows lead with: its image, or a lettered square.
* Hidden from assistive tech so the fallback's initial does not prefix the row's name.
*/
function DecorativeProjectIcon({ project }: { project: CommandCenterWorkspaceResult["project"] }) {
const initial = projectIconPlaceholderLabelFromDisplayName(project.name).charAt(0).toUpperCase();
return (
<ProjectIconView
iconDataUri={project.iconDataUri}
initial={initial}
projectViewKey={project.viewKey}
size={16}
textStyle={styles.projectIconFallbackText}
/>
<View accessibilityElementsHidden importantForAccessibility="no-hide-descendants" aria-hidden>
<ProjectIconView
iconDataUri={project.iconDataUri}
initial={initial}
projectViewKey={project.viewKey}
size={16}
textStyle={styles.projectIconFallbackText}
/>
</View>
);
}
@@ -650,17 +760,18 @@ function ResultContent({
</View>
);
}
if (result.kind === "agent") {
return <AgentResultContent result={result} query={query} backdrop={backdrop} />;
}
if (result.kind === "workspace") {
const key = result.id.slice("workspace:".length);
return (
<View style={styles.rowContent} testID={`command-center-workspace-${key}`}>
<View style={styles.rowMain}>
{/* Decorative: the fallback icon's initial must not prefix the row's accessible name. */}
<View style={styles.iconSlot}>
<WorkspaceResultIcon result={result} backdrop={backdrop} />
{/* An archived workspace runs nothing, so it has no status to show. */}
{result.archived ? (
<DecorativeProjectIcon project={result.project} />
) : (
<WorkspaceResultIcon result={result} backdrop={backdrop} />
)}
</View>
<View style={styles.textContent}>
<QueryHighlightedText
@@ -675,8 +786,16 @@ function ResultContent({
activityAt={result.activityAt}
testID="command-center-workspace-subtitle"
/>
{result.matchedAgent ? (
<MatchedAgentLine
agent={result.matchedAgent}
serverId={result.serverId}
query={query}
/>
) : null}
</View>
</View>
{result.archived ? <ArchivedBadge /> : null}
</View>
);
}
@@ -761,9 +880,24 @@ function SectionRow({ row }: { row: Extract<CommandCenterListRow, { kind: "secti
}
// Escape arrives without asking; it is registered for `agent.interrupt` anyway.
// Backspace stays off deliberately — the palette pops its scope with it on web,
// Backspace stays off deliberately — on web it returns a tab to All when the field is empty,
// but a registered command would stop the search field deleting text.
const TAB_PLACEHOLDER_KEYS = {
all: "shell.commandCenter.placeholder",
workspaces: "shell.commandCenter.workspacePlaceholder",
files: "shell.commandCenter.filePlaceholder",
actions: "shell.commandCenter.actionPlaceholder",
} as const satisfies Record<CommandCenterTab, string>;
/** The search field names what the tab searches; All names only the kinds on offer. */
function placeholderKey(tab: CommandCenterTab, tabs: readonly CommandCenterTab[]) {
if (tab === "all" && !tabs.includes("files")) {
return "shell.commandCenter.placeholderWithoutFiles";
}
return TAB_PLACEHOLDER_KEYS[tab];
}
export function CommandCenter() {
const { t } = useTranslation();
const isCompact = useIsCompactFormFactor();
@@ -875,22 +1009,42 @@ export function CommandCenter() {
: {}),
scrollEventThrottle: 16,
};
const submit = useCallback(() => state.key("Enter"), [state]);
const submit = useCallback(() => state.key({ key: "Enter" }), [state]);
const handleOverlayKeyDown = useCallback(
(event: KeyboardInputEvent) => {
if (!state.key(event.key)) return false;
if (!state.key(event)) return false;
event.preventDefault();
return true;
},
[state],
);
const setOverlayScope = useOverlayRegistration({
keys: ["Escape", "Enter", "ArrowUp", "ArrowDown"],
keys: ["Escape", "Enter", "ArrowUp", "ArrowDown", "Tab"],
active: state.open,
layer: modalLayer,
onKeyDown: handleOverlayKeyDown,
});
const tabOptions = useMemo<SegmentedControlOption<CommandCenterTab>[]>(
() =>
state.tabs.map((tab) => ({
value: tab,
label: t(`shell.commandCenter.tabs.${tab}`),
testID: `command-center-tab-${tab}`,
})),
[state.tabs, t],
);
const tabs = (
<SegmentedControl
options={tabOptions}
value={state.tab}
onValueChange={state.selectTab}
size="sm"
testID="command-center-tabs"
/>
);
const placeholder = t(placeholderKey(state.tab, state.tabs));
if (showBottomSheet) {
return (
<IsolatedBottomSheetModal
@@ -910,32 +1064,28 @@ export function CommandCenter() {
accessible={false}
>
<OverlayLayerProvider layer={modalLayer}>
<View style={[styles.bottomSheetHeader, styles.searchRow]} testID="command-center-header">
{state.scope === "files" ? (
<ScopeChip label={t("shell.commandCenter.files")} onRemove={state.clearScope} />
) : null}
<ThemedBottomSheetTextInput
testID="command-center-input"
ref={bottomSheetInputRef}
initialValue={state.query}
variant="bottom-sheet"
onChangeText={state.setQuery}
onSubmitEditing={submit}
placeholder={
state.scope === "files"
? t("shell.commandCenter.filePlaceholder")
: t("shell.commandCenter.placeholder")
}
style={[styles.input, styles.growingInput]}
autoCapitalize="none"
autoCorrect={false}
autoFocus
/>
<FileSearchLoadingIndicator
loading={state.fileSearchLoading}
label={t("shell.commandCenter.searchingFiles")}
/>
<View style={styles.header} testID="command-center-header">
<View style={styles.searchRow}>
<ThemedBottomSheetTextInput
testID="command-center-input"
ref={bottomSheetInputRef}
initialValue={state.query}
variant="bottom-sheet"
onChangeText={state.setQuery}
onSubmitEditing={submit}
placeholder={placeholder}
style={[styles.input, styles.growingInput]}
autoCapitalize="none"
autoCorrect={false}
autoFocus
/>
<FileSearchLoadingIndicator
loading={state.fileSearchLoading}
label={t("shell.commandCenter.searchingFiles")}
/>
</View>
</View>
<View style={styles.tabBar}>{tabs}</View>
{fileSearchError}
<BottomSheetFlatList ref={bottomSheetListRef} {...commonListProps} />
</OverlayLayerProvider>
@@ -949,30 +1099,26 @@ export function CommandCenter() {
<View style={styles.overlay}>
<Pressable style={styles.backdrop} onPress={state.close} />
<View ref={setOverlayScope} testID="command-center-panel" style={styles.panel}>
<View style={[styles.header, styles.searchRow]} testID="command-center-header">
{state.scope === "files" ? (
<ScopeChip label={t("shell.commandCenter.files")} onRemove={state.clearScope} />
) : null}
<ThemedTextInput
testID="command-center-input"
ref={state.inputRef}
initialValue={state.query}
onChangeText={state.setQuery}
placeholder={
state.scope === "files"
? t("shell.commandCenter.filePlaceholder")
: t("shell.commandCenter.placeholder")
}
style={[styles.input, styles.growingInput]}
autoCapitalize="none"
autoCorrect={false}
autoFocus
/>
<FileSearchLoadingIndicator
loading={state.fileSearchLoading}
label={t("shell.commandCenter.searchingFiles")}
/>
<View style={styles.header} testID="command-center-header">
<View style={styles.searchRow}>
<ThemedTextInput
testID="command-center-input"
ref={state.inputRef}
initialValue={state.query}
onChangeText={state.setQuery}
placeholder={placeholder}
style={[styles.input, styles.growingInput]}
autoCapitalize="none"
autoCorrect={false}
autoFocus
/>
<FileSearchLoadingIndicator
loading={state.fileSearchLoading}
label={t("shell.commandCenter.searchingFiles")}
/>
</View>
</View>
<View style={styles.tabBar}>{tabs}</View>
{fileSearchError}
<FlatList ref={listRef} {...commonListProps} />
</View>
@@ -998,25 +1144,6 @@ function FileSearchLoadingIndicator({ loading, label }: { loading: boolean; labe
);
}
// The chip sits at the input's own height and type size so it reads as the scope of the field
// rather than a badge dropped into the query. Its 28px matches the input's line box, so showing or
// dropping the scope cannot resize the header.
function ScopeChip({ label, onRemove }: { label: string; onRemove(): void }) {
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={label}
onPress={onRemove}
style={styles.scopeChip}
testID="command-center-files-scope"
>
<ThemedFolder size={14} strokeWidth={2.2} />
<Text style={styles.scopeChipLabel}>{label}</Text>
<ThemedX size={12} strokeWidth={2.2} />
</Pressable>
);
}
const styles = StyleSheet.create((theme) => ({
overlay: {
flex: 1,
@@ -1043,11 +1170,12 @@ const styles = StyleSheet.create((theme) => ({
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
bottomSheetHeader: {
// Below the divider, above the list. The first section label brings its own top gap, so the
// pills sit with equal space above and below. A fixed bar, so switching tabs never moves the
// field or the list.
tabBar: {
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
paddingTop: COMMAND_CENTER_SECTION_GAP,
},
input: {
fontSize: theme.fontSize.base,
@@ -1058,25 +1186,6 @@ const styles = StyleSheet.create((theme) => ({
growingInput: { flex: 1, minWidth: 0 },
searchRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2] },
fileSearchStatus: { width: 14, height: 14, alignItems: "center", justifyContent: "center" },
scopeChip: {
// Stretching to the row rather than setting a height keeps the chip exactly as tall as the
// input's line box, so showing or dropping the scope can never resize the header.
alignSelf: "stretch",
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1.5],
marginRight: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.md,
borderWidth: 1,
borderColor: theme.colors.borderAccent,
backgroundColor: theme.colors.surface2,
},
scopeChipLabel: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
fontWeight: "500",
},
results: { flex: 1 },
// Sections and rows take their height from list-metrics; these styles only place content in it.
section: { justifyContent: "flex-end" },
@@ -1129,6 +1238,14 @@ const styles = StyleSheet.create((theme) => ({
},
subtitleLine: { flexDirection: "row", minWidth: 0 },
subtitleText: { flexShrink: 1, minWidth: 0 },
agentLine: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1.5],
minWidth: 0,
height: COMMAND_CENTER_LINE_HEIGHT.agent,
},
agentLineIcon: { width: 12, alignItems: "center", justifyContent: "center" },
iconSlot: {
width: 16,
height: COMMAND_CENTER_LINE_HEIGHT.singleLine,
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { createCommandCenterInteraction } from "./interaction";
import { projectCommandCenterRows, type CommandCenterFileResult } from "./results";
import type { CommandCenterScope } from "@/stores/keyboard-shortcuts-store";
import type { CommandCenterTab } from "./tabs";
function results(ids = ["a", "b", "c", "d"]) {
return projectCommandCenterRows(
@@ -21,17 +21,17 @@ function results(ids = ["a", "b", "c", "d"]) {
})),
},
],
{ singleLineRow: 40, twoLineRow: 60, sectionTitle: 20, sectionDivider: 10 },
{ singleLineRow: 40, twoLineRow: 60, threeLineRow: 80, sectionTitle: 20, sectionDivider: 10 },
);
}
function setup(options: { scope?: CommandCenterScope; measured?: boolean } = {}) {
function setup(options: { tab?: CommandCenterTab; measured?: boolean } = {}) {
const offsets: number[] = [];
const model = createCommandCenterInteraction((offset) => offsets.push(offset));
const publish = (ids = ["a", "b", "c", "d"], loading = false) =>
model.update({
open: true,
scope: options.scope ?? null,
tab: options.tab ?? "all",
query: model.getSnapshot().query,
projection: results(ids),
loading,
@@ -53,6 +53,45 @@ describe("Command Center interaction", () => {
expect(offsets).toEqual([]);
});
it("follows the first result while results load, so Enter does not run what rendered first", () => {
const { model, publish } = setup();
model.setQuery("");
// Recent workspaces lead All and arrive after the actions below them.
publish(["new-workspace", "settings"], true);
expect(model.getSnapshot().activeId).toBe("new-workspace");
publish(["recent", "new-workspace", "settings"]);
expect(model.getSnapshot().activeId).toBe("recent");
});
it("keeps the selected row when settled results reorder", () => {
const { model, publish } = setup();
expect(model.getSnapshot().activeId).toBe("a");
// Another workspace's agent changed status, so it moves to the top of Recent workspaces.
publish(["b", "a", "c", "d"]);
expect(model.getSnapshot().activeId).toBe("a");
});
it("keeps the selected row when results land above it after the query settled", () => {
const { model, publish } = setup();
model.setQuery("invoice");
// Files arrive while archived workspaces are still being searched.
publish(["file-1", "file-2"], true);
expect(model.getSnapshot().activeId).toBe("file-1");
publish(["archived", "file-1", "file-2"]);
expect(model.getSnapshot().activeId).toBe("archived");
publish(["archived-2", "archived", "file-1", "file-2"]);
expect(model.getSnapshot().activeId).toBe("archived");
});
it("keeps the user's pick while results are still loading", () => {
const { model, publish } = setup();
model.setQuery("q");
publish(["a", "b"], true);
model.move("next");
publish(["z", "a", "b"], true);
expect(model.getSnapshot().activeId).toBe("b");
});
it("reveals only clipped keyboard selections and wraps around", () => {
const { model, offsets } = setup();
expect(model.move("next")).toBe(true);
@@ -120,7 +159,7 @@ describe("Command Center interaction", () => {
});
it("waits for scoped search results instead of revealing the previous query's rows", () => {
const { model, offsets, publish } = setup({ scope: "files" });
const { model, offsets, publish } = setup({ tab: "files" });
model.scrolled(100);
model.setQuery("new");
publish(["a", "b"], true);
@@ -132,7 +171,7 @@ describe("Command Center interaction", () => {
});
it("manual interaction cancels a reveal awaiting search results", () => {
const { model, offsets, publish } = setup({ scope: "files" });
const { model, offsets, publish } = setup({ tab: "files" });
model.setQuery("new");
publish([], true);
model.beginManualScroll();
@@ -153,13 +192,29 @@ describe("Command Center interaction", () => {
expect(offsets).toEqual([]);
});
it("scope changes reset selection and request a reveal", () => {
it("tab changes select the first result and return the list to the top", () => {
const { model, offsets } = setup();
model.move("next");
model.move("next");
model.move("next");
model.scrolled(100);
model.update({ open: true, scope: "files", query: "", projection: results(), loading: false });
model.update({ open: true, tab: "files", query: "", projection: results(), loading: false });
expect(model.getSnapshot().activeId).toBe("a");
expect(offsets).toEqual([20, 20]);
expect(offsets.at(-1)).toBe(0);
});
it("tab changes keep the query", () => {
const { model, publish } = setup();
model.setQuery("b");
publish(["b"]);
model.update({
open: true,
tab: "workspaces",
query: "b",
projection: results(["b"]),
loading: false,
});
expect(model.getSnapshot()).toEqual({ query: "b", activeId: "b" });
});
it("close discards the old query, metrics, and deferred reveal", () => {
@@ -167,7 +222,7 @@ describe("Command Center interaction", () => {
model.setQuery("old");
publish();
model.move("previous");
model.update({ open: false, scope: null, query: "old", projection: results(), loading: false });
model.update({ open: false, tab: "all", query: "old", projection: results(), loading: false });
model.layout(80);
expect(model.getSnapshot()).toEqual({ query: "", activeId: null });
expect(offsets).toEqual([]);
+30 -11
View File
@@ -1,9 +1,9 @@
import type { CommandCenterScope } from "@/stores/keyboard-shortcuts-store";
import {
moveActiveResultId,
preserveActiveResultId,
type CommandCenterListProjection,
} from "./results";
import type { CommandCenterTab } from "./tabs";
interface Snapshot {
query: string;
@@ -12,21 +12,28 @@ interface Snapshot {
interface ResultsInput {
open: boolean;
scope: CommandCenterScope;
tab: CommandCenterTab;
query: string;
projection: CommandCenterListProjection;
/** A source of this query's and tab's results is still loading. */
loading: boolean;
}
/** Selection persists; permission to move the viewport lasts for one user command. */
/**
* While a query's or tab's results are still loading, the selection follows the first result,
* so Enter right after opening runs what ends up on top rather than whatever rendered first.
* Once they have all loaded, or the user moves the selection, it stays on its row by ID through
* any later reordering. Permission to move the viewport lasts for one user command.
*/
export function createCommandCenterInteraction(scrollToOffset: (offset: number) => void) {
let snapshot: Snapshot = { query: "", activeId: null };
const listeners = new Set<() => void>();
let open = false;
let scope: CommandCenterScope = null;
let tab: CommandCenterTab = "all";
let projection: CommandCenterListProjection | null = null;
let loading = false;
let revealPending = false;
let followingFirst = true;
let offset = 0;
let visibleLength = 0;
@@ -38,8 +45,8 @@ export function createCommandCenterInteraction(scrollToOffset: (offset: number)
function reveal() {
if (!open || !revealPending || !projection || visibleLength <= 0) return;
// Scoped file search keeps its previous entries while the next query is loading.
if (scope === "files" && loading) return;
// The Files tab keeps its previous entries while the next query is loading.
if (tab === "files" && loading) return;
if (!snapshot.activeId) {
if (!loading) revealPending = false;
return;
@@ -67,9 +74,10 @@ export function createCommandCenterInteraction(scrollToOffset: (offset: number)
};
},
update(input: ResultsInput) {
const resetSelection = input.open && (!open || scope !== input.scope);
const tabChanged = open && input.open && tab !== input.tab;
const resetSelection = input.open && (!open || tabChanged);
open = input.open;
scope = input.scope;
tab = input.tab;
if (!open) {
projection = null;
revealPending = false;
@@ -80,33 +88,44 @@ export function createCommandCenterInteraction(scrollToOffset: (offset: number)
}
if (resetSelection) {
revealPending = true;
followingFirst = true;
publish({ ...snapshot, activeId: null });
}
// A tab is a different list: it starts at its top, section label included.
if (tabChanged && offset !== 0) {
offset = 0;
scrollToOffset(0);
}
// A query command invalidates the old projection until React supplies its new results.
if (input.query !== snapshot.query) return;
projection = input.projection;
loading = input.loading;
if (scope !== "files" || !loading) {
if (tab !== "files" || !loading) {
publish({
...snapshot,
activeId: preserveActiveResultId(snapshot.activeId, projection.selectableResults),
activeId: followingFirst
? (projection.selectableResults[0]?.id ?? null)
: preserveActiveResultId(snapshot.activeId, projection.selectableResults),
});
if (!loading) followingFirst = false;
}
reveal();
},
setQuery(query: string) {
projection = null;
revealPending = true;
followingFirst = true;
publish({ query, activeId: null });
},
move(direction: "next" | "previous"): boolean {
if (
!open ||
!projection ||
(scope === "files" && loading) ||
(tab === "files" && loading) ||
projection.selectableResults.length === 0
)
return false;
followingFirst = false;
publish({
...snapshot,
activeId: moveActiveResultId(snapshot.activeId, projection.selectableResults, direction),
@@ -16,6 +16,8 @@ export const COMMAND_CENTER_LINE_HEIGHT = {
singleLine: 20,
title: 18,
subtitle: 16,
/** The matched-agent line under a workspace's subtitle. */
agent: 16,
sectionLabel: 16,
} as const;
@@ -26,6 +28,8 @@ const SECTION_DIVIDER_THICKNESS = 1;
export interface CommandCenterListMetrics {
singleLineRow: number;
twoLineRow: number;
/** A workspace row that also names the agent the query matched. */
threeLineRow: number;
/** A section label and the gaps around it. */
sectionTitle: number;
/** The rule between two sections and the gaps around it. */
@@ -37,6 +41,7 @@ function listMetrics(rowInset: number): CommandCenterListMetrics {
return {
singleLineRow: rowInset + line.singleLine + rowInset,
twoLineRow: rowInset + line.title + line.subtitle + rowInset,
threeLineRow: rowInset + line.title + line.subtitle + line.agent + rowInset,
sectionTitle: COMMAND_CENTER_SECTION_GAP + line.sectionLabel + COMMAND_CENTER_SECTION_GAP,
sectionDivider:
COMMAND_CENTER_SECTION_GAP + SECTION_DIVIDER_THICKNESS + COMMAND_CENTER_SECTION_GAP,
+47 -45
View File
@@ -3,7 +3,6 @@ import type { CommandCenterContribution } from "./contributions";
import { COMMAND_CENTER_LIST_METRICS } from "./list-metrics";
import {
buildContributionSections,
filterAndRankBuiltInResults,
joinSubtitleParts,
moveActiveResultId,
PINNED_SECTION_BAND,
@@ -11,6 +10,7 @@ import {
projectCommandCenterRows,
type CommandCenterWorkspaceResult,
} from "./results";
import { createWorkspaceRanker } from "./workspace-search";
function contribution(input: {
id: string;
@@ -118,7 +118,12 @@ function workspace(
project: { viewKey: "project", name: "project", iconDataUri: null },
changeRequestNumber: null,
activityAt,
agents: [],
matchedAgent: null,
archived: false,
background: false,
run: () => undefined,
openAgent: () => undefined,
};
}
@@ -184,6 +189,40 @@ describe("Command Center result projection", () => {
expect(sectionResultIds(buildContributionSections(contributions, "home"))).toEqual(["home"]);
});
it("lists query-only actions without a query when asked to browse them", () => {
const contributions = [
contribution({ id: "settings", group: "actions", groupRank: 0 }),
contribution({ id: "opus", group: "models", groupRank: 1, visibility: "query" }),
];
expect(
sectionResultIds(buildContributionSections(contributions, "", { includeQueryOnly: true })),
).toEqual(["settings", "opus"]);
});
it("gives a workspace row matched through an agent room for its agent line", () => {
const matched = {
...workspace("workspace:matched"),
matchedAgent: { id: "agent", title: "Add receipt emails", provider: "mock" },
};
const projection = projectCommandCenterRows(
[
{
id: "workspaces",
band: PINNED_SECTION_BAND,
rank: 2,
results: [workspace("workspace:plain"), matched],
},
],
metrics,
);
expect(projection.rows.map((row) => row.height)).toEqual([
metrics.twoLineRow,
metrics.threeLineRow,
]);
expect(metrics.threeLineRow).toBeGreaterThan(metrics.twoLineRow);
});
it("preserves active selection by id and falls back to the first result", () => {
const first = workspace("workspace:first");
const second = workspace("workspace:second");
@@ -486,25 +525,9 @@ describe("Command Center query tokenization", () => {
});
});
describe("filterAndRankBuiltInResults", () => {
const visibleOnly = (row: CommandCenterWorkspaceResult) => ({
visible: [row.title, row.subtitle],
hidden: [],
});
it("shows the five most recent rows for an empty query", () => {
const rows = Array.from({ length: 8 }, (_, index) =>
workspace(`w${index}`, `w${index}`, "host", minutesAgo(8 - index)),
);
expect(filterAndRankBuiltInResults(rows, "", visibleOnly).map((row) => row.id)).toEqual([
"w7",
"w6",
"w5",
"w4",
"w3",
]);
});
describe("workspace ranking", () => {
const rank = (rows: readonly CommandCenterWorkspaceResult[], query: string) =>
createWorkspaceRanker()(rows, query).map((row) => row.id);
it("puts the best match first even when it sorts last alphabetically", () => {
const rows = [
@@ -514,22 +537,7 @@ describe("filterAndRankBuiltInResults", () => {
];
// Alphabetically this is Alpha, Label, Zebra. "lab des" only matches Label as Design.
expect(filterAndRankBuiltInResults(rows, "lab des", visibleOnly).map((row) => row.id)).toEqual([
"c",
]);
});
it("ranks a title match above a match that only the hidden field reached", () => {
const rows = [workspace("hidden-hit", "Unrelated"), workspace("title-hit", "Deploy")];
const fields = (row: CommandCenterWorkspaceResult) =>
row.id === "hidden-hit"
? { visible: [row.title], hidden: ["deploy"] }
: { visible: [row.title], hidden: [] };
expect(filterAndRankBuiltInResults(rows, "deploy", fields).map((row) => row.id)).toEqual([
"title-hit",
"hidden-hit",
]);
expect(rank(rows, "lab des")).toEqual(["c"]);
});
it("orders rows in the same match tier by recency, not by where the word sits", () => {
@@ -539,10 +547,7 @@ describe("filterAndRankBuiltInResults", () => {
workspace("newer", "design the cli interface to expose usage data", "host", minutesAgo(5)),
];
expect(filterAndRankBuiltInResults(rows, "usage", visibleOnly).map((row) => row.id)).toEqual([
"newer",
"older",
]);
expect(rank(rows, "usage")).toEqual(["newer", "older"]);
});
it("keeps a better match tier above a more recent weaker match", () => {
@@ -551,16 +556,13 @@ describe("filterAndRankBuiltInResults", () => {
workspace("whole-word", "Usage hover card", "host", minutesAgo(600)),
];
expect(filterAndRankBuiltInResults(rows, "usage", visibleOnly).map((row) => row.id)).toEqual([
"whole-word",
"subsequence",
]);
expect(rank(rows, "usage")).toEqual(["whole-word", "subsequence"]);
});
it("drops a row when one token matches nothing", () => {
const rows = [workspace("a", "Label as Design")];
expect(filterAndRankBuiltInResults(rows, "lab zzz", visibleOnly)).toEqual([]);
expect(rank(rows, "lab zzz")).toEqual([]);
});
});
+48 -51
View File
@@ -1,12 +1,15 @@
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
import {
compareMatchScores,
type MatchClass,
type MatchScore,
scoreTextFields,
textFieldsMatchClass,
} from "@getpaseo/protocol/search/text-match";
import type { CommandCenterContribution } from "./contributions";
import type { CommandCenterListMetrics } from "./list-metrics";
/** Sections pinned above every contribution section. */
export const LEADING_SECTION_BAND = -1;
/** Sections that reorder by how well they match the query. */
export const CONTRIBUTION_SECTION_BAND = 0;
/** Sections pinned below every contribution section regardless of match quality. */
@@ -50,6 +53,13 @@ export interface CommandCenterWorkspaceProject {
iconDataUri: string | null;
}
/** An agent a workspace can be found by. Agents are never results of their own. */
export interface CommandCenterWorkspaceAgent {
id: string;
title: string;
provider: string;
}
export interface CommandCenterWorkspaceResult {
kind: "workspace";
id: string;
@@ -60,18 +70,19 @@ export interface CommandCenterWorkspaceResult {
project: CommandCenterWorkspaceProject;
/** PR/MR number backing this workspace, so a number query can match it exactly. */
changeRequestNumber: number | null;
/** Last activity, or when it was archived for an archived workspace. */
activityAt: Date | null;
/** The workspace's agents, most recent first. Part of what the workspace matches on. */
agents: readonly CommandCenterWorkspaceAgent[];
/** The agent the query matched better than the workspace's own text. Drawn as a third line. */
matchedAgent: CommandCenterWorkspaceAgent | null;
archived: boolean;
/** Started by automation rather than the user. Found by a query, never listed as recent. */
background: boolean;
/** Opens the workspace and lets its default tab selection apply. */
run(): void;
}
export interface CommandCenterAgentResult {
kind: "agent";
id: string;
agent: AggregatedAgent;
title: string;
subtitle: string;
activityAt: Date | null;
run(): void;
/** Opens the workspace on this agent's tab. */
openAgent(agentId: string): void;
}
export interface CommandCenterFileResult {
@@ -94,13 +105,12 @@ export interface CommandCenterContributionResult {
export type CommandCenterResult =
| CommandCenterWorkspaceResult
| CommandCenterAgentResult
| CommandCenterFileResult
| CommandCenterContributionResult;
export interface CommandCenterResultSection {
id: string;
/** CONTRIBUTION_SECTION_BAND or PINNED_SECTION_BAND. Compared before anything else. */
/** LEADING, CONTRIBUTION or PINNED_SECTION_BAND. Compared before anything else. */
band: number;
rank: number;
title?: string;
@@ -162,6 +172,14 @@ export function scoreSearchFields(
return match ? { fieldRank: KEYWORD_FIELD_RANK, match } : null;
}
/** How closely the query matched the fields; see `MatchClass`. Null when it does not match. */
export function searchFieldsMatchClass(
query: string,
fields: CommandCenterSearchFields,
): MatchClass | null {
return textFieldsMatchClass(query, [...fields.visible, ...fields.hidden]);
}
/** Join non-empty subtitle parts with " · ", dropping null/undefined/empty. */
export function joinSubtitleParts(parts: readonly (string | null | undefined)[]): string {
return parts.filter((part): part is string => Boolean(part)).join(" · ");
@@ -181,12 +199,18 @@ function contributionSearchFields(
return { visible, hidden: contribution.keywords };
}
/** Whether the row draws a second, subtitle line under its title. */
function hasSubtitleLine(result: CommandCenterResult): boolean {
if (result.kind === "workspace" || result.kind === "agent") return true;
if (result.kind === "file") return false;
/** How many lines of text the row draws, which decides its height. */
function resultLineCount(result: CommandCenterResult): 1 | 2 | 3 {
if (result.kind === "workspace") return result.matchedAgent ? 3 : 2;
if (result.kind === "file") return 1;
const presentation = result.contribution.presentation;
return presentation.kind === "action" && Boolean(presentation.subtitle);
return presentation.kind === "action" && presentation.subtitle ? 2 : 1;
}
function resultHeight(result: CommandCenterResult, metrics: CommandCenterListMetrics): number {
const lines = resultLineCount(result);
if (lines === 3) return metrics.threeLineRow;
return lines === 2 ? metrics.twoLineRow : metrics.singleLineRow;
}
/**
@@ -211,10 +235,7 @@ function resultScore(result: CommandCenterResult): CommandCenterScore | undefine
return result.kind === "contribution" ? result.score : undefined;
}
/** Rows shown when nothing has been typed, per pinned category. */
const DEFAULT_CATEGORY_RESULT_LIMIT = 5;
/** A built-in row (workspace or agent) that orders by how recently it saw activity. */
/** A built-in row that orders by how recently it saw activity. */
export interface CommandCenterRecentRow {
activityAt: Date | null;
}
@@ -235,44 +256,20 @@ export function compareBuiltInScores(left: CommandCenterScore, right: CommandCen
}
/**
* Filter and rank already-built rows. Rows in, rows out — building them needs translations,
* navigation callbacks and the project tree, none of which matching cares about, so that stays
* with the caller and this stays testable without mounting React.
*
* Rows rank by `compareBuiltInScores`; rows that rank equally, including every row of an empty
* query, go most recent first.
* Group contributions into their sections, ranked by the query. `includeQueryOnly` lists the
* contributions that otherwise wait for a query, for a list whose whole job is browsing them.
*/
export function filterAndRankBuiltInResults<Row extends CommandCenterRecentRow>(
rows: readonly Row[],
query: string,
fieldsOf: (row: Row) => CommandCenterSearchFields,
): Row[] {
if (!query.trim()) {
return [...rows].sort(compareByRecency).slice(0, DEFAULT_CATEGORY_RESULT_LIMIT);
}
const scored: { row: Row; score: CommandCenterScore }[] = [];
for (const row of rows) {
const score = scoreSearchFields(query, fieldsOf(row));
if (score) scored.push({ row, score });
}
scored.sort(
(left, right) =>
compareBuiltInScores(left.score, right.score) || compareByRecency(left.row, right.row),
);
return scored.map((entry) => entry.row);
}
export function buildContributionSections(
contributions: readonly CommandCenterContribution[],
query: string,
options: { includeQueryOnly?: boolean } = {},
): CommandCenterResultSection[] {
const groups = new Map<string, MutableCommandCenterResultSection>();
const registryIndexById = new Map<string, number>();
const hasQuery = Boolean(query.trim());
for (const [index, contribution] of contributions.entries()) {
if (contribution.visibility === "query" && !hasQuery) continue;
if (contribution.visibility === "query" && !hasQuery && !options.includeQueryOnly) continue;
let score: CommandCenterScore | undefined;
if (hasQuery) {
// The score is the filter: null means no token found a home, so the row is out.
@@ -351,7 +348,7 @@ export function projectCommandCenterRows(
offset += sectionHeight;
}
for (const result of section.results) {
const height = hasSubtitleLine(result) ? metrics.twoLineRow : metrics.singleLineRow;
const height = resultHeight(result, metrics);
offsets.push(offset);
rowIndexByResultId.set(result.id, rows.length);
rows.push({ kind: "result", key: result.id, result, height });
@@ -0,0 +1,181 @@
import { describe, expect, it } from "vitest";
import type { CommandCenterContribution } from "./contributions";
import type { CommandCenterFileResult, CommandCenterWorkspaceResult } from "./results";
import { COMMAND_CENTER_LIST_METRICS } from "./list-metrics";
import { projectCommandCenterRows } from "./results";
import { buildCommandCenterSections } from "./sections";
import type { CommandCenterTab } from "./tabs";
import { createWorkspaceRanker } from "./workspace-search";
function workspace(
id: string,
options: {
daysAgo?: number;
title?: string;
archived?: boolean;
background?: boolean;
agentTitle?: string;
} = {},
): CommandCenterWorkspaceResult {
return {
kind: "workspace",
id: `workspace:server:${id}`,
serverId: "server",
workspaceId: id,
title: options.title ?? id,
subtitle: "project · main",
project: { viewKey: "project", name: "project", iconDataUri: null },
changeRequestNumber: null,
activityAt: new Date(Date.UTC(2026, 9, 30) - (options.daysAgo ?? 0) * 86_400_000),
agents: options.agentTitle
? [{ id: `${id}-agent`, title: options.agentTitle, provider: "mock" }]
: [],
matchedAgent: null,
archived: options.archived ?? false,
background: options.background ?? false,
run: () => undefined,
openAgent: () => undefined,
};
}
function action(id: string, group: string, visibility: "always" | "query" = "always") {
return {
id,
group,
groupRank: group === "workspace" ? 0 : 1,
rank: 0,
keywords: [],
visibility,
run: () => undefined,
presentation: { kind: "action", title: id, sectionTitle: group },
} satisfies CommandCenterContribution;
}
const file: CommandCenterFileResult = {
kind: "file",
id: "file:src/index.ts",
filePath: "src/index.ts",
title: "index.ts",
subtitle: "src",
run: () => undefined,
};
const workspaces = [
workspace("current", { daysAgo: 0 }),
workspace("payments", { daysAgo: 1, title: "Payments refactor" }),
// Started by automation; more recent than the workspaces below it.
workspace("nightly", { daysAgo: 1.5, title: "Nightly triage", background: true }),
workspace("onboarding", { daysAgo: 2, title: "Onboarding flow", agentTitle: "Add receipts" }),
workspace("docs", { daysAgo: 3, title: "Docs site" }),
workspace("search", { daysAgo: 4, title: "Search ranking" }),
];
const archived = [workspace("billing", { title: "Billing cleanup", archived: true })];
const contributions = [
action("New agent", "workspace"),
action("Settings", "actions"),
action("Group by status", "actions", "query"),
];
function listed(
tab: CommandCenterTab,
query: string,
options: { archivedMatchedByHost?: boolean; showBackground?: boolean } = {},
) {
const sections = buildCommandCenterSections({
tab,
query,
rankWorkspaces: createWorkspaceRanker(),
workspaces,
archivedWorkspaces: query ? archived : [],
archivedMatchedByHost: options.archivedMatchedByHost ?? true,
showBackground: options.showBackground ?? false,
currentWorkspaceId: "workspace:server:current",
contributions,
files: [file],
titles: { recentWorkspaces: "Recent workspaces", workspaces: "Workspaces", files: "Files" },
});
return projectCommandCenterRows(sections, COMMAND_CENTER_LIST_METRICS.desktop).rows.map((row) =>
row.kind === "section" ? `# ${row.title ?? ""}` : row.key,
);
}
describe("command center tabs", () => {
it("All leads with the three most recent workspaces, then actions, before a query", () => {
expect(listed("all", "")).toEqual([
"# Recent workspaces",
"workspace:server:payments",
"workspace:server:onboarding",
"workspace:server:docs",
"# workspace",
"New agent",
"# actions",
"Settings",
]);
});
it("lists background workspaces unqueried only as the sidebar does; a query finds them", () => {
expect(listed("all", "")).not.toContain("workspace:server:nightly");
expect(listed("all", "", { showBackground: true })).not.toContain("workspace:server:nightly");
expect(listed("workspaces", "")).not.toContain("workspace:server:nightly");
expect(listed("workspaces", "", { showBackground: true })).toContain(
"workspace:server:nightly",
);
expect(listed("all", "nightly")).toContain("workspace:server:nightly");
expect(listed("workspaces", "nightly")).toContain("workspace:server:nightly");
});
it("All ranks every kind of result for a query, archived workspaces after active ones", () => {
expect(listed("all", "i")).toEqual(
expect.arrayContaining(["Settings", "file:src/index.ts", "workspace:server:billing"]),
);
const rows = listed("all", "ing");
const workspaceRows = rows.filter((row) => row.startsWith("workspace:"));
expect(workspaceRows.at(-1)).toBe("workspace:server:billing");
expect(rows).not.toContain("# Recent workspaces");
});
it("Workspaces lists every workspace, current included, most recent first", () => {
expect(listed("workspaces", "")).toEqual([
"# Workspaces",
"workspace:server:current",
"workspace:server:payments",
"workspace:server:onboarding",
"workspace:server:docs",
"workspace:server:search",
]);
});
it("Workspaces finds a workspace through its agent and appends archived matches", () => {
expect(listed("workspaces", "receipts")).toEqual([
"# Workspaces",
"workspace:server:onboarding",
"workspace:server:billing",
]);
});
it("keeps archived rows the host did not match for this query only if they match it", () => {
// The previous query's archived results stand in while the next search loads.
expect(listed("workspaces", "receipts", { archivedMatchedByHost: false })).toEqual([
"# Workspaces",
"workspace:server:onboarding",
]);
expect(listed("workspaces", "billing", { archivedMatchedByHost: false })).toEqual([
"# Workspaces",
"workspace:server:billing",
]);
});
it("Files lists only files", () => {
expect(listed("files", "")).toEqual(["# Files", "file:src/index.ts"]);
});
it("Actions lists every action, including those that otherwise wait for a query", () => {
expect(listed("actions", "")).toEqual([
"# workspace",
"New agent",
"# actions",
"Settings",
"Group by status",
]);
});
});
+120
View File
@@ -0,0 +1,120 @@
import type { CommandCenterContribution } from "./contributions";
import {
buildContributionSections,
LEADING_SECTION_BAND,
PINNED_SECTION_BAND,
type CommandCenterFileResult,
type CommandCenterResultSection,
type CommandCenterWorkspaceResult,
} from "./results";
import type { CommandCenterTab } from "./tabs";
import type { WorkspaceRanker } from "./workspace-search";
/**
* How many workspaces All leads with before anything is typed. Background workspaces are left
* out, as the sidebar leaves them out by default.
*/
const RECENT_WORKSPACE_LIMIT = 3;
export interface CommandCenterSectionTitles {
recentWorkspaces: string;
workspaces: string;
files: string;
}
export interface CommandCenterSectionsInput {
tab: CommandCenterTab;
query: string;
rankWorkspaces: WorkspaceRanker;
workspaces: readonly CommandCenterWorkspaceResult[];
/** Archived workspaces the host found for the query. Empty without a query. */
archivedWorkspaces: readonly CommandCenterWorkspaceResult[];
/**
* The host matched the archived workspaces for this exact query. Its search forgives typos,
* so its matches stay even when the client cannot score them. Results from the previous
* query must match on their own.
*/
archivedMatchedByHost: boolean;
/**
* The sidebar's Show background preference. Without a query, Workspaces lists background
* workspaces only when the sidebar does; a query finds them either way.
*/
showBackground: boolean;
/** The open workspace's result ID. Recent workspaces leaves it out: the user is already there. */
currentWorkspaceId: string | null;
contributions: readonly CommandCenterContribution[];
files: readonly CommandCenterFileResult[];
titles: CommandCenterSectionTitles;
}
function recentWorkspacesSection(input: CommandCenterSectionsInput): CommandCenterResultSection {
const recent = input
.rankWorkspaces(input.workspaces, "")
.filter((workspace) => workspace.id !== input.currentWorkspaceId && !workspace.background)
.slice(0, RECENT_WORKSPACE_LIMIT);
return {
id: "recent-workspaces",
band: LEADING_SECTION_BAND,
rank: 0,
title: input.titles.recentWorkspaces,
results: recent,
};
}
/** Active matches first; archived ones are appended, so active rows never move when they land. */
function workspacesSection(input: CommandCenterSectionsInput): CommandCenterResultSection {
const listed =
input.query.trim() || input.showBackground
? input.workspaces
: input.workspaces.filter((workspace) => !workspace.background);
const active = input.rankWorkspaces(listed, input.query);
const archived = input.query.trim()
? input.rankWorkspaces(input.archivedWorkspaces, input.query, {
keepUnmatched: input.archivedMatchedByHost,
})
: [];
return {
id: "workspaces",
band: PINNED_SECTION_BAND,
rank: 2,
title: input.titles.workspaces,
results: [...active, ...archived],
};
}
function filesSection(input: CommandCenterSectionsInput): CommandCenterResultSection {
// File rows are matched by the daemon, so they carry no client-side score and cannot be
// relevance-compared against the rows that do. Pinning them keeps the section comparator
// total: band is decided before any score is read.
return {
id: "files",
band: PINNED_SECTION_BAND,
rank: 4,
title: input.titles.files,
results: input.files,
};
}
/**
* The sections a tab shows. All mixes every kind and leads with recent workspaces until
* something is typed; every other tab lists everything of its one kind.
*/
export function buildCommandCenterSections(
input: CommandCenterSectionsInput,
): CommandCenterResultSection[] {
switch (input.tab) {
case "all": {
const actions = buildContributionSections(input.contributions, input.query);
if (!input.query.trim()) return [recentWorkspacesSection(input), ...actions];
return [...actions, workspacesSection(input), filesSection(input)];
}
case "workspaces":
return [workspacesSection(input)];
case "files":
return [filesSection(input)];
case "actions":
return buildContributionSections(input.contributions, input.query, {
includeQueryOnly: true,
});
}
}
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import {
availableCommandCenterTabs,
resolveCommandCenterTab,
rotateCommandCenterTab,
type CommandCenterTab,
} from "./tabs";
const IN_WORKSPACE = availableCommandCenterTabs({ hasWorkspace: true });
const NO_WORKSPACE = availableCommandCenterTabs({ hasWorkspace: false });
function walk(available: readonly CommandCenterTab[], direction: "next" | "previous") {
const walked: CommandCenterTab[] = ["all"];
for (let step = 0; step < available.length; step += 1) {
walked.push(rotateCommandCenterTab(walked[walked.length - 1], direction, available));
}
return walked;
}
describe("command center tabs", () => {
it("walks All, Workspaces, Files, Actions in a workspace and wraps", () => {
expect(walk(IN_WORKSPACE, "next")).toEqual(["all", "workspaces", "files", "actions", "all"]);
expect(walk(IN_WORKSPACE, "previous")).toEqual([
"all",
"actions",
"files",
"workspaces",
"all",
]);
});
it("offers no Files tab outside a workspace, and Tab skips it", () => {
expect(NO_WORKSPACE).toEqual(["all", "workspaces", "actions"]);
expect(walk(NO_WORKSPACE, "next")).toEqual(["all", "workspaces", "actions", "all"]);
expect(walk(NO_WORKSPACE, "previous")).toEqual(["all", "actions", "workspaces", "all"]);
});
it("opens a requested tab only when it is on offer", () => {
expect(resolveCommandCenterTab("files", IN_WORKSPACE)).toBe("files");
expect(resolveCommandCenterTab("files", NO_WORKSPACE)).toBe("all");
expect(resolveCommandCenterTab("actions", NO_WORKSPACE)).toBe("actions");
});
});
+39
View File
@@ -0,0 +1,39 @@
/**
* The command center's tabs, in the order Tab walks them. All shows every kind of result; each
* other tab shows one kind.
*/
const COMMAND_CENTER_TABS = ["all", "workspaces", "files", "actions"] as const;
export type CommandCenterTab = (typeof COMMAND_CENTER_TABS)[number];
/**
* The tabs on offer, in the order Tab walks them. Files searches the open workspace, so it is
* offered only when a workspace is open; without one, All lists no files either.
*/
export function availableCommandCenterTabs(context: {
hasWorkspace: boolean;
}): readonly CommandCenterTab[] {
return context.hasWorkspace
? COMMAND_CENTER_TABS
: COMMAND_CENTER_TABS.filter((tab) => tab !== "files");
}
/** The tab to show for a requested one: itself when on offer, otherwise All. */
export function resolveCommandCenterTab(
requested: CommandCenterTab,
available: readonly CommandCenterTab[],
): CommandCenterTab {
return available.includes(requested) ? requested : "all";
}
/** The available tab after (or before) `tab`, wrapping at either end. */
export function rotateCommandCenterTab(
tab: CommandCenterTab,
direction: "next" | "previous",
available: readonly CommandCenterTab[],
): CommandCenterTab {
const index = available.indexOf(tab);
const delta = direction === "next" ? 1 : -1;
const count = available.length;
return available[(index + delta + count) % count];
}
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { CommandCenterWorkspaceResult } from "./results";
import { filterAndRankWorkspaces } from "./workspace-search";
import type { CommandCenterWorkspaceAgent, CommandCenterWorkspaceResult } from "./results";
import { createWorkspaceRanker } from "./workspace-search";
function workspace(input: {
id: string;
@@ -8,6 +8,8 @@ function workspace(input: {
subtitle?: string;
changeRequestNumber?: number | null;
activityAt?: Date | null;
agents?: readonly CommandCenterWorkspaceAgent[];
opened?: string[];
}): CommandCenterWorkspaceResult {
return {
kind: "workspace",
@@ -19,15 +21,24 @@ function workspace(input: {
project: { viewKey: "project", name: "project", iconDataUri: null },
changeRequestNumber: input.changeRequestNumber ?? null,
activityAt: input.activityAt ?? null,
run: () => undefined,
agents: input.agents ?? [],
matchedAgent: null,
archived: false,
background: false,
run: () => input.opened?.push("workspace"),
openAgent: (agentId) => input.opened?.push(agentId),
};
}
function ids(rows: readonly CommandCenterWorkspaceResult[], query: string): string[] {
return filterAndRankWorkspaces(rows, query).map((row) => row.id);
function agent(id: string, title: string): CommandCenterWorkspaceAgent {
return { id, title, provider: "mock" };
}
describe("filterAndRankWorkspaces", () => {
function ids(rows: readonly CommandCenterWorkspaceResult[], query: string): string[] {
return createWorkspaceRanker()(rows, query).map((row) => row.id);
}
describe("workspace ranking", () => {
const subject = workspace({
id: "payments",
title: "Payments refactor",
@@ -96,4 +107,214 @@ describe("filterAndRankWorkspaces", () => {
});
expect(ids([older, newer], "usage")).toEqual(["newer", "older"]);
});
it("lists every workspace most recent first when the query is empty", () => {
const rows = Array.from({ length: 8 }, (_, index) =>
workspace({ id: `w${index}`, activityAt: new Date(2026, 0, index + 1) }),
);
expect(ids(rows, "")).toEqual(["w7", "w6", "w5", "w4", "w3", "w2", "w1", "w0"]);
});
it("matches a workspace through one of its agents and names that agent", () => {
const receipts = agent("agent-receipts", "Add receipt emails");
const row = workspace({
id: "onboarding",
title: "Onboarding flow",
agents: [agent("agent-welcome", "Polish welcome screen"), receipts],
});
const [ranked] = createWorkspaceRanker()([row], "receipt");
expect(ranked.id).toBe("onboarding");
expect(ranked.matchedAgent).toEqual(receipts);
});
it("names no agent when the workspace's own text matched", () => {
const row = workspace({
id: "payments",
title: "Payments refactor",
agents: [agent("agent-payments", "Payments retries")],
});
const [ranked] = createWorkspaceRanker()([row], "payments");
expect(ranked.matchedAgent).toBeNull();
});
it("ranks a workspace's own match above an equally good match through an agent", () => {
const viaAgent = workspace({
id: "via-agent",
title: "Onboarding flow",
activityAt: new Date("2026-10-17T00:00:00Z"),
agents: [agent("a", "Receipt emails")],
});
const own = workspace({
id: "own",
title: "Receipt redesign",
activityAt: new Date("2026-10-01T00:00:00Z"),
});
expect(ids([viaAgent, own], "receipt")).toEqual(["own", "via-agent"]);
});
it("picks the best-matching agent for the line", () => {
const row = workspace({
id: "w",
title: "Onboarding",
agents: [agent("loose", "Reconcile cash receipts later"), agent("exact", "receipts")],
});
const [ranked] = createWorkspaceRanker()([row], "receipts");
expect(ranked.matchedAgent?.id).toBe("exact");
});
it("matches a query split between the workspace's text and an agent's title", () => {
const login = agent("agent-login", "Fix login retries");
const row = workspace({ id: "payments", title: "payments", agents: [login] });
const [ranked] = createWorkspaceRanker()([row], "payments login");
expect(ranked?.id).toBe("payments");
expect(ranked?.matchedAgent).toEqual(login);
});
it("names the agent when it matches better than a loose match on the workspace's text", () => {
const opened: string[] = [];
const login = agent("agent-login", "Fix login retries");
const row = workspace({
id: "long-input",
title: "Long input",
// "login" is a subsequence of "long-input", the weakest kind of match.
subtitle: "Local · paseo · feature/long-input",
agents: [login],
opened,
});
const [ranked] = createWorkspaceRanker()([row], "login");
expect(ranked?.matchedAgent).toEqual(login);
ranked?.run();
expect(opened).toEqual(["agent-login"]);
});
it("keeps a change-request hit on the workspace even when an agent's title names the number", () => {
const opened: string[] = [];
const row = workspace({
id: "payments",
title: "Payments refactor",
changeRequestNumber: 42,
agents: [agent("agent-review", "Address PR 42 review comments")],
opened,
});
const rank = createWorkspaceRanker();
for (const query of ["42", "pr 42", "#42"]) {
const [ranked] = rank([row], query);
expect(ranked?.matchedAgent).toBeNull();
ranked?.run();
}
expect(opened).toEqual(["workspace", "workspace", "workspace"]);
});
it("names no agent while the workspace's own text matches every keystroke contiguously", () => {
const row = workspace({
id: "fix-login-bug",
title: "fix-login-bug",
subtitle: "paseo · fix/login-bug",
agents: [agent("agent-login", "Login page fix")],
});
const rank = createWorkspaceRanker();
for (const query of ["l", "lo", "log", "logi", "login"]) {
expect(rank([row], query)[0]?.matchedAgent).toBeNull();
}
});
it("names no agent whose one-word title matches better than the workspace's whole word", () => {
const row = workspace({
id: "review-queue",
title: "Review queue",
agents: [agent("agent-review", "Review")],
});
expect(createWorkspaceRanker()([row], "review")[0]?.matchedAgent).toBeNull();
});
it("ranks a better match through an agent above a looser match on a workspace's own text", () => {
const loose = workspace({
id: "loose-own",
title: "Long input",
subtitle: "Local · paseo · feature/long-input",
activityAt: new Date("2026-10-17T00:00:00Z"),
});
const viaAgent = workspace({
id: "via-agent",
title: "Auth",
activityAt: new Date("2026-10-01T00:00:00Z"),
agents: [agent("a", "login page")],
});
expect(ids([loose, viaAgent], "login")).toEqual(["via-agent", "loose-own"]);
});
it("keeps a workspace that also matches on its own name first while typing an agent's title", () => {
const ownAndAgent = workspace({
id: "oauth-refactor",
activityAt: new Date("2026-10-17T00:00:00Z"),
agents: [agent("agent-a", "Auth flow fix")],
});
const agentOnly = workspace({
id: "cleanup",
activityAt: new Date("2026-10-01T00:00:00Z"),
agents: [agent("agent-b", "Auth flow fix")],
});
for (const query of ["a", "au", "auth", "auth f", "auth fl", "auth flow"]) {
expect(ids([ownAndAgent, agentOnly], query)).toEqual(["oauth-refactor", "cleanup"]);
}
});
it("ranks a word match on a workspace's name above a newer one that matches only inside a word", () => {
const rank = createWorkspaceRanker();
const authService = workspace({
id: "auth-service",
activityAt: new Date("2026-10-01T00:00:00Z"),
});
const oauthRefactor = workspace({
id: "oauth-refactor",
activityAt: new Date("2026-10-17T00:00:00Z"),
agents: [agent("agent-auth", "Auth flow fix")],
});
const authRows = rank([oauthRefactor, authService], "auth");
expect(authRows.map((row) => row.id)).toEqual(["auth-service", "oauth-refactor"]);
// The row that matched through its agent says so.
expect(authRows[1]?.matchedAgent?.title).toBe("Auth flow fix");
const fixLogin = workspace({ id: "fix-login", activityAt: new Date("2026-10-17T00:00:00Z") });
const prefixedThing = workspace({
id: "prefixed-thing",
activityAt: new Date("2026-10-01T00:00:00Z"),
agents: [agent("agent-fix", "Fix")],
});
const fixRows = rank([prefixedThing, fixLogin], "fix");
expect(fixRows.map((row) => row.id)).toEqual(["fix-login", "prefixed-thing"]);
expect(fixRows[1]?.matchedAgent?.title).toBe("Fix");
});
it("opens the matched agent's tab, and the workspace otherwise", () => {
const opened: string[] = [];
const row = workspace({
id: "w",
title: "Onboarding flow",
agents: [agent("agent-receipts", "Add receipt emails")],
opened,
});
const rank = createWorkspaceRanker();
rank([row], "receipt")[0].run();
rank([row], "onboarding")[0].run();
expect(opened).toEqual(["agent-receipts", "workspace"]);
});
it("returns the same row object across keystrokes while the matched agent holds", () => {
const row = workspace({
id: "w",
title: "Onboarding flow",
agents: [agent("agent-receipts", "Add receipt emails")],
});
const rank = createWorkspaceRanker();
expect(rank([row], "rece")[0]).toBe(rank([row], "receipt")[0]);
expect(rank([row], "onboard")[0]).toBe(row);
});
it("keeps rows the host already matched when asked to, without an agent line", () => {
const row = workspace({ id: "archived", title: "Billing cleanup" });
const rank = createWorkspaceRanker();
expect(rank([row], "billign")).toEqual([]);
expect(rank([row], "billign", { keepUnmatched: true })).toEqual([row]);
});
});
@@ -1,9 +1,12 @@
import { compareMatchClasses, type MatchClass } from "@getpaseo/protocol/search/text-match";
import {
compareBuiltInScores,
compareByRecency,
filterAndRankBuiltInResults,
scoreSearchFields,
searchFieldsMatchClass,
type CommandCenterScore,
type CommandCenterSearchFields,
type CommandCenterWorkspaceAgent,
type CommandCenterWorkspaceResult,
} from "./results";
@@ -24,41 +27,176 @@ function parseChangeRequestQuery(query: string): number | null {
return Number.isSafeInteger(number) ? number : null;
}
/** Within a match class, a match on the workspace's own text ranks ahead of one through an agent. */
const OWN_MATCH = 0;
const AGENT_MATCH = 1;
/** The host matched it (typo-tolerant, server-side) but nothing here does. Archived rows only. */
const HOST_MATCH = 2;
/** How a row matched: its class decides first, its score within the class. */
interface Candidate {
matchClass: MatchClass;
score: CommandCenterScore;
}
interface ScoredWorkspace {
workspace: CommandCenterWorkspaceResult;
score: CommandCenterScore | null;
via: typeof OWN_MATCH | typeof AGENT_MATCH | typeof HOST_MATCH;
/** The match the row shows, which is also the one it ranks by. */
candidate: Candidate | null;
agent: CommandCenterWorkspaceAgent | null;
changeRequestHit: boolean;
}
function searchFields(workspace: CommandCenterWorkspaceResult) {
function ownSearchFields(workspace: CommandCenterWorkspaceResult) {
return { visible: [workspace.title, workspace.subtitle], hidden: [] };
}
/** An agent is found through its own title and its workspace's text together. */
function agentSearchFields(
workspace: CommandCenterWorkspaceResult,
agent: CommandCenterWorkspaceAgent,
) {
return { visible: [workspace.title, workspace.subtitle, agent.title], hidden: [] };
}
function scoreCandidate(query: string, fields: CommandCenterSearchFields): Candidate | null {
const score = scoreSearchFields(query, fields);
const matchClass = searchFieldsMatchClass(query, fields);
return score && matchClass ? { matchClass, score } : null;
}
function compareCandidates(left: Candidate, right: Candidate): number {
return (
compareMatchClasses(left.matchClass, right.matchClass) ||
compareBuiltInScores(left.score, right.score)
);
}
/**
* Change-request hits first, host-only matches last; then match class, the workspace's own text
* ahead of an agent, match quality, and recency.
*/
function compareScoredWorkspaces(left: ScoredWorkspace, right: ScoredWorkspace): number {
if (left.changeRequestHit !== right.changeRequestHit) {
return left.changeRequestHit ? -1 : 1;
}
if (left.score && right.score) {
const scoreDelta = compareBuiltInScores(left.score, right.score);
if ((left.via === HOST_MATCH) !== (right.via === HOST_MATCH)) {
return left.via === HOST_MATCH ? 1 : -1;
}
if (left.candidate && right.candidate) {
const classDelta = compareMatchClasses(left.candidate.matchClass, right.candidate.matchClass);
if (classDelta !== 0) return classDelta;
}
if (left.via !== right.via) return left.via - right.via;
if (left.candidate && right.candidate) {
const scoreDelta = compareBuiltInScores(left.candidate.score, right.candidate.score);
if (scoreDelta !== 0) return scoreDelta;
}
return compareByRecency(left.workspace, right.workspace);
}
export function filterAndRankWorkspaces(
interface AgentMatch extends Candidate {
agent: CommandCenterWorkspaceAgent;
}
/**
* The workspace's best-matching agent. Each agent is scored together with the workspace's
* text, so a query split across the two finds it. Agents arrive most recent first, so a tie
* keeps the newer one.
*/
function agentMatch(workspace: CommandCenterWorkspaceResult, query: string): AgentMatch | null {
let best: AgentMatch | null = null;
for (const agent of workspace.agents) {
const candidate = scoreCandidate(query, agentSearchFields(workspace, agent));
if (candidate && (!best || compareCandidates(candidate, best) < 0)) {
best = { agent, ...candidate };
}
}
return best;
}
export type WorkspaceRanker = (
workspaces: readonly CommandCenterWorkspaceResult[],
query: string,
): CommandCenterWorkspaceResult[] {
if (!query.trim()) return filterAndRankBuiltInResults(workspaces, query, searchFields);
options?: { keepUnmatched?: boolean },
) => CommandCenterWorkspaceResult[];
const changeRequestNumber = parseChangeRequestQuery(query);
const matches: ScoredWorkspace[] = [];
for (const workspace of workspaces) {
const score = scoreSearchFields(query, searchFields(workspace));
const changeRequestHit =
changeRequestNumber !== null && workspace.changeRequestNumber === changeRequestNumber;
if (score || changeRequestHit) matches.push({ workspace, score, changeRequestHit });
/**
* Rank workspaces for a query, best match first. A workspace matches on its own title, project,
* branch and change request, or on those together with the title of one of its agents. It is
* matched through an agent only when the agent matches in a closer class than its own text
* (see `MatchClass`), never on a change-request hit; such a match comes back naming that agent,
* and opens it. Each row ranks by the match it shows.
*
* An empty query lists every workspace, most recent first. `keepUnmatched` keeps rows nothing
* here matches, ranked last: archived rows come from the host's own search, which is
* typo-tolerant, so the host is the authority on whether they match.
*
* The ranker remembers the row it made for each workspace and agent, so a row matched through
* the same agent is the same object on every keystroke and its list row does not re-render.
*/
export function createWorkspaceRanker(): WorkspaceRanker {
const viaAgent = new WeakMap<
CommandCenterWorkspaceResult,
Map<string, CommandCenterWorkspaceResult>
>();
function matchedThrough(
workspace: CommandCenterWorkspaceResult,
agent: CommandCenterWorkspaceAgent,
): CommandCenterWorkspaceResult {
let rows = viaAgent.get(workspace);
if (!rows) {
rows = new Map();
viaAgent.set(workspace, rows);
}
let row = rows.get(agent.id);
if (!row) {
row = { ...workspace, matchedAgent: agent, run: () => workspace.openAgent(agent.id) };
rows.set(agent.id, row);
}
return row;
}
matches.sort(compareScoredWorkspaces);
return matches.map((match) => match.workspace);
return (workspaces, query, options = {}) => {
if (!query.trim()) return [...workspaces].sort(compareByRecency);
const changeRequestNumber = parseChangeRequestQuery(query);
const matches: ScoredWorkspace[] = [];
for (const workspace of workspaces) {
const changeRequestHit =
changeRequestNumber !== null && workspace.changeRequestNumber === changeRequestNumber;
const own = scoreCandidate(query, ownSearchFields(workspace));
// A change-request hit is the workspace's own answer to the query.
const throughAgent = changeRequestHit ? null : agentMatch(workspace, query);
// An agent is named only when it matches in a closer class than the workspace's own text.
if (
throughAgent &&
(!own || compareMatchClasses(throughAgent.matchClass, own.matchClass) < 0)
) {
matches.push({
workspace,
via: AGENT_MATCH,
candidate: throughAgent,
agent: throughAgent.agent,
changeRequestHit,
});
} else if (own || changeRequestHit) {
matches.push({ workspace, via: OWN_MATCH, candidate: own, agent: null, changeRequestHit });
} else if (options.keepUnmatched) {
matches.push({
workspace,
via: HOST_MATCH,
candidate: null,
agent: null,
changeRequestHit,
});
}
}
matches.sort(compareScoredWorkspaces);
return matches.map((match) =>
match.agent ? matchedThrough(match.workspace, match.agent) : match.workspace,
);
};
}
@@ -0,0 +1,76 @@
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { RotateCw, Settings } from "lucide-react-native";
import { afterEach, describe, expect, it } from "vitest";
import {
InlineHeaderView,
SheetHeaderIconButton,
SheetHeaderView,
type SheetHeader,
} from "@/components/adaptive-modal-sheet";
let root: Root | null = null;
let container: HTMLDivElement | null = null;
afterEach(() => {
act(() => root?.unmount());
container?.remove();
root = null;
container = null;
});
function noop() {}
const LONG_TITLE =
"A sheet title long enough to fill the whole header row on a phone and then some more";
const header: SheetHeader = {
title: LONG_TITLE,
actions: (
<>
<SheetHeaderIconButton icon={RotateCw} label="Refresh" onPress={noop} testID="refresh" />
<SheetHeaderIconButton icon={Settings} label="Settings" onPress={noop} testID="settings" />
</>
),
};
function mount(node: React.ReactNode) {
container = document.createElement("div");
container.style.width = "390px";
document.body.appendChild(container);
root = createRoot(container);
act(() => root?.render(node));
}
function rectOf(testID: string): DOMRect {
const found = document.querySelector(`[data-testid="${testID}"]`);
if (!(found instanceof HTMLElement)) throw new Error(`Nothing rendered as ${testID}`);
return found.getBoundingClientRect();
}
/** Each button keeps its own width, follows the previous one and stays inside the row. */
function expectButtonsInRow(testIDs: string[]) {
const row = container?.getBoundingClientRect();
if (!row) throw new Error("Nothing mounted");
const rects = testIDs.map(rectOf);
for (const [index, rect] of rects.entries()) {
expect(rect.width).toBeGreaterThan(0);
expect(rect.right).toBeLessThanOrEqual(row.right + 1);
const previous = rects[index - 1];
if (previous) expect(rect.left).toBeGreaterThanOrEqual(previous.right);
}
}
describe("sheet header with a long title", () => {
it("truncates the title and keeps the actions and Close in place", () => {
mount(<SheetHeaderView header={header} onClose={noop} closeTestID="close" />);
expectButtonsInRow(["refresh", "settings", "close"]);
});
it("does the same in the inline header", () => {
mount(<InlineHeaderView header={header} />);
expectButtonsInRow(["refresh", "settings"]);
});
});
@@ -3,7 +3,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import type { ComponentProps, ReactNode } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { Keyboard, Pressable, Text, View } from "react-native";
import { Keyboard, Text, View } from "react-native";
import type { DimensionValue, StyleProp, ViewProps, ViewStyle } from "react-native";
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
@@ -29,6 +29,15 @@ import { useKeyboardVisibility } from "@/hooks/use-keyboard-visibility";
import { SafeAreaInsetsContext, useSafeAreaInsets } from "react-native-safe-area-context";
import { AdaptiveTextInput } from "@/components/adaptive-text-input";
import { useDismissibleOverlay } from "@/components/ui/overlay-dismissal";
import { DismissBackdrop } from "@/components/ui/dismiss-backdrop";
import {
IconButtonChromeProvider,
iconButtonChromeGlyphSize,
iconButtonChromeHang,
mutedIconColorMapping,
} from "@/components/ui/icon-button-chrome";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { ToolbarButton } from "@/components/ui/pane-content-toolbar";
export { AdaptiveTextInput, type AdaptiveTextInputProps } from "@/components/adaptive-text-input";
// Horizontal indent token shared by the sheet header (title, back arrow,
@@ -37,11 +46,6 @@ export { AdaptiveTextInput, type AdaptiveTextInputProps } from "@/components/ada
// match this padding.
export const SHEET_HORIZONTAL_PADDING_SCALE = 6;
// The header's close button grows outward from its glyph, so the glyph's
// trailing rail is the content inset plus this padding. Rows whose trailing
// glyph should line up with the X must reach the same rail.
export const SHEET_HEADER_CLOSE_PADDING_SCALE = 2;
/** The one height a sheet takes when it shows a whole pane rather than sizing to its content. */
export const FULL_SHEET_SNAP_POINTS = ["92%"];
@@ -63,6 +67,7 @@ export interface SheetHeaderBack {
export interface SheetHeader {
title: string;
titleTestID?: string;
subtitle?: ReactNode;
back?: SheetHeaderBack;
leading?: ReactNode;
@@ -71,7 +76,9 @@ export interface SheetHeader {
}
const SCROLL_CONTENT_GROW = { flexGrow: 1 };
const ABSOLUTE_FILL_STYLE = { ...StyleSheet.absoluteFill };
// Header buttons hang by how far their frame reaches past the glyph, so the edge glyphs sit on the
// content rail and a 44pt touch frame does not grow the row.
const HEADER_BUTTON_HANG = iconButtonChromeHang("large");
const NATIVE_DIALOG_SNAP_POINTS = ["100%"];
const styles = StyleSheet.create((theme) => ({
@@ -105,20 +112,24 @@ const styles = StyleSheet.create((theme) => ({
borderBottomWidth: 1,
borderBottomColor: theme.colors.surface2,
},
// One height for every sheet header whatever its buttons; a hung 44pt touch frame fits inside it.
headerRow: {
minHeight: 64,
paddingHorizontal: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE],
paddingVertical: theme.spacing[4],
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
headerBackButton: {
borderRadius: theme.borderRadius.lg,
headerBack: {
marginVertical: HEADER_BUTTON_HANG,
marginLeft: HEADER_BUTTON_HANG,
},
headerLeadingSlot: {
alignItems: "center",
justifyContent: "center",
},
// The title takes the room the buttons leave and truncates; the buttons never shrink.
headerTitleGroup: {
flex: 1,
gap: theme.spacing[1],
@@ -128,14 +139,17 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.medium,
},
headerActions: {
subtitle: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
headerTrailing: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
closeButton: {
padding: theme.spacing[SHEET_HEADER_CLOSE_PADDING_SCALE],
borderRadius: theme.borderRadius.lg,
flexShrink: 0,
marginVertical: HEADER_BUTTON_HANG,
marginRight: HEADER_BUTTON_HANG,
},
// Without a header the close button floats over the card's top-right corner.
floatingClose: {
@@ -176,6 +190,7 @@ const styles = StyleSheet.create((theme) => ({
},
inlineTitle: {
flex: 1,
minWidth: 0,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foreground,
@@ -343,47 +358,92 @@ function SheetContent({ style, children }: { style: StyleProp<ViewStyle>; childr
return <View style={[styles.sheetContent, style]}>{children}</View>;
}
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const ThemedHeaderGlyph = withUnistyles(function HeaderGlyph({
icon: Icon,
size,
color,
}: {
icon: LucideIcon;
size: number;
color?: string;
}) {
return <Icon size={size} color={color} />;
});
/**
* An icon action in a sheet header's trailing rail, drawn like the Close button so a header's
* actions line up with it at one size. Pass it through `SheetHeader.actions`.
* The one icon button of a sheet header: the back arrow, the close X, and every action passed
* through `SheetHeader.actions`. Inside a header it takes the header island's chrome, so all of them
* share one frame, glyph, colour and hover. `busy` swaps the glyph for a spinner and holds presses.
*/
export function SheetHeaderIconButton({
icon: Icon,
accessibilityLabel,
icon,
label,
onPress,
busy = false,
disabled = false,
testID,
}: {
icon: LucideIcon;
accessibilityLabel: string;
/** The accessible name, also shown as the desktop tooltip. */
label: string;
onPress: () => void;
busy?: boolean;
disabled?: boolean;
testID?: string;
}) {
const { theme } = useUnistyles();
const compact = useIsCompactFormFactor();
const glyphSize = iconButtonChromeGlyphSize("large", compact);
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
style={styles.closeButton}
<ToolbarButton
label={label}
compact={compact}
disabled={disabled || busy}
onPress={onPress}
testID={testID}
>
{({ pressed }) => (
<Icon size={16} color={pressed ? theme.colors.foreground : theme.colors.foregroundMuted} />
{busy ? (
<ThemedLoadingSpinner size={glyphSize} uniProps={mutedIconColorMapping} />
) : (
<ThemedHeaderGlyph icon={icon} size={glyphSize} uniProps={mutedIconColorMapping} />
)}
</Pressable>
</ToolbarButton>
);
}
function SheetHeaderBackButton({ back }: { back: SheetHeaderBack }) {
const { t } = useTranslation();
return (
<View style={styles.headerBack}>
<SheetHeaderIconButton
icon={ArrowLeft}
label={back.accessibilityLabel ?? back.label ?? t("common.actions.back")}
onPress={back.onPress}
testID="sheet-header-back"
/>
</View>
);
}
/** A plain-text header subtitle: one muted line under the title that truncates, never the title. */
export function SheetHeaderSubtitle({ text, testID }: { text: string; testID?: string }) {
return (
<Text style={styles.subtitle} numberOfLines={1} testID={testID}>
{text}
</Text>
);
}
export function SheetHeaderView({
header,
onClose,
showCloseButton = true,
testID,
closeTestID,
}: {
header: SheetHeader;
onClose: () => void;
showCloseButton?: boolean;
testID?: string;
closeTestID?: string;
}) {
const { theme } = useUnistyles();
const { t } = useTranslation();
@@ -391,8 +451,6 @@ export function SheetHeaderView({
() => [styles.title, { color: theme.colors.foreground }],
[theme.colors.foreground],
);
const back = header.back;
const handleBackPress = back?.onPress;
const search = header.search;
const handleSearchChange = useCallback(
(value: string) => {
@@ -403,40 +461,27 @@ export function SheetHeaderView({
return (
<View style={styles.headerContainer} testID={testID}>
<View style={styles.headerRow}>
{handleBackPress ? (
<Pressable
onPress={handleBackPress}
hitSlop={8}
style={styles.headerBackButton}
accessibilityRole="button"
accessibilityLabel={back?.accessibilityLabel ?? back?.label ?? t("common.actions.back")}
testID="sheet-header-back"
>
{({ pressed }) => (
<ArrowLeft
size={18}
color={pressed ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</Pressable>
) : null}
{header.leading ? <View style={styles.headerLeadingSlot}>{header.leading}</View> : null}
<View style={styles.headerTitleGroup}>
<Text style={titleStyle} numberOfLines={1}>
{header.title}
</Text>
{header.subtitle}
<IconButtonChromeProvider value="header-island">
<View style={styles.headerRow}>
{header.back ? <SheetHeaderBackButton back={header.back} /> : null}
{header.leading ? <View style={styles.headerLeadingSlot}>{header.leading}</View> : null}
<View style={styles.headerTitleGroup}>
<Text style={titleStyle} numberOfLines={1} testID={header.titleTestID}>
{header.title}
</Text>
{header.subtitle}
</View>
<View style={styles.headerTrailing}>
{header.actions}
<SheetHeaderIconButton
icon={X}
label={t("common.actions.close")}
onPress={onClose}
testID={closeTestID}
/>
</View>
</View>
{header.actions ? <View style={styles.headerActions}>{header.actions}</View> : null}
{showCloseButton ? (
<SheetHeaderIconButton
icon={X}
accessibilityLabel={t("common.actions.close")}
onPress={onClose}
/>
) : null}
</View>
</IconButtonChromeProvider>
{search ? (
<View style={styles.searchRow}>
<Search size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
@@ -462,39 +507,21 @@ export function SheetHeaderView({
export function InlineHeaderView({ header }: { header: SheetHeader }) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const back = header.back;
const handleBackPress = back?.onPress;
const hasInlineRow = Boolean(handleBackPress || header.leading || header.actions);
const hasInlineRow = Boolean(header.back || header.leading || header.actions);
if (!hasInlineRow && !header.search) return null;
return (
<View>
{hasInlineRow ? (
<View style={styles.inlineHeaderRow}>
{handleBackPress ? (
<Pressable
onPress={handleBackPress}
hitSlop={8}
style={styles.headerBackButton}
accessibilityRole="button"
accessibilityLabel={
back?.accessibilityLabel ?? back?.label ?? t("common.actions.back")
}
testID="sheet-header-back"
>
{({ pressed }) => (
<ArrowLeft
size={16}
color={pressed ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</Pressable>
) : null}
{header.leading ? <View style={styles.headerLeadingSlot}>{header.leading}</View> : null}
<Text style={styles.inlineTitle} numberOfLines={1}>
{header.title}
</Text>
{header.actions ? <View style={styles.headerActions}>{header.actions}</View> : null}
</View>
<IconButtonChromeProvider value="header-island">
<View style={styles.inlineHeaderRow}>
{header.back ? <SheetHeaderBackButton back={header.back} /> : null}
{header.leading ? <View style={styles.headerLeadingSlot}>{header.leading}</View> : null}
<Text style={styles.inlineTitle} numberOfLines={1} testID={header.titleTestID}>
{header.title}
</Text>
{header.actions ? <View style={styles.headerTrailing}>{header.actions}</View> : null}
</View>
</IconButtonChromeProvider>
) : null}
{header.search ? (
<View style={styles.inlineSearchRow}>
@@ -798,24 +825,18 @@ export function AdaptiveModalSheet({
</View>
{footerView}
{header ? null : (
<View style={styles.floatingClose}>
<SheetHeaderIconButton
icon={X}
accessibilityLabel={t("common.actions.close")}
onPress={onClose}
/>
</View>
<IconButtonChromeProvider value="header-island">
<View style={styles.floatingClose}>
<SheetHeaderIconButton icon={X} label={t("common.actions.close")} onPress={onClose} />
</View>
</IconButtonChromeProvider>
)}
</OverlayLayerProvider>
);
const desktopContent = (
<View style={desktopOverlayStyle} testID={testID}>
<Pressable
accessibilityLabel={t("common.actions.dismiss")}
style={ABSOLUTE_FILL_STYLE}
onPress={onClose}
/>
<DismissBackdrop label={t("common.actions.dismiss")} onPress={onClose} />
<View ref={setOverlayScope} style={desktopCardStyle} role="dialog" aria-modal tabIndex={-1}>
{cardInner}
</View>
+4 -14
View File
@@ -17,11 +17,12 @@ import { useIsCompactFormFactor } from "@/constants/layout";
import { useTimeAgo } from "@/hooks/use-time-ago";
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { useSessionStore } from "@/stores/session-store";
import { Archive, ChevronRight } from "lucide-react-native";
import { ChevronRight } from "lucide-react-native";
import { useProviderIcon } from "@/components/provider-icons";
import { navigateToAgent } from "@/utils/navigate-to-agent";
import { useArchiveAgent } from "@/hooks/use-archive-agent";
import { HighlightedText } from "@/components/ui/highlighted-text";
import { ArchivedBadge } from "@/components/archived-badge";
import { StatusBadge, type StatusBadgeVariant } from "@/components/ui/status-badge";
import { findHighlightRanges } from "@/components/ui/highlighted-text-segments";
@@ -99,36 +100,30 @@ function formatDateSectionLabel(t: TFunction, section: DateSectionKey): string {
function SessionBadge({
label,
icon,
tone = "neutral",
}: {
label: string;
icon?: ReactElement;
tone?: "neutral" | "warning" | "danger";
}) {
let variant: StatusBadgeVariant = "muted";
if (tone === "warning") variant = "warning";
else if (tone === "danger") variant = "error";
return <StatusBadge label={label} variant={variant} leading={icon} />;
return <StatusBadge label={label} variant={variant} />;
}
function SessionRowBadges({
agent,
archivedIcon,
pendingPermissionCount,
showDesktopAttention,
}: {
agent: AggregatedAgent;
archivedIcon: ReactElement;
pendingPermissionCount: number;
showDesktopAttention: boolean;
}) {
const { t } = useTranslation();
return (
<>
{agent.archivedAt ? (
<SessionBadge label={t("agentList.badges.archived")} icon={archivedIcon} />
) : null}
{agent.archivedAt ? <ArchivedBadge /> : null}
{pendingPermissionCount > 0 ? (
<SessionBadge
label={t("agentList.badges.pending", { count: pendingPermissionCount })}
@@ -214,10 +209,6 @@ function SessionRow({
const handlePress = useCallback(() => onPress(agent), [onPress, agent]);
const handleLongPress = useCallback(() => onLongPress(agent), [onLongPress, agent]);
const archivedIcon = useMemo(
() => <Archive size={theme.fontSize.sm} color={theme.colors.foregroundMuted} />,
[theme.fontSize.sm, theme.colors.foregroundMuted],
);
const showDesktopAttention =
!isMobile && showAttentionIndicator && Boolean(agent.requiresAttention);
@@ -261,7 +252,6 @@ function SessionRow({
) : null}
<SessionRowBadges
agent={agent}
archivedIcon={archivedIcon}
pendingPermissionCount={pendingPermissionCount}
showDesktopAttention={showDesktopAttention}
/>
@@ -1,11 +1,15 @@
import * as Clipboard from "expo-clipboard";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Platform, Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import { Platform, Text, View } from "react-native";
import { Copy, RotateCw } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import {
AdaptiveModalSheet,
SheetHeaderIconButton,
type SheetHeader,
} from "@/components/adaptive-modal-sheet";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { ScrollableCodeSurface, SurfaceCard } from "@/components/ui/scrollable-code-surface";
import { useToast } from "@/contexts/toast-context";
@@ -42,8 +46,6 @@ interface DiagnosticCollectionResult {
}
const SNAP_POINTS = ["55%", "88%"];
const ThemedCopy = withUnistyles(Copy);
const ThemedRotateCw = withUnistyles(RotateCw);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
@@ -150,68 +152,31 @@ export function AppDiagnosticSheet({
.catch(() => toast.error(t("settings.diagnostics.app.copyFailed")));
}, [diagnostic, t, toast]);
const iconButtonStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.iconButton,
(Boolean(hovered) || pressed) && styles.iconButtonHovered,
],
[],
);
const disabledIconButtonStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.iconButton,
(Boolean(hovered) || pressed) && Boolean(diagnostic) && styles.iconButtonHovered,
diagnostic ? null : styles.disabled,
],
[diagnostic],
);
const header = useMemo<SheetHeader>(
() => ({
title: t("settings.diagnostics.app.title"),
actions: (
<View style={styles.headerActions}>
<Pressable
<>
<SheetHeaderIconButton
icon={Copy}
label={t("settings.diagnostics.app.copyAccessibility")}
onPress={handleCopyPress}
disabled={!diagnostic}
hitSlop={8}
style={disabledIconButtonStyle}
accessibilityRole="button"
accessibilityLabel={t("settings.diagnostics.app.copyAccessibility")}
>
<ThemedCopy size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
</Pressable>
<Pressable
onPress={handleRefreshPress}
disabled={loading}
hitSlop={8}
style={iconButtonStyle}
accessibilityRole="button"
accessibilityLabel={
/>
<SheetHeaderIconButton
icon={RotateCw}
label={
loading
? t("settings.diagnostics.app.refreshingAccessibility")
: t("settings.diagnostics.app.refreshAccessibility")
}
>
{loading ? (
<ThemedLoadingSpinner size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
) : (
<ThemedRotateCw size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
)}
</Pressable>
</View>
onPress={handleRefreshPress}
busy={loading}
/>
</>
),
}),
[
diagnostic,
disabledIconButtonStyle,
handleCopyPress,
handleRefreshPress,
iconButtonStyle,
loading,
t,
],
[diagnostic, handleCopyPress, handleRefreshPress, loading, t],
);
return (
@@ -342,24 +307,6 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.base,
color: theme.colors.foregroundMuted,
},
headerActions: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
iconButton: {
width: 28,
height: 28,
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
},
iconButtonHovered: {
backgroundColor: theme.colors.surface2,
},
disabled: {
opacity: 0.5,
},
statusDot: {
width: 8,
height: 8,
@@ -0,0 +1,17 @@
import { Archive } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { withUnistyles } from "react-native-unistyles";
import { StatusBadge } from "@/components/ui/status-badge";
const ThemedArchive = withUnistyles(Archive, (theme) => ({
size: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
}));
const ARCHIVE_ICON = <ThemedArchive />;
/** Marks a session or workspace the user archived, wherever archived items are listed. */
export function ArchivedBadge() {
const { t } = useTranslation();
return <StatusBadge label={t("agentList.badges.archived")} leading={ARCHIVE_ICON} />;
}
@@ -0,0 +1,149 @@
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { persistAttachmentFromBlob } from "@/attachments/service";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { i18n } from "@/i18n/i18next";
import { WindowLayoutProvider } from "@/window-layout";
import { AttachmentLightbox, type ImageLightboxSource } from "./attachment-lightbox";
interface FrameSample {
image: { x: number; y: number; width: number; height: number } | null;
error: boolean;
}
function nextFrame(): Promise<number> {
return new Promise((resolve) => requestAnimationFrame(resolve));
}
function createPng(width: number, height: number): Promise<Blob> {
const canvas = new OffscreenCanvas(width, height);
const context = canvas.getContext("2d");
if (!context) throw new Error("Canvas 2D context unavailable");
context.fillStyle = "#3a7";
context.fillRect(0, 0, width, height);
return canvas.convertToBlob({ type: "image/png" });
}
function noop() {}
function isPainted(element: Element): boolean {
for (let node: Element | null = element; node; node = node.parentElement) {
if (Number(getComputedStyle(node).opacity) === 0) return false;
}
return true;
}
/** What the next paint will show: the visible lightbox image box, and whether the error is up. */
function sampleFrame(): FrameSample {
const image = document.querySelector('[data-testid="attachment-lightbox-image"]');
const rect = image?.getBoundingClientRect();
const visible = image && rect && rect.width > 0 && rect.height > 0 && isPainted(image);
return {
image: visible
? {
x: Math.round(rect.x),
y: Math.round(rect.y),
width: Math.round(rect.width),
height: Math.round(rect.height),
}
: null,
error: (document.body.textContent ?? "").includes("Couldn't load image"),
};
}
/** Samples every frame from the open until the image has held one box for 20 frames. */
async function recordOpening(open: () => void): Promise<FrameSample[]> {
const samples: FrameSample[] = [];
open();
let stableFrames = 0;
for (let frame = 0; frame < 300 && stableFrames < 20; frame += 1) {
await nextFrame();
const sample = sampleFrame();
const previous = samples.at(-1);
stableFrames =
sample.image && JSON.stringify(sample.image) === JSON.stringify(previous?.image)
? stableFrames + 1
: 0;
samples.push(sample);
}
return samples;
}
let root: Root;
let container: HTMLDivElement;
beforeEach(async () => {
vi.stubGlobal("React", React);
await i18n.changeLanguage("en");
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
document.body.replaceChildren();
vi.unstubAllGlobals();
});
function renderLightbox(source: ImageLightboxSource | null) {
root.render(
<SafeAreaProvider>
<WindowLayoutProvider>
<AttachmentLightbox source={source} onClose={noop} />
</WindowLayoutProvider>
</SafeAreaProvider>,
);
}
async function expectOpeningPaintsOnlyTheFittedImage(source: ImageLightboxSource) {
const samples = await recordOpening(() => renderLightbox(source));
const fitted = samples.at(-1)?.image;
expect(fitted, "the image never settled").toBeTruthy();
expect(samples.filter((sample) => sample.error)).toEqual([]);
expect(
samples.filter(
(sample) => sample.image && JSON.stringify(sample.image) !== JSON.stringify(fitted),
),
).toEqual([]);
}
describe("AttachmentLightbox opening in the browser", () => {
it("paints the fitted attachment image as its first image frame", async () => {
const metadata = await persistAttachmentFromBlob({
blob: await createPng(2400, 1600),
mimeType: "image/png",
fileName: "screenshot.png",
});
await expectOpeningPaintsOnlyTheFittedImage({ type: "attachment", metadata });
});
it("paints only the fitted image when the same attachment opens again", async () => {
const metadata = await persistAttachmentFromBlob({
blob: await createPng(2400, 1600),
mimeType: "image/png",
fileName: "screenshot.png",
});
const source: ImageLightboxSource = { type: "attachment", metadata };
await expectOpeningPaintsOnlyTheFittedImage(source);
await act(async () => renderLightbox(null));
await expectOpeningPaintsOnlyTheFittedImage(source);
});
it("shows an image the browser reports no natural size for", async () => {
const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="0" height="0"></svg>';
const uri = URL.createObjectURL(new Blob([svg], { type: "image/svg+xml" }));
await expectOpeningPaintsOnlyTheFittedImage({ type: "uri", uri });
});
it("paints the fitted timeline image as its first image frame", async () => {
const uri = URL.createObjectURL(await createPng(2400, 1600));
await expectOpeningPaintsOnlyTheFittedImage({ type: "uri", uri });
});
});
@@ -4,6 +4,7 @@ import { createRoot, type Root } from "react-dom/client";
import { JSDOM } from "jsdom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { AttachmentMetadata } from "@/attachments/types";
import type { AttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
import { AttachmentLightbox, type ImageLightboxSource } from "./attachment-lightbox";
const { theme, imageMetadata, useAttachmentPreviewUrlMock } = vi.hoisted(() => {
@@ -38,9 +39,9 @@ const { theme, imageMetadata, useAttachmentPreviewUrlMock } = vi.hoisted(() => {
return {
theme: hoistedTheme,
imageMetadata: hoistedImageMetadata,
useAttachmentPreviewUrlMock: vi.fn<(metadata: AttachmentMetadata | null) => string | null>(
() => "blob:preview",
),
useAttachmentPreviewUrlMock: vi.fn<
(metadata: AttachmentMetadata | null) => AttachmentPreviewUrl
>(() => ({ status: "ready", url: "blob:preview" })),
};
});
const attachmentSource: ImageLightboxSource = { type: "attachment", metadata: imageMetadata };
@@ -73,10 +74,13 @@ vi.mock("react-i18next", () => ({
}));
vi.mock("react-native-safe-area-context", () => ({
SafeAreaProvider: ({ children }: { children?: React.ReactNode }) => children,
useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
}));
vi.mock("@/window-layout", () => ({
WindowSafeArea: ({ children }: { children?: React.ReactNode }) => children,
}));
vi.mock("react-native-gesture-handler", () => ({
GestureHandlerRootView: ({ children }: { children?: React.ReactNode }) =>
React.createElement("div", { "data-testid": "gesture-handler-root" }, children),
@@ -151,7 +155,7 @@ beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
useAttachmentPreviewUrlMock.mockReturnValue("blob:preview");
useAttachmentPreviewUrlMock.mockReturnValue({ status: "ready", url: "blob:preview" });
});
afterEach(() => {
@@ -229,14 +233,22 @@ describe("AttachmentLightbox", () => {
expect(onClose).toHaveBeenCalledTimes(1);
});
it("shows error text when the preview URL resolves to null", () => {
useAttachmentPreviewUrlMock.mockReturnValue(null);
it("shows error text when the preview URL fails to resolve", () => {
useAttachmentPreviewUrlMock.mockReturnValue({ status: "failed" });
render(<AttachmentLightbox source={attachmentSource} onClose={vi.fn()} />);
expect(queryByTestId("attachment-lightbox-image")).toBeNull();
expect(document.body.textContent ?? "").toContain("Couldn't load image");
});
it("shows neither the image nor an error while the preview URL resolves", () => {
useAttachmentPreviewUrlMock.mockReturnValue({ status: "loading" });
render(<AttachmentLightbox source={attachmentSource} onClose={vi.fn()} />);
expect(queryByTestId("attachment-lightbox-image")).toBeNull();
expect(document.body.textContent ?? "").not.toContain("Couldn't load image");
});
it("closes on Escape key on web", () => {
const onClose = vi.fn();
render(<AttachmentLightbox source={attachmentSource} onClose={onClose} />);
@@ -4,17 +4,21 @@ import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react
import { Pressable, Text, View } from "react-native";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { StyleSheet } from "react-native-unistyles";
import { SafeAreaProvider, useSafeAreaInsets } from "react-native-safe-area-context";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { X } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import type { AttachmentMetadata } from "@/attachments/types";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
import {
type AttachmentPreviewUrl,
useAttachmentPreviewUrl,
} from "@/attachments/use-attachment-preview-url";
import { isNative } from "@/constants/platform";
import { SPACING } from "@/styles/theme";
import { WindowChromeRootRegion } from "@/utils/desktop-window";
import { ZoomableImage } from "@/components/zoomable-viewport/image";
import type { ViewportSize } from "@/components/zoomable-viewport/geometry";
import { useGlobalOverlayLayer } from "@/lib/overlay-root";
import { WindowSafeArea } from "@/window-layout";
export type ImageLightboxSource =
| { type: "attachment"; metadata: AttachmentMetadata }
@@ -31,10 +35,13 @@ const LIGHTBOX_FIT = { padding: SPACING[4], maxWidth: 960, maxHeight: 640 };
export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps) {
const { t } = useTranslation();
const metadata = source?.type === "attachment" ? source.metadata : null;
const attachmentUrl = useAttachmentPreviewUrl(metadata);
const url = source?.type === "uri" ? source.uri : attachmentUrl;
const attachmentPreview = useAttachmentPreviewUrl(metadata);
const image: AttachmentPreviewUrl =
source?.type === "uri" ? { status: "ready", url: source.uri } : attachmentPreview;
const url = image.status === "ready" ? image.url : null;
const contentSize = source?.type === "uri" ? source.contentSize : undefined;
const [errored, setErrored] = useState(false);
const failed = errored || image.status === "failed";
const modalLayer = useGlobalOverlayLayer("modal", source !== null);
useEffect(() => {
@@ -76,14 +83,12 @@ export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps)
return null;
}
const hasError = errored || !url;
return (
<Modal transparent animationType="fade" statusBarTranslucent visible onRequestClose={onClose}>
<ModalRoot style={styles.root}>
{/* The modal is its own window: its insets are the window's, not the ones left where the
image was opened (inside a sheet tab, the bottom one is already spent). */}
<SafeAreaProvider>
{/* The modal draws over the whole window: its insets are the window's, not the ones left
where the image was opened (inside a sheet tab, the bottom one is already spent). */}
<WindowSafeArea>
<WindowChromeRootRegion corners="both">
<View ref={setOverlayScope} style={styles.root}>
<Pressable
@@ -94,9 +99,10 @@ export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps)
style={styles.backdrop}
/>
<LightboxImageArea>
{hasError ? (
{failed ? (
<Text style={styles.errorText}>{t("message.attachments.imageLoadFailed")}</Text>
) : (
) : null}
{!failed && url ? (
<ZoomableImage
accessibilityLabel={t("composer.attachments.openImage")}
actions={actions}
@@ -108,11 +114,11 @@ export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps)
testID="attachment-lightbox"
uri={url}
/>
)}
) : null}
</LightboxImageArea>
</View>
</WindowChromeRootRegion>
</SafeAreaProvider>
</WindowSafeArea>
</ModalRoot>
</Modal>
);
@@ -133,9 +133,10 @@ export function AttachmentLabel({ icon, title, subtitle }: AttachmentLabelProps)
/** Square image preview pill body. */
export function AttachmentThumbnail({ metadata }: { metadata: AttachmentMetadata }) {
const uri = useAttachmentPreviewUrl(metadata);
const source = useMemo(() => ({ uri: uri ?? "" }), [uri]);
if (!uri) {
const preview = useAttachmentPreviewUrl(metadata);
const uri = preview.status === "ready" ? preview.url : null;
const source = useMemo(() => (uri ? { uri } : null), [uri]);
if (!source) {
return <View style={styles.thumbnailPlaceholder} />;
}
return <Image source={source} style={styles.thumbnail} />;
@@ -0,0 +1,45 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createDragHold } from "./drag-hold";
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
function hold() {
const onArm = vi.fn();
return { onArm, hold: createDragHold({ delayMs: 180, slopPx: 4, onArm }) };
}
describe("createDragHold", () => {
it("arms once the finger has stayed within the slop for the delay", () => {
const { onArm, hold: dragHold } = hold();
dragHold.begin({ x: 10, y: 10 });
dragHold.move({ x: 12, y: 13 });
vi.advanceTimersByTime(180);
expect(onArm).toHaveBeenCalledTimes(1);
});
it("never arms when the finger moves past the slop first, as when a scroll starts", () => {
const { onArm, hold: dragHold } = hold();
dragHold.begin({ x: 10, y: 10 });
dragHold.move({ x: 10, y: 20 });
vi.advanceTimersByTime(500);
expect(onArm).not.toHaveBeenCalled();
});
it("never arms when the finger lifts before the delay", () => {
const { onArm, hold: dragHold } = hold();
dragHold.begin({ x: 10, y: 10 });
vi.advanceTimersByTime(100);
dragHold.cancel();
vi.advanceTimersByTime(500);
expect(onArm).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,51 @@
export interface TouchPoint {
x: number;
y: number;
}
export interface DragHold {
begin: (point: TouchPoint) => void;
move: (point: TouchPoint) => void;
cancel: () => void;
}
/**
* A hold that arms a drag once the finger has stayed put for `delayMs`. Moving further than
* `slopPx` from where the touch began cancels it, so a scroll that starts on a handle stays a
* scroll. The sidebar rows arm on the same delay and slop.
*/
export function createDragHold({
delayMs,
slopPx,
onArm,
}: {
delayMs: number;
slopPx: number;
onArm: () => void;
}): DragHold {
let timer: ReturnType<typeof setTimeout> | null = null;
let start: TouchPoint | null = null;
function cancel() {
if (timer) clearTimeout(timer);
timer = null;
start = null;
}
return {
begin(point) {
cancel();
start = point;
timer = setTimeout(() => {
timer = null;
start = null;
onArm();
}, delayMs);
},
move(point) {
if (!start) return;
if (Math.hypot(point.x - start.x, point.y - start.y) > slopPx) cancel();
},
cancel,
};
}
@@ -0,0 +1,14 @@
import { describe, expect, it } from "vitest";
import { movedItemKey } from "./native-drop";
const byValue = (item: string): string => item;
describe("movedItemKey", () => {
it("names the item at its new slot", () => {
expect(movedItemKey({ data: ["b", "c", "a"], from: 0, to: 2 }, byValue)).toBe("a");
});
it("names nothing when the item is released in the slot it started in", () => {
expect(movedItemKey({ data: ["a", "b", "c"], from: 1, to: 1 }, byValue)).toBeNull();
});
});
@@ -0,0 +1,17 @@
export interface NativeDrop<T> {
data: T[];
from: number;
to: number;
}
/**
* The key of the item a native drop moved, or null when the item was released in the slot it
* started in. The native list reports every release; web reports only drops that moved an item.
*/
export function movedItemKey<T>(
{ data, from, to }: NativeDrop<T>,
keyExtractor: (item: T, index: number) => string,
): string | null {
if (from === to) return null;
return keyExtractor(data[to], to);
}
@@ -24,7 +24,7 @@ export function useDragReorderState<T>({
}: {
data: T[];
keyExtractor: (item: T, index: number) => string;
onDragEnd?: (items: T[]) => void;
onDragEnd?: (items: T[], movedKey: string) => void;
onDragBegin?: () => void;
disabled?: boolean;
}): DragReorderState<T> {
@@ -50,13 +50,14 @@ export function useDragReorderState<T>({
dispatch({ type: "clear" });
if (disabled) return;
const activeId = String(active.id);
const reordered = reorderItemsOnDragEnd({
items,
activeId: String(active.id),
activeId,
overId: over ? String(over.id) : null,
keyExtractor,
});
if (reordered) onDragEnd?.(reordered);
if (reordered) onDragEnd?.(reordered, activeId);
},
[data, disabled, keyExtractor, onDragEnd, state.dragItems],
);
@@ -1,13 +1,87 @@
import { RefreshControl } from "react-native";
import { useCallback, useMemo, useState } from "react";
import { RefreshControl, View, type GestureResponderEvent } from "react-native";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import * as Haptics from "expo-haptics";
import DraggableFlatList, {
NestableDraggableFlatList,
type RenderItemParams,
} from "react-native-draggable-flatlist";
import type { GestureType } from "react-native-gesture-handler";
import { useUnistyles } from "react-native-unistyles";
import type { DraggableListProps, DraggableRenderItemInfo } from "./draggable-list.types";
import { useContentDrag } from "@/components/ui/scroll-view";
import { createDragHold } from "./drag-reorder/drag-hold";
import { movedItemKey } from "./drag-reorder/native-drop";
import type {
DraggableListHandleProps,
DraggableListProps,
DraggableRenderItemInfo,
} from "./draggable-list.types";
export type { DraggableListProps, DraggableRenderItemInfo };
export type { DraggableListHandleProps, DraggableListProps, DraggableRenderItemInfo };
/** The row's `drag()`, for its handle. */
const RowDragContext = createContext<(() => void) | null>(null);
// The list only accepts drag() once its pan gesture has begun for this touch, and that begin
// reaches JS after the touch does. Same hold and slop as the sidebar rows' drag arm.
const HANDLE_HOLD_MS = 180;
const HANDLE_HOLD_SLOP_PX = 4;
function touchPoint(event: GestureResponderEvent) {
return { x: event.nativeEvent.pageX, y: event.nativeEvent.pageY };
}
/**
* On native the handle has no accessibility role or label: a screen reader cannot hold and drag,
* and announcing a control whose activation does nothing misleads. The label is for web, where
* the keyboard reorders through it.
*/
export function DraggableListHandle({ testID, style, children }: DraggableListHandleProps) {
const drag = useContext(RowDragContext);
const hold = useMemo(
() =>
drag
? createDragHold({
delayMs: HANDLE_HOLD_MS,
slopPx: HANDLE_HOLD_SLOP_PX,
onArm: () => {
void Haptics.selectionAsync().catch(() => {});
drag();
},
})
: null,
[drag],
);
useEffect(() => () => hold?.cancel(), [hold]);
const startHold = useCallback(
(event: GestureResponderEvent) => hold?.begin(touchPoint(event)),
[hold],
);
const moveHold = useCallback(
(event: GestureResponderEvent) => hold?.move(touchPoint(event)),
[hold],
);
const cancelHold = useCallback(() => hold?.cancel(), [hold]);
return (
<View
onTouchStart={startHold}
onTouchMove={moveHold}
onTouchEnd={cancelHold}
onTouchCancel={cancelHold}
style={style}
testID={testID}
>
{children}
</View>
);
}
const SCROLL_ENABLED_FLEX_STYLE = { flex: 1 };
@@ -41,6 +115,12 @@ export function DraggableList<T>({
}: DraggableListProps<T>) {
const { theme } = useUnistyles();
const [isDragging, setIsDragging] = useState(false);
// Inside a sheet the sheet would otherwise take the vertical move from an armed row.
const contentDrag = useContentDrag();
const { attach: attachContentDrag } = contentDrag;
const dragGestureRef = useRef<GestureType | undefined>(undefined);
// Passive effects run after the list's gesture detector has attached and filled the ref.
useEffect(() => attachContentDrag?.(dragGestureRef), [attachContentDrag]);
// Pass the ref directly to DraggableFlatList - it handles gesture
// coordination internally for nestable lists.
@@ -63,27 +143,31 @@ export function DraggableList<T>({
drag,
isActive,
};
return renderItem(info);
return <RowDragContext.Provider value={drag}>{renderItem(info)}</RowDragContext.Provider>;
},
[renderItem],
);
const handleDragEnd = useCallback(
({ data: newData }: { data: T[] }) => {
(drop: { data: T[]; from: number; to: number }) => {
setIsDragging(false);
onDragEnd(newData);
contentDrag.setActive(false);
const moved = movedItemKey(drop, keyExtractor);
if (moved !== null) onDragEnd(drop.data, moved);
},
[onDragEnd],
[contentDrag, keyExtractor, onDragEnd],
);
const handleDragBegin = useCallback(() => {
setIsDragging(true);
contentDrag.setActive(true);
onDragBeginProp?.();
}, [onDragBeginProp]);
}, [contentDrag, onDragBeginProp]);
const handleRelease = useCallback(() => {
setIsDragging(false);
}, []);
contentDrag.setActive(false);
}, [contentDrag]);
const showRefreshControl = Boolean(onRefresh) && (!isDragging || Boolean(refreshing));
const resolvedContainerStyle =
@@ -128,6 +212,7 @@ export function DraggableList<T>({
extraData={extraData}
simultaneousHandlers={simultaneousHandlers}
dragGestureHostPresented={gestureHostPresented}
dragGestureRef={attachContentDrag ? dragGestureRef : undefined}
// Higher activation distance reduces accidental drag capture while nested
// lists are inside a scroll container.
activationDistance={20}
@@ -1,4 +1,4 @@
import type { ReactElement, MutableRefObject } from "react";
import type { ReactElement, ReactNode, MutableRefObject } from "react";
import type { StyleProp, ViewStyle } from "react-native";
import type { GestureType } from "react-native-gesture-handler";
@@ -12,6 +12,18 @@ export interface DraggableListDragHandleProps {
setActivatorNodeRef?: (node: unknown) => void;
}
/**
* The grip a row is dragged by, rendered anywhere inside `renderItem`. Pointer drags start from
* it on web (with `useDragHandle`); touch drags start after a short hold on native.
*/
export interface DraggableListHandleProps {
/** Names the keyboard-reorderable handle on web. Native leaves the handle out of accessibility. */
accessibilityLabel: string;
testID?: string;
style?: StyleProp<ViewStyle>;
children: ReactNode;
}
export interface DraggableRenderItemInfo<T> {
item: T;
index: number;
@@ -24,7 +36,8 @@ export interface DraggableListProps<T> {
data: T[];
keyExtractor: (item: T, index: number) => string;
renderItem: (info: DraggableRenderItemInfo<T>) => ReactElement;
onDragEnd: (data: T[]) => void;
/** The new order, and the key of the item the user moved to reach it. */
onDragEnd: (data: T[], movedKey: string) => void;
style?: StyleProp<ViewStyle>;
/** Outer container style (useful for nested, non-scrolling lists). */
containerStyle?: StyleProp<ViewStyle>;
@@ -0,0 +1,110 @@
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
DraggableList,
DraggableListHandle,
type DraggableRenderItemInfo,
} from "./draggable-list.web";
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
interface Item {
id: string;
}
function keyExtractor(item: Item): string {
return item.id;
}
function noop() {}
let root: Root | null = null;
let container: HTMLDivElement | null = null;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root?.unmount());
container?.remove();
root = null;
container = null;
});
function renderRowWithHandle(info: DraggableRenderItemInfo<Item>) {
return (
<div>
<DraggableListHandle accessibilityLabel={`Move ${info.item.id}`} testID="row-handle">
<span>grip</span>
</DraggableListHandle>
<span data-testid="row-body">{info.item.id}</span>
</div>
);
}
function renderList(onDragBegin: () => void) {
act(() => {
root?.render(
<DraggableList
data={[{ id: "one" }, { id: "two" }]}
keyExtractor={keyExtractor}
renderItem={renderRowWithHandle}
onDragEnd={noop}
onDragBegin={onDragBegin}
useDragHandle
/>,
);
});
}
function byTestId(testID: string): HTMLElement {
const found = container?.querySelector(`[data-testid="${testID}"]`);
if (!(found instanceof HTMLElement)) throw new Error(`Nothing rendered as ${testID}`);
return found;
}
/** Press on the element's centre, move the mouse down by `distance`, and release. */
function pressAndMove(element: HTMLElement, distance: number) {
const { left, top, width, height } = element.getBoundingClientRect();
const x = left + width / 2;
const y = top + height / 2;
act(() => {
element.dispatchEvent(
new MouseEvent("mousedown", { bubbles: true, button: 0, clientX: x, clientY: y }),
);
});
act(() => {
document.dispatchEvent(
new MouseEvent("mousemove", { bubbles: true, clientX: x, clientY: y + distance }),
);
});
act(() => {
document.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
});
}
describe("DraggableListHandle on web", () => {
it("starts the row's drag from the handle, and only from the handle", () => {
const onDragBegin = vi.fn();
renderList(onDragBegin);
pressAndMove(byTestId("row-body"), 20);
expect(onDragBegin).not.toHaveBeenCalled();
pressAndMove(byTestId("row-handle"), 20);
expect(onDragBegin).toHaveBeenCalledTimes(1);
});
it("is a labelled button rather than dnd-kit's sortable", () => {
renderList(noop);
const handle = byTestId("row-handle");
expect(handle.getAttribute("role")).toBe("button");
expect(handle.getAttribute("aria-label")).toBe("Move one");
expect(handle.getAttribute("aria-roledescription")).toBeNull();
});
});
@@ -1,4 +1,13 @@
import { memo, useCallback, useMemo, useRef, type ReactElement } from "react";
import {
createContext,
memo,
useCallback,
useContext,
useMemo,
useRef,
type ReactElement,
type Ref,
} from "react";
import { ScrollView, View } from "react-native";
import {
DndContext,
@@ -17,10 +26,52 @@ import {
useSortable,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import type { DraggableListProps, DraggableRenderItemInfo } from "./draggable-list.types";
import type {
DraggableListDragHandleProps,
DraggableListHandleProps,
DraggableListProps,
DraggableRenderItemInfo,
} from "./draggable-list.types";
import { getDragActivationConstraints, useDragReorderState } from "./drag-reorder";
export type { DraggableListProps, DraggableRenderItemInfo };
export type { DraggableListHandleProps, DraggableListProps, DraggableRenderItemInfo };
/** The row's dnd-kit activator, for its handle. Absent unless the list uses drag handles. */
const RowHandleContext = createContext<DraggableListDragHandleProps | undefined>(undefined);
// React Native's style types have no `cursor: "grab"`; react-native-web passes it through.
const GRAB_CURSOR = { cursor: "grab" } as object;
export function DraggableListHandle({
accessibilityLabel,
testID,
style,
children,
}: DraggableListHandleProps) {
const handle = useContext(RowHandleContext);
// The handle is a labelled button; dnd-kit's own role, focus stop and "sortable" description
// would replace that.
const {
role: _role,
tabIndex: _tabIndex,
"aria-roledescription": _roleDescription,
...attributes
} = handle?.attributes ?? {};
const handleStyle = useMemo(() => [GRAB_CURSOR, style], [style]);
return (
<View
{...attributes}
{...handle?.listeners}
ref={handle?.setActivatorNodeRef as unknown as Ref<View>}
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
style={handleStyle}
testID={testID}
>
{children}
</View>
);
}
const restrictToVerticalAxis: Modifier = ({ transform }) => ({
...transform,
@@ -177,7 +228,9 @@ function SortableItemInner<T>({
return (
<div {...wrapperProps} style={style}>
{renderItem(info)}
<RowHandleContext.Provider value={dragHandleProps}>
{renderItem(info)}
</RowHandleContext.Provider>
</div>
);
}
@@ -5,6 +5,8 @@ import {
useHostRuntimeConnectionStatus,
} from "@/runtime/host-runtime";
export const HOST_STATUS_DOT_SIZE = 8;
export function HostStatusDot({ serverId }: { serverId: string }) {
const status = useHostRuntimeConnectionStatus(serverId);
@@ -19,8 +21,8 @@ function statusStyle(status: HostRuntimeConnectionStatus) {
const styles = StyleSheet.create((theme) => ({
dot: {
width: 8,
height: 8,
width: HOST_STATUS_DOT_SIZE,
height: HOST_STATUS_DOT_SIZE,
borderRadius: theme.borderRadius.full,
},
dotOnline: {
@@ -1,8 +1,12 @@
import { useCallback, useMemo, useRef, useState, type ReactElement } from "react";
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import { ChevronDown, Server } from "lucide-react-native";
import { Text, View } from "react-native";
import { Server } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import type { Theme } from "@/styles/theme";
import { HOST_STATUS_DOT_SIZE } from "@/components/host-status-dot";
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
import { mapLayout } from "@/components/ui/control-sizing";
import { density } from "@/styles/density";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import {
ALL_HOSTS_OPTION_ID,
getHostPickerLabel,
@@ -12,7 +16,8 @@ import {
} from "@/components/hosts/host-picker";
const ThemedServer = withUnistyles(Server);
const ThemedChevronDown = withUnistyles(ChevronDown);
// The height a hung trigger keeps: one line of its base-size label.
const LABEL_LINE = 20;
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
export interface HostFilterProps {
@@ -24,20 +29,28 @@ export interface HostFilterProps {
* manager edits a single host's catalog, so "all" is not an answer it could carry out.
*/
includeAllHost?: boolean;
/**
* Hang the trigger into the surrounding padding, for a trigger that leads a column such as a
* modal body: its leading glyph's ink sits on the content rail and it adds only a label line to
* the column, so the column's own gap sets the rhythm. The frame still grows outward on hover.
*/
hang?: boolean;
triggerTestID?: string;
hostOptionTestID?: (serverId: string) => string;
}
/**
* The "All hosts / <host>" host pill shared by History, Schedules, Usage, the label manager and
* The "All hosts / <host>" host picker shared by History, Schedules, Usage, the label manager and
* plugin screens: an anchored HostPicker, hidden by the caller when only one host exists. Without
* `includeAllHost` it picks the one host a surface shows.
* `includeAllHost` it picks the one host a surface shows. Its trigger is the Settings host
* trigger; a long host name truncates instead of pushing the row.
*/
export function HostFilter({
hosts,
selectedHost,
onSelectHost,
includeAllHost = true,
hang = false,
triggerTestID,
hostOptionTestID,
}: HostFilterProps): ReactElement {
@@ -50,12 +63,21 @@ export function HostFilter({
);
const handleFilterOpen = useCallback(() => setIsFilterOpen(true), []);
const showsAllHosts = selectedHost === ALL_HOSTS_OPTION_ID;
const slotStyle = useMemo(
() => [
styles.triggerSlot,
hang && styles.triggerSlotHang,
hang && (showsAllHosts ? styles.glyphHang : styles.dotHang),
],
[hang, showsAllHosts],
);
const filterTriggerStyle = useCallback(
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.filterTrigger,
Boolean(hovered) && styles.filterTriggerHovered,
pressed && styles.filterTriggerPressed,
const triggerStyle = useCallback(
({ pressed, hovered }: { pressed: boolean; hovered: boolean }) => [
styles.trigger,
hovered && styles.triggerHovered,
pressed && styles.triggerPressed,
],
[],
);
@@ -74,54 +96,73 @@ export function HostFilter({
desktopPlacement="bottom-start"
hostOptionTestID={hostOptionTestID}
>
<View ref={filterAnchorRef} collapsable={false} style={styles.filterTriggerWrap}>
<Pressable
<View style={slotStyle}>
<ComboboxTrigger
ref={filterAnchorRef}
onPress={handleFilterOpen}
style={filterTriggerStyle}
style={triggerStyle}
testID={triggerTestID}
accessibilityRole="button"
accessibilityLabel={`Filter: ${selectedHostLabel}`}
>
{selectedHost === ALL_HOSTS_OPTION_ID ? (
<ThemedServer size={14} uniProps={mutedColorMapping} />
{showsAllHosts ? (
<ThemedServer size={ICON_SIZE.sm} uniProps={mutedColorMapping} />
) : (
<HostStatusDotSlot serverId={selectedHost} />
)}
<Text style={styles.filterTriggerText} numberOfLines={1}>
<Text style={styles.label} numberOfLines={1}>
{selectedHostLabel}
</Text>
<ThemedChevronDown size={14} uniProps={mutedColorMapping} />
</Pressable>
</ComboboxTrigger>
</View>
</HostPicker>
);
}
const styles = StyleSheet.create((theme) => ({
filterTriggerWrap: {
alignSelf: "flex-start",
// A row around the trigger keeps it as wide as its label in a column, and centred like any other
// item in a row.
triggerSlot: {
flexDirection: "row",
flexShrink: 1,
minWidth: 0,
maxWidth: "100%",
},
filterTrigger: {
// Hangs the frame's height down to a label line.
triggerSlotHang: {
marginVertical: mapLayout(density.tightRowHeight, (height) => (LABEL_LINE - height) / 2),
},
// Hangs the padding, so the server glyph's box sits on the rail.
glyphHang: {
marginLeft: -theme.spacing[2],
},
// Hangs the padding and the dot's inset in its icon slot, so the dot's ink sits on the rail.
dotHang: {
marginLeft: -(theme.spacing[2] + (ICON_SIZE.sm - HOST_STATUS_DOT_SIZE) / 2),
},
// The Settings host trigger: a borderless row that fills on hover and press.
trigger: {
flexShrink: 1,
minWidth: 0,
maxWidth: "100%",
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1.5],
alignSelf: "flex-start",
paddingVertical: theme.spacing[1.5],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.surface1,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
minHeight: density.tightRowHeight,
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
},
filterTriggerHovered: {
triggerHovered: {
backgroundColor: theme.colors.surface2,
},
filterTriggerPressed: {
backgroundColor: theme.colors.surface3,
triggerPressed: {
backgroundColor: theme.colors.surface2,
},
filterTriggerText: {
label: {
flexShrink: 1,
minWidth: 0,
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.medium,
fontWeight: theme.fontWeight.normal,
},
}));
@@ -0,0 +1,47 @@
import { useCallback, useMemo, type ReactElement } from "react";
import { View } from "react-native";
import { HostStatusDot } from "@/components/host-status-dot";
import { MenuItem } from "@/components/ui/menu";
interface HostMenuItemProps {
serverId: string;
label: string;
selected: boolean;
onSelect: (serverId: string) => void;
/** Off for a multi-select page, where choosing a host is one of several toggles. */
closeOnSelect?: boolean;
testID?: string;
statusTestID?: string;
}
/** A host as a menu option: its live status dot leads, and the chosen host carries the check. */
export function HostMenuItem({
serverId,
label,
selected,
onSelect,
closeOnSelect = true,
testID,
statusTestID,
}: HostMenuItemProps): ReactElement {
const handleSelect = useCallback(() => onSelect(serverId), [onSelect, serverId]);
const leading = useMemo(
() => (
<View testID={statusTestID}>
<HostStatusDot serverId={serverId} />
</View>
),
[serverId, statusTestID],
);
return (
<MenuItem
selected={selected}
closeOnSelect={closeOnSelect}
leading={leading}
onSelect={handleSelect}
testID={testID}
>
{label}
</MenuItem>
);
}
@@ -164,6 +164,25 @@ vi.mock("@/components/adaptive-modal-sheet", () => ({
{children}
</section>
) : null,
SheetHeaderIconButton: ({
label,
onPress,
busy,
testID,
}: {
label: string;
onPress: () => void;
busy?: boolean;
testID?: string;
}) => (
<button
type="button"
aria-label={label}
disabled={busy}
onClick={onPress}
data-testid={testID}
/>
),
}));
vi.mock("react-native", async () => {
@@ -9,7 +9,11 @@ import type {
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
import { ChevronDown, Inbox, Layers, RotateCw } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import {
AdaptiveModalSheet,
SheetHeaderIconButton,
type SheetHeader,
} from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
import { ImportSessionList } from "@/components/import-session-list";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
@@ -216,36 +220,6 @@ function ProviderErrorBannerRow({
);
}
function RefreshAction({ isRefreshing, onPress }: { isRefreshing: boolean; onPress: () => void }) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const pressableStyle = useCallback(
({ pressed }: PressableStateCallbackType) => [
styles.refreshButton,
pressed && styles.refreshButtonPressed,
],
[],
);
return (
<Pressable
onPress={onPress}
disabled={isRefreshing}
accessibilityLabel={t("importSession.actions.refresh")}
accessibilityRole="button"
testID="import-session-refresh"
style={pressableStyle}
>
<View style={styles.refreshIconSlot}>
{isRefreshing ? (
<LoadingSpinner color={theme.colors.foregroundMuted} />
) : (
<RotateCw size={16} color={theme.colors.foregroundMuted} />
)}
</View>
</Pressable>
);
}
function ScopeSubtitle({
hostLabel,
isScoped,
@@ -775,7 +749,15 @@ export function ImportSessionSheet({
},
}
: {}),
actions: <RefreshAction isRefreshing={isRefreshing} onPress={handleRefresh} />,
actions: (
<SheetHeaderIconButton
icon={RotateCw}
label={t("importSession.actions.refresh")}
onPress={handleRefresh}
busy={isRefreshing}
testID="import-session-refresh"
/>
),
}),
[handleRefresh, handleShowAll, hostLabel, isRefreshing, scopeCwd, supportsSearch, t, visible],
);
@@ -1099,18 +1081,4 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.base,
textAlign: "center",
},
refreshButton: {
padding: theme.spacing[2],
marginRight: theme.spacing[1],
borderRadius: theme.borderRadius.lg,
},
refreshButtonPressed: {
backgroundColor: theme.colors.surface2,
},
refreshIconSlot: {
width: 16,
height: 16,
alignItems: "center",
justifyContent: "center",
},
}));
+12 -35
View File
@@ -35,7 +35,7 @@ import {
type AgentProfilePickerRow as AgentProfilePickerRowModel,
type AgentProfileSeed,
} from "@/agent-profiles";
import type { SheetHeader } from "@/components/adaptive-modal-sheet";
import { SheetHeaderIconButton, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
@@ -74,14 +74,13 @@ const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const ThemedPencil = withUnistyles(Pencil);
const ThemedPlus = withUnistyles(Plus);
const ThemedSearch = withUnistyles(Search);
const ThemedSettings = withUnistyles(Settings);
function ProviderSettingsAction({
accessibilityLabel,
label,
provider,
serverId,
}: {
accessibilityLabel: string;
label: string;
provider: string;
serverId: string | null;
}) {
@@ -92,17 +91,13 @@ function ProviderSettingsAction({
}, [overlayParentLayer, provider, serverId]);
return (
<Pressable
<SheetHeaderIconButton
icon={Settings}
label={label}
onPress={handlePress}
disabled={!serverId}
hitSlop={8}
style={iconButtonStyle}
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
testID={`selector-header-settings-${provider}`}
>
<HeaderSettingsIcon disabled={!serverId} />
</Pressable>
/>
);
}
@@ -188,10 +183,6 @@ const foregroundExtraMutedMapping = (theme: Theme) => ({
color: theme.colors.foregroundExtraMuted,
});
const headerSettingsMapping = (disabled: boolean) => (theme: Theme) => ({
color: disabled ? theme.colors.border : theme.colors.foregroundMuted,
});
interface ModelBrowserInput {
providers: ProviderSelectorProvider[];
selectedProvider: string;
@@ -278,11 +269,6 @@ export function ModelProviderGlyph({
return <Icon size={size} strokeWidth={strokeWidth} color={color} />;
}
function HeaderSettingsIcon({ disabled }: { disabled: boolean }) {
const uniProps = useMemo(() => headerSettingsMapping(disabled), [disabled]);
return <ThemedSettings size={ICON_SIZE.sm} uniProps={uniProps} />;
}
function iconButtonStyle({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) {
return [
styles.rowIconButton,
@@ -395,15 +381,11 @@ export function useModelBrowser({
),
back: singleProviderView ? undefined : { onPress: showAll },
actions: (
<View style={styles.headerActionRow}>
<ProviderSettingsAction
serverId={serverId}
provider={view.providerId}
accessibilityLabel={t("modelSelector.openProviderSettings", {
provider: view.providerLabel,
})}
/>
</View>
<ProviderSettingsAction
serverId={serverId}
provider={view.providerId}
label={t("modelSelector.openProviderSettings", { provider: view.providerLabel })}
/>
),
search: {
onChange: handleSearchQueryChange,
@@ -1586,11 +1568,6 @@ export function ModelBrowser({
}
const styles = StyleSheet.create((theme) => ({
headerActionRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
profilesContainer: {
backgroundColor: theme.colors.surface1,
borderBottomWidth: 1,
@@ -8,6 +8,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import {
AdaptiveModalSheet,
AdaptiveTextInput,
SheetHeaderIconButton,
type SheetHeader,
} from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
@@ -275,28 +276,10 @@ function DiagnosticSubSheet({
}
}, [visible, fetchDiagnostic]);
const refreshButtonStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
sheetStyles.iconButton,
(Boolean(hovered) || pressed) && sheetStyles.iconButtonHovered,
loading ? sheetStyles.disabled : null,
],
[loading],
);
const handleRefreshPress = useCallback(() => {
void fetchDiagnostic();
}, [fetchDiagnostic]);
const copyButtonStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
sheetStyles.iconButton,
(Boolean(hovered) || pressed) && Boolean(diagnostic) && sheetStyles.iconButtonHovered,
diagnostic ? null : sheetStyles.disabled,
],
[diagnostic],
);
const handleCopyPress = useCallback(() => {
if (!diagnostic) return;
void Clipboard.setStringAsync(diagnostic)
@@ -308,49 +291,27 @@ function DiagnosticSubSheet({
() => ({
title: t("settings.providers.diagnostic.title"),
actions: (
<View style={sheetStyles.headerActions}>
<Pressable
<>
<SheetHeaderIconButton
icon={Copy}
label={t("settings.providers.diagnostic.copyAccessibility")}
onPress={handleCopyPress}
disabled={!diagnostic}
hitSlop={8}
style={copyButtonStyle}
accessibilityRole="button"
accessibilityLabel={t("settings.providers.diagnostic.copyAccessibility")}
>
<Copy size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Pressable>
<Pressable
onPress={handleRefreshPress}
disabled={loading}
hitSlop={8}
style={refreshButtonStyle}
accessibilityRole="button"
accessibilityLabel={
/>
<SheetHeaderIconButton
icon={RotateCw}
label={
loading
? t("settings.providers.diagnostic.refreshingAccessibility")
: t("settings.providers.diagnostic.refreshAccessibility")
}
>
{loading ? (
<LoadingSpinner size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
) : (
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
)}
</Pressable>
</View>
onPress={handleRefreshPress}
busy={loading}
/>
</>
),
}),
[
copyButtonStyle,
diagnostic,
handleCopyPress,
handleRefreshPress,
loading,
refreshButtonStyle,
t,
theme.colors.foregroundMuted,
theme.iconSize.sm,
],
[diagnostic, handleCopyPress, handleRefreshPress, loading, t],
);
let body: React.ReactNode;
@@ -762,11 +723,6 @@ const sheetStyles = StyleSheet.create((theme) => ({
iconButtonHovered: {
backgroundColor: theme.colors.surface2,
},
headerActions: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
disabled: {
opacity: 0.5,
},
@@ -31,6 +31,7 @@ export { SettingsSection } from "./headings/settings-section";
export { SettingsCollapsibleRow } from "./collapsible-row";
export { SettingsDetailRow } from "./detail-row";
export { SettingsInfoTip } from "./headings/settings-info-tip";
export { MenuSettingsRows } from "./row-density";
/** What app screens can add to a plugin-facing row: an accessory beside the label, such as an info tip. */
interface AppRowExtras {
@@ -2,16 +2,27 @@ import { createContext, useContext, useMemo, type ReactNode } from "react";
import type { StyleProp, TextStyle, ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { CONTROL_HEIGHTS, mapLayout, type ControlSize } from "@/components/ui/control-geometry";
import { menuRowContentInset } from "@/components/ui/menu";
import { settingsStyles } from "@/styles/settings";
// Rows revealed under a collapsible row belong to it, so they sit one step smaller than the rows
// of the card: tighter padding, smaller text, smaller controls. The container decides; a row
// never knows on its own that it is nested.
const NestedRowsContext = createContext(false);
// The container decides how its rows lay out; a row never knows on its own where it is.
//
// - card: the rows of a settings card.
// - nested: rows revealed under a collapsible row belong to it, so they sit one step smaller than
// the rows of the card: tighter padding, smaller text, smaller controls.
// - menu: rows that share a menu surface with menu rows put their labels on the menu's label rail.
type RowPlacement = "card" | "nested" | "menu";
const RowPlacementContext = createContext<RowPlacement>("card");
export function NestedSettingsRows({ children }: { children: ReactNode }) {
return <NestedRowsContext.Provider value>{children}</NestedRowsContext.Provider>;
return <RowPlacementContext.Provider value="nested">{children}</RowPlacementContext.Provider>;
}
/** Settings rows rendered in a menu surface, alongside menu rows. */
export function MenuSettingsRows({ children }: { children: ReactNode }) {
return <RowPlacementContext.Provider value="menu">{children}</RowPlacementContext.Provider>;
}
export interface SettingsRowLayout {
@@ -22,26 +33,36 @@ export interface SettingsRowLayout {
controlSize: ControlSize;
}
/** How a row lays out where it is rendered: in a card, or nested under a collapsible row. */
function layoutFor(placement: RowPlacement): SettingsRowLayout {
switch (placement) {
case "nested":
return {
row: [settingsStyles.row, styles.nestedRow],
title: [settingsStyles.rowTitle, styles.nestedText],
hint: [settingsStyles.rowHint, styles.nestedText],
controlSize: "sm",
};
case "menu":
return {
row: [settingsStyles.row, styles.menuRow],
title: settingsStyles.rowTitle,
hint: settingsStyles.rowHint,
controlSize: "md",
};
case "card":
return {
row: settingsStyles.row,
title: settingsStyles.rowTitle,
hint: settingsStyles.rowHint,
controlSize: "md",
};
}
}
/** How a row lays out where it is rendered: in a card, nested under a collapsible row, or in a menu. */
export function useSettingsRowLayout(): SettingsRowLayout {
const nested = useContext(NestedRowsContext);
return useMemo(
() =>
nested
? {
row: [settingsStyles.row, styles.nestedRow],
title: [settingsStyles.rowTitle, styles.nestedText],
hint: [settingsStyles.rowHint, styles.nestedText],
controlSize: "sm",
}
: {
row: settingsStyles.row,
title: settingsStyles.rowTitle,
hint: settingsStyles.rowHint,
controlSize: "md",
},
[nested],
);
const placement = useContext(RowPlacementContext);
return useMemo(() => layoutFor(placement), [placement]);
}
const styles = StyleSheet.create((theme) => ({
@@ -54,4 +75,7 @@ const styles = StyleSheet.create((theme) => ({
nestedText: {
fontSize: theme.fontSize.sm,
},
menuRow: {
paddingHorizontal: menuRowContentInset(theme),
},
}));
@@ -7,7 +7,7 @@ import {
type ReactNode,
} from "react";
import { useTranslation } from "react-i18next";
import { View, type PressableStateCallbackType } from "react-native";
import type { PressableStateCallbackType } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import {
Captions,
@@ -35,7 +35,7 @@ import {
MenuTrigger,
type MenuPageDefinition,
} from "@/components/ui/menu";
import { HostStatusDot } from "@/components/host-status-dot";
import { HostMenuItem } from "@/components/hosts/host-menu-item";
import { isWeb } from "@/constants/platform";
import { useHosts } from "@/runtime/host-runtime";
import { useSidebarModel } from "@/components/sidebar/sidebar-model";
@@ -813,53 +813,21 @@ function HostFilterPage({
{t("sidebar.display.hostFilter.all")}
</MenuItem>
{hosts.map((host) => (
<HostFilterItem
<HostMenuItem
key={host.serverId}
serverId={host.serverId}
label={host.label?.trim() || host.serverId}
selected={preferences.hostFilters.includes(host.serverId)}
onToggle={preferences.toggleHostFilter}
onSelect={preferences.toggleHostFilter}
closeOnSelect={false}
testID={`sidebar-host-filter-${host.serverId}`}
statusTestID={`sidebar-host-filter-status-${host.serverId}`}
/>
))}
</>
);
}
/** 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,
@@ -127,7 +127,7 @@ export function SortableInlineList<T>({
data: T[];
keyExtractor: (item: T, index: number) => string;
renderItem: (info: DraggableRenderItemInfo<T>) => ReactElement;
onDragEnd?: (data: T[]) => void;
onDragEnd?: (data: T[], movedKey: string) => void;
useDragHandle?: boolean;
disabled?: boolean;
activationDistance?: number;
+11 -49
View File
@@ -1,16 +1,16 @@
import React, { createContext, useContext, useCallback, useMemo } from "react";
import type { ReactNode } from "react";
import { View, Text, Pressable } from "react-native";
import { View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
import { X } from "lucide-react-native";
import type { ToolCallDetail } from "@getpaseo/protocol/agent-types";
import {
IsolatedBottomSheetModal,
type ContextBridge,
useIsolatedBottomSheetVisibility,
} from "@/components/ui/isolated-bottom-sheet-modal";
import { SheetHeaderView, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { ICON_SIZE } from "@/styles/theme";
import type { ToolCallIconComponent } from "@/utils/tool-call-icon";
import { ToolCallDetailsContent } from "./tool-call-details";
@@ -66,9 +66,6 @@ function ToolCallHeaderIcon({ icon: Icon, size, color }: ToolCallHeaderIconProps
const ThemedToolCallHeaderIcon = withUnistyles(ToolCallHeaderIcon, (theme) => ({
color: theme.colors.foreground,
}));
const ThemedCloseIcon = withUnistyles(X, (theme) => ({
color: theme.colors.foregroundMuted,
}));
export const ToolCallSheetModal = withUnistyles(IsolatedBottomSheetModal, (theme) => ({
backgroundStyle: {
@@ -148,7 +145,6 @@ interface ToolCallSheetContentProps {
}
function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) {
const { t } = useTranslation();
const {
toolName,
displayName,
@@ -157,27 +153,17 @@ function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) {
icon: IconComponent,
showLoadingSkeleton,
} = data;
const header = useMemo<SheetHeader>(
() => ({
title: displayName,
leading: <ThemedToolCallHeaderIcon icon={IconComponent} size={ICON_SIZE.md} />,
}),
[IconComponent, displayName],
);
return (
<View style={styles.container}>
{/* Header */}
<View style={styles.header}>
<View style={styles.headerLeft}>
<ThemedToolCallHeaderIcon icon={IconComponent} size={20} />
<Text style={styles.headerTitle} numberOfLines={1}>
{displayName}
</Text>
</View>
<Pressable
onPress={onClose}
style={styles.closeButton}
accessibilityRole="button"
accessibilityLabel={t("common.actions.close")}
testID="tool-call-sheet-close"
>
<ThemedCloseIcon size={20} />
</Pressable>
</View>
<SheetHeaderView header={header} onClose={onClose} closeTestID="tool-call-sheet-close" />
{/* Content */}
<BottomSheetScrollView style={styles.content} contentContainerStyle={styles.contentContainer}>
@@ -200,30 +186,6 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
backgroundColor: theme.colors.surface2,
},
header: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
borderBottomWidth: theme.borderWidth[1],
borderBottomColor: theme.colors.border,
},
headerLeft: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
flex: 1,
},
headerTitle: {
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.semibold,
color: theme.colors.foreground,
flex: 1,
},
closeButton: {
padding: theme.spacing[2],
},
content: {
flex: 1,
minHeight: 0,
@@ -0,0 +1,574 @@
import React, { act, type ReactNode } from "react";
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { PortalProvider } from "@gorhom/portal";
import { View } from "react-native";
import { KeyboardProvider } from "react-native-keyboard-controller";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { RetainedPanel } from "@/components/retained-panel";
import { KeyboardShiftProvider } from "@/keyboard/shift";
import type { AutocompleteOption } from "./autocomplete";
import { AutocompletePopover } from "./autocomplete-popover";
import { FloatingPanelPortalHost } from "./floating-panel-portal";
// App sources compile against the classic JSX runtime, which expects React on the global.
beforeEach(() => vi.stubGlobal("React", React));
/**
* The popover must paint its first frame at its place. On native, Reanimated computes a style's
* first value when its hook first runs and sends later values after the mounting commit, which
* native paints: a bound computed while the popover was closed hung the popover over the composer
* for a frame on every open. The frame now mounts only with a measured anchor. This suite runs the
* web frame; the native frame was checked frame by frame on Android.
*/
const SAFE_AREA = {
frame: { x: 0, y: 0, width: 390, height: 844 },
insets: { top: 0, left: 0, right: 0, bottom: 0 },
};
// The composer rests on the dock's bottom edge and grows upward, as in the app.
const COMPOSER_BOTTOM = 124;
const OFFSET_FROM_ANCHOR = 12;
const OPTIONS: AutocompleteOption[] = ["help", "hello", "history"].map((name) => ({
id: name,
label: `/${name}`,
icon: "command",
}));
let root: Root | null = null;
let container: HTMLDivElement | null = null;
afterEach(() => {
act(() => root?.unmount());
container?.remove();
root = null;
container = null;
});
const SCREEN_STYLE = { width: 390, height: 844 };
const styleCache = new Map<string, object>();
function cachedStyle<T extends object>(key: string, create: () => T): T {
const cached = styleCache.get(key);
if (cached) return cached as T;
const style = create();
styleCache.set(key, style);
return style;
}
function composerStyle(width: number): object {
return cachedStyle(`composer:${width}`, () => ({
position: "absolute" as const,
bottom: COMPOSER_BOTTOM,
left: 16,
width,
}));
}
function inputStyle(height: number): object {
return cachedStyle(`input:${height}`, () => ({ height }));
}
function ignoreSelection(): void {}
function Providers({ children }: { children: ReactNode }): ReactNode {
return (
<SafeAreaProvider initialMetrics={SAFE_AREA}>
<KeyboardProvider>
<KeyboardShiftProvider>
<PortalProvider>{children}</PortalProvider>
</KeyboardShiftProvider>
</KeyboardProvider>
</SafeAreaProvider>
);
}
interface ScreenProps {
visible: boolean;
panelActive?: boolean;
inputHeight?: number;
composerWidth?: number;
options?: AutocompleteOption[];
/** Counted up from the best match, which is the last option. */
selectedFromBest?: number;
}
function Screen({
visible,
panelActive = true,
inputHeight = 120,
composerWidth = 358,
options = OPTIONS,
selectedFromBest = 0,
}: ScreenProps): ReactNode {
return (
<Providers>
<RetainedPanel active={panelActive}>
<View style={SCREEN_STYLE}>
<View style={composerStyle(composerWidth)}>
<AutocompletePopover
visible={visible}
options={options}
selectedIndex={options.length - 1 - selectedFromBest}
onSelect={ignoreSelection}
>
<View testID="composer-input" style={inputStyle(inputHeight)} />
</AutocompletePopover>
</View>
<FloatingPanelPortalHost />
</View>
</RetainedPanel>
</Providers>
);
}
function render(node: ReactNode): void {
if (!container) {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
}
act(() => root?.render(node));
}
/** Commits a change to the screen synchronously and lets the browser paint it. */
async function changeAndPaint(node: ReactNode): Promise<void> {
flushSync(() => root?.render(node));
await new Promise<void>((resolve) => {
requestAnimationFrame(() => {
const channel = new MessageChannel();
channel.port1.addEventListener("message", () => resolve(), { once: true });
channel.port1.start();
channel.port2.postMessage(null);
});
});
}
function rectOf(testID: string): DOMRect | null {
return document.querySelector(`[data-testid="${testID}"]`)?.getBoundingClientRect() ?? null;
}
/** Where the popover sits against the composer, or null when it is not painted. */
function placementAgainstComposer(): { gap: number; x: number; width: number } | null {
const popover = rectOf("composer-autocomplete-popover");
const composer = rectOf("composer-input");
if (!popover || !composer) return null;
return {
gap: Math.round(composer.top - popover.bottom),
x: Math.round(popover.x - composer.x),
width: Math.round(popover.width - composer.width),
};
}
async function openSettled(props: Omit<ScreenProps, "visible"> = {}): Promise<void> {
render(<Screen visible={false} {...props} />);
render(<Screen visible {...props} />);
await waitForPopover();
await new Promise((resolve) => setTimeout(resolve, 200));
expect(placementAgainstComposer()).toEqual({ gap: OFFSET_FROM_ANCHOR, x: 0, width: 0 });
}
/** Every max-height the popover's frame has held, starting with the one it mounted with. */
function recordPopoverMaxHeights(): { values: string[]; stop: () => void } {
const values: string[] = [];
const observer = new MutationObserver((records) => {
for (const record of records) {
if (record.type === "childList") {
for (const node of record.addedNodes) {
if (!(node instanceof HTMLElement)) continue;
const popover = node.matches('[data-testid="composer-autocomplete-popover"]')
? node
: node.querySelector<HTMLElement>('[data-testid="composer-autocomplete-popover"]');
// The attribute records below carry what the style was before each change; this
// carries the style at insertion when nothing changed it afterwards.
if (popover && values.length === 0) values.push(popover.style.maxHeight);
}
}
if (
record.type === "attributes" &&
record.target instanceof HTMLElement &&
record.target.dataset.testid === "composer-autocomplete-popover"
) {
const before = /max-height:\s*([^;]+)/.exec(record.oldValue ?? "")?.[1]?.trim() ?? "";
if (values.length <= 1) values.splice(0, values.length, before);
values.push(record.target.style.maxHeight);
}
}
});
observer.observe(document.body, {
subtree: true,
childList: true,
attributes: true,
attributeFilter: ["style"],
attributeOldValue: true,
});
return { values, stop: () => observer.disconnect() };
}
async function waitForPopover(): Promise<HTMLElement> {
for (let attempt = 0; attempt < 100; attempt += 1) {
const popover = document.querySelector<HTMLElement>(
'[data-testid="composer-autocomplete-popover"]',
);
if (popover) return popover;
await new Promise((resolve) => setTimeout(resolve, 20));
}
throw new Error("The popover did not open");
}
it("opens with its height bound instead of the bound it had while closed", async () => {
// The composer mounts first and opens the popover later, as typing `/` does.
render(<Screen visible={false} />);
const recording = recordPopoverMaxHeights();
render(<Screen visible />);
const popover = await waitForPopover();
await new Promise((resolve) => setTimeout(resolve, 200));
recording.stop();
const settled = popover.style.maxHeight;
expect(Number.parseFloat(settled)).toBeGreaterThan(0);
expect(recording.values[0]).toBe(settled);
});
/**
* The popover lives in a portal and copies the composer's rect. A copy taken after the composer's
* layout paints the composer at its new size against the popover's old place: the popover overlapped
* a composer that grew a line, floated above one that shrank, and kept the old width after a resize.
*/
it("moves with the composer in the frame the composer grows", async () => {
await openSettled();
await changeAndPaint(<Screen visible inputHeight={160} />);
expect(placementAgainstComposer()).toEqual({ gap: OFFSET_FROM_ANCHOR, x: 0, width: 0 });
});
it("follows the composer down when its text clears before the popover closes", async () => {
// The input relayouts on the cleared text before the autocomplete hides the popover.
await openSettled({ inputHeight: 160 });
await changeAndPaint(<Screen visible inputHeight={120} />);
expect(placementAgainstComposer()).toEqual({ gap: OFFSET_FROM_ANCHOR, x: 0, width: 0 });
});
it("matches the composer's width in the frame the composer resizes", async () => {
await openSettled();
await changeAndPaint(<Screen visible composerWidth={240} />);
expect(placementAgainstComposer()).toEqual({ gap: OFFSET_FROM_ANCHOR, x: 0, width: 0 });
});
/**
* A retained workspace stays mounted while another route shows. A popover left open there tracked a
* composer it could not measure, and on native stopped following the composer after the user came
* back.
*/
it("closes while its retained panel is hidden and reopens in place when it shows again", async () => {
await openSettled();
render(<Screen visible panelActive={false} />);
expect(rectOf("composer-autocomplete-popover")).toBeNull();
render(<Screen visible />);
await waitForPopover();
await new Promise((resolve) => setTimeout(resolve, 200));
expect(placementAgainstComposer()).toEqual({ gap: OFFSET_FROM_ANCHOR, x: 0, width: 0 });
});
/** As many commands as a workspace with skills installed lists; the best match is last. */
function commandOptions(prefix: string, count: number): AutocompleteOption[] {
return Array.from({ length: count }, (_, index) => ({
id: `${prefix}-${count - index}`,
label: `/${prefix}-${count - index}`,
icon: "skill" as const,
description: `Description of ${prefix} ${count - index}`,
tag: "Skill",
}));
}
const LIST_PADDING_AND_BORDER = 5;
const ROW_HEIGHT_AT_LEAST = 36;
/** How far the best match's row sits above the popover's bottom edge, or null when not painted. */
function bestMatchAgainstPopoverBottom(label: string): number | null {
const popover = rectOf("composer-autocomplete-popover");
const row = document.querySelector(`[aria-label^="${label},"]`)?.getBoundingClientRect();
if (!popover) return null;
if (!row) return Number.NaN;
return Math.round(popover.bottom - row.bottom);
}
/** Samples a reading after every paint until stopped. */
function recordPaintedFrames<T>(read: () => T): { frames: T[]; stop: () => void } {
const frames: T[] = [];
let running = true;
const channel = new MessageChannel();
channel.port1.addEventListener("message", () => {
if (running) frames.push(read());
});
channel.port1.start();
const tick = (): void => {
if (!running) return;
channel.port2.postMessage(null);
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
return {
frames,
stop: () => {
running = false;
},
};
}
/**
* The list is longer than the popover and reads upward from its best match, which sits against the
* bottom edge. Scrolling there after the list mounted painted the list at its old offset first: a
* few pixels short on open, a different stretch of the list when the results changed.
*/
it("paints a long list with its best match against the bottom from the first frame", async () => {
const options = commandOptions("skill", 100);
render(<Screen visible={false} options={options} />);
const recording = recordPaintedFrames(() => bestMatchAgainstPopoverBottom("/skill-1"));
render(<Screen visible options={options} />);
await waitForPopover();
await new Promise((resolve) => setTimeout(resolve, 300));
recording.stop();
const painted = recording.frames.filter((frame) => frame !== null);
expect(painted.length).toBeGreaterThan(0);
expect([...new Set(painted)]).toEqual([LIST_PADDING_AND_BORDER]);
});
it("keeps the best match against the bottom in the frame the results change", async () => {
const all = commandOptions("skill", 100);
const fewer = all.filter((_, index) => index % 3 === 0);
render(<Screen visible={false} options={all} />);
render(<Screen visible options={all} />);
await waitForPopover();
await new Promise((resolve) => setTimeout(resolve, 300));
// Narrowing keeps `/skill-1` the best match; widening brings the rest back above it.
const recording = recordPaintedFrames(() => bestMatchAgainstPopoverBottom("/skill-1"));
await changeAndPaint(<Screen visible options={fewer} />);
await new Promise((resolve) => setTimeout(resolve, 200));
await changeAndPaint(<Screen visible options={all} />);
await new Promise((resolve) => setTimeout(resolve, 300));
recording.stop();
expect([...new Set(recording.frames)]).toEqual([LIST_PADDING_AND_BORDER]);
});
function mountedRows(prefix = "skill"): Element[] {
return [...document.querySelectorAll(`[aria-label^="/${prefix}-"]`)];
}
/**
* Every result used to mount before the popover's first frame, so a workspace with a hundred
* commands waited on a hundred rows to see six.
*/
it("mounts the rows nearest the composer first, however many results there are", async () => {
const options = commandOptions("skill", 500);
render(<Screen visible={false} options={options} />);
const recording = recordPaintedFrames(() =>
rectOf("composer-autocomplete-popover") ? mountedRows().length : null,
);
render(<Screen visible options={options} />);
await waitForPopover();
await new Promise((resolve) => setTimeout(resolve, 100));
recording.stop();
const firstFrame = recording.frames.find((frame) => frame !== null);
expect(firstFrame).toBeGreaterThan(0);
expect(firstFrame).toBeLessThan(20);
expect(bestMatchAgainstPopoverBottom("/skill-1")).toBe(LIST_PADDING_AND_BORDER);
});
/**
* A list that had mounted its whole window kept that window for the next results, so narrowing a
* long list to another long one waited on ten times the rows the popover shows.
*/
it("mounts the rows nearest the composer first when the results change", async () => {
const commands = commandOptions("skill", 500);
const matches = commandOptions("plugin", 300);
render(<Screen visible={false} options={commands} />);
render(<Screen visible options={commands} />);
await waitForPopover();
await expect.poll(() => mountedRows().length, { timeout: 3000 }).toBeGreaterThan(40);
const recording = recordPaintedFrames(() => mountedRows("plugin").length);
await changeAndPaint(<Screen visible options={matches} />);
recording.stop();
const firstFrame = recording.frames.find((count) => count > 0);
expect(firstFrame).toBeLessThan(20);
expect(bestMatchAgainstPopoverBottom("/plugin-1")).toBe(LIST_PADDING_AND_BORDER);
});
function scrollerOf(popover: HTMLElement): HTMLElement {
const scroller = [...popover.querySelectorAll<HTMLElement>("*")].find(
(element) =>
element.scrollHeight > element.clientHeight + 1 &&
["auto", "scroll"].includes(getComputedStyle(element).overflowY),
);
if (!scroller) throw new Error("The list does not scroll");
return scroller;
}
/**
* Native paints a scroll command a frame or two after the rows it was meant for. A list that rests
* against the composer only because script scrolled it there shows the rows somewhere else first,
* so the composer's edge is where the list's scroll starts.
*/
it("rests against the composer where its scroll starts", async () => {
const all = commandOptions("skill", 100);
const fewer = all.filter((_, index) => index % 3 === 0);
render(<Screen visible={false} options={all} />);
render(<Screen visible options={all} />);
const popover = await waitForPopover();
await new Promise((resolve) => setTimeout(resolve, 300));
expect(scrollerOf(popover).scrollTop).toBe(0);
await changeAndPaint(<Screen visible options={fewer} />);
await new Promise((resolve) => setTimeout(resolve, 200));
expect(scrollerOf(popover).scrollTop).toBe(0);
await changeAndPaint(<Screen visible options={all} />);
await new Promise((resolve) => setTimeout(resolve, 300));
expect(scrollerOf(popover).scrollTop).toBe(0);
expect(bestMatchAgainstPopoverBottom("/skill-1")).toBe(LIST_PADDING_AND_BORDER);
});
it("returns to the best match in the frame results arrive while the list is scrolled away", async () => {
const all = commandOptions("skill", 100);
const fewer = all.filter((_, index) => index % 3 === 0);
render(<Screen visible={false} options={all} />);
render(<Screen visible options={all} />);
const popover = await waitForPopover();
await new Promise((resolve) => setTimeout(resolve, 300));
scrollerOf(popover).scrollTop = 400;
await new Promise((resolve) => setTimeout(resolve, 200));
expect(bestMatchAgainstPopoverBottom("/skill-1")).not.toBe(LIST_PADDING_AND_BORDER);
const recording = recordPaintedFrames(() => bestMatchAgainstPopoverBottom("/skill-1"));
await changeAndPaint(<Screen visible options={fewer} />);
await new Promise((resolve) => setTimeout(resolve, 200));
recording.stop();
expect([...new Set(recording.frames)]).toEqual([LIST_PADDING_AND_BORDER]);
});
/**
* The composer hands over a new list on every keystroke and every refetch, often holding the results
* it already showed. Starting over for those returned a list the user had scrolled to its rest.
*/
it("keeps its place when the same results arrive again", async () => {
const options = commandOptions("skill", 100);
render(<Screen visible={false} options={options} />);
render(<Screen visible options={options} />);
const popover = await waitForPopover();
await new Promise((resolve) => setTimeout(resolve, 300));
scrollerOf(popover).scrollTop = 400;
await new Promise((resolve) => setTimeout(resolve, 200));
const scrolledAway = bestMatchAgainstPopoverBottom("/skill-1");
expect(scrolledAway).not.toBe(LIST_PADDING_AND_BORDER);
const recording = recordPaintedFrames(() => bestMatchAgainstPopoverBottom("/skill-1"));
await changeAndPaint(<Screen visible options={commandOptions("skill", 100)} />);
await new Promise((resolve) => setTimeout(resolve, 200));
recording.stop();
expect([...new Set(recording.frames)]).toEqual([scrolledAway]);
});
/** Whether the row is wholly inside the popover's box. */
function isRowInView(label: string): boolean {
const popover = rectOf("composer-autocomplete-popover");
const row = document.querySelector(`[aria-label^="${label},"]`)?.getBoundingClientRect();
if (!popover || !row) return false;
return row.top >= popover.top - 0.5 && row.bottom <= popover.bottom + 0.5;
}
it("reveals the row the arrow keys select, including across the wrap to the far end", async () => {
const options = commandOptions("skill", 100);
render(<Screen visible={false} options={options} />);
render(<Screen visible options={options} />);
await waitForPopover();
await new Promise((resolve) => setTimeout(resolve, 300));
for (const selectedFromBest of [1, 5, 6, 7, 12, 6, 0, 99, 98, 93, 0]) {
render(<Screen visible options={options} selectedFromBest={selectedFromBest} />);
// Rows beyond the mounted ones come into view as they mount.
await expect
.poll(() => [selectedFromBest, isRowInView(`/skill-${selectedFromBest + 1}`)], {
timeout: 3000,
})
.toEqual([selectedFromBest, true]);
}
await expect.poll(() => bestMatchAgainstPopoverBottom("/skill-1")).toBe(LIST_PADDING_AND_BORDER);
});
/** The composer grew until the space above it holds fewer rows than the popover was showing. */
it("keeps the selected row in view when the popover gets shorter", async () => {
const options = commandOptions("skill", 100);
render(<Screen visible={false} options={options} />);
render(<Screen visible options={options} />);
await waitForPopover();
await new Promise((resolve) => setTimeout(resolve, 300));
// Beyond the rows the popover opened with, so it came into view at the popover's top edge.
render(<Screen visible options={options} selectedFromBest={7} />);
await expect.poll(() => isRowInView("/skill-8"), { timeout: 3000 }).toBe(true);
const height = rectOf("composer-autocomplete-popover")!.height;
render(<Screen visible options={options} selectedFromBest={7} inputHeight={560} />);
await expect
.poll(() => rectOf("composer-autocomplete-popover")!.height, { timeout: 3000 })
.toBeLessThan(height - ROW_HEIGHT_AT_LEAST);
await expect.poll(() => isRowInView("/skill-8"), { timeout: 3000 }).toBe(true);
});
/** The label of the row at the top of the popover's box. */
function topRowInView(): string | null {
const popover = rectOf("composer-autocomplete-popover");
if (!popover) return null;
const rows = mountedRows()
.map((row) => ({ rect: row.getBoundingClientRect(), label: row.getAttribute("aria-label") }))
.filter(({ rect }) => rect.top >= popover.top - 0.5 && rect.bottom <= popover.bottom + 0.5)
.toSorted((a, b) => a.rect.top - b.rect.top);
return rows[0]?.label?.split(",")[0] ?? null;
}
/**
* The far end of a long list is not mounted while the list rests against the composer. Scrolling to
* it as rows mounted painted the list at several places on the way.
*/
it("jumps between the two ends in one frame when the arrow keys wrap", async () => {
const options = commandOptions("skill", 100);
render(<Screen visible={false} options={options} />);
render(<Screen visible options={options} />);
await waitForPopover();
await new Promise((resolve) => setTimeout(resolve, 300));
const restingAtComposer = topRowInView();
const toFarEnd = recordPaintedFrames(topRowInView);
await changeAndPaint(<Screen visible options={options} selectedFromBest={99} />);
await new Promise((resolve) => setTimeout(resolve, 400));
toFarEnd.stop();
expect([...new Set(toFarEnd.frames)]).toEqual(["/skill-100"]);
expect(isRowInView("/skill-100")).toBe(true);
const toComposer = recordPaintedFrames(topRowInView);
await changeAndPaint(<Screen visible options={options} selectedFromBest={0} />);
await new Promise((resolve) => setTimeout(resolve, 400));
toComposer.stop();
expect([...new Set(toComposer.frames)]).toEqual([restingAtComposer]);
expect(bestMatchAgainstPopoverBottom("/skill-1")).toBe(LIST_PADDING_AND_BORDER);
});
it("scrolls towards rows further from the composer when the wheel turns up", async () => {
const options = commandOptions("skill", 100);
render(<Screen visible={false} options={options} />);
render(<Screen visible options={options} />);
const popover = await waitForPopover();
await new Promise((resolve) => setTimeout(resolve, 300));
const row = document.querySelector('[aria-label^="/skill-1,"]');
row?.dispatchEvent(new WheelEvent("wheel", { deltaY: -120, bubbles: true, cancelable: true }));
await new Promise((resolve) => setTimeout(resolve, 200));
// The best match moved down, out of the popover, by the distance the wheel turned.
expect(bestMatchAgainstPopoverBottom("/skill-1")).toBe(LIST_PADDING_AND_BORDER - 120);
expect(isRowInView("/skill-7")).toBe(true);
expect(popover.getBoundingClientRect().bottom).toBe(rectOf("composer-input")!.top - 12);
});
@@ -1,15 +1,13 @@
import { useCallback, useEffect, useRef, useState, type ReactElement, type ReactNode } from "react";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ReactElement,
type ReactNode,
} from "react";
import { View, useWindowDimensions, type StyleProp, type ViewStyle } from "react-native";
StyleSheet,
View,
useWindowDimensions,
type StyleProp,
type ViewStyle,
} from "react-native";
import { Portal } from "@gorhom/portal";
import Animated, { useAnimatedReaction, useAnimatedStyle } from "react-native-reanimated";
import { useAnimatedReaction, useAnimatedRef } from "react-native-reanimated";
import { scheduleOnRN } from "react-native-worklets";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import {
@@ -21,18 +19,10 @@ import {
measureFloatingPanelPortalHost,
useFloatingPanelPortalHostName,
} from "@/components/ui/floating-panel-portal";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { KeyboardTranslateView, useKeyboardAnchor, useKeyboardShift } from "@/keyboard/shift";
import { SPACING } from "@/styles/theme";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
const OFFSET_FROM_ANCHOR = SPACING[3];
interface RelativeAnchorRect {
x: number;
y: number;
width: number;
hostHeight: number;
}
import { PopoverFrame } from "./internal/frame";
import type { AnchorAtRest } from "./internal/placement";
interface AutocompletePopoverProps {
visible: boolean;
@@ -55,8 +45,16 @@ export function AutocompletePopover({
}: AutocompletePopoverProps): ReactElement | null {
"use no memo";
// React Compiler memoizes effect captures by reading SharedValue.value during render.
const anchorRef = useRef<View>(null);
const [relativeAnchorRect, setRelativeAnchorRect] = useState<RelativeAnchorRect | null>(null);
const anchorRef = useAnimatedRef<View>();
// The animated ref returns its native handle, which React 19 would call as a ref cleanup.
// The anchor stays a plain View: its style is the caller's Unistyles style.
const setAnchorElement = useCallback(
(element: View | null) => {
anchorRef(element);
},
[anchorRef],
);
const [anchorAtRest, setAnchorAtRest] = useState<AnchorAtRest | null>(null);
const windowDimensions = useWindowDimensions();
const safeAreaInsets = useSafeAreaInsets();
const portalHostName = useFloatingPanelPortalHostName();
@@ -64,7 +62,9 @@ export function AutocompletePopover({
const anchor = useKeyboardAnchor();
const { measureAtRest } = anchor;
const measurementGeneration = useRef(0);
const canMeasure = visible && (options.length === 0 || selectedIndex >= 0);
// A retained panel stays mounted while hidden; its anchor cannot be measured until it shows.
const isPanelActive = useRetainedPanelActive();
const canMeasure = isPanelActive && visible && (options.length === 0 || selectedIndex >= 0);
const remeasure = useCallback(() => {
if (!canMeasure) return;
@@ -78,10 +78,11 @@ export function AutocompletePopover({
if (generation !== measurementGeneration.current || !anchorRect || !hostRect) {
return undefined;
}
setRelativeAnchorRect({
setAnchorAtRest({
x: anchorRect.x - hostRect.x,
y: anchorRect.y - hostRect.y,
width: anchorRect.width,
height: anchorRect.height,
hostHeight: hostRect.height,
});
return undefined;
@@ -91,7 +92,7 @@ export function AutocompletePopover({
useEffect(() => {
measurementGeneration.current += 1;
if (!canMeasure) {
setRelativeAnchorRect(null);
setAnchorAtRest(null);
return;
}
@@ -114,47 +115,32 @@ export function AutocompletePopover({
[isMoving, remeasure],
);
// Lay out at the anchor's resting position; the KeyboardTranslateView below lifts
// it with the anchor. Height changes only at keyboard boundaries.
const baseStyle = useMemo(() => {
if (!relativeAnchorRect) return null;
return inlineUnistylesStyle({
position: "absolute" as const,
left: relativeAnchorRect.x,
width: relativeAnchorRect.width,
bottom: relativeAnchorRect.hostHeight - relativeAnchorRect.y + OFFSET_FROM_ANCHOR,
});
}, [relativeAnchorRect]);
const anchorY = relativeAnchorRect?.y ?? 0;
const reservedLift = anchor.reservedLift;
const heightStyle = useAnimatedStyle(
() => ({
maxHeight: Math.max(
0,
anchorY - reservedLift.value - safeAreaInsets.top - OFFSET_FROM_ANCHOR * 2,
),
}),
[anchorY, reservedLift, safeAreaInsets.top],
);
const showPopover = canMeasure && relativeAnchorRect && baseStyle;
// The translate view is the popover's own frame, so it lifts only the popover.
// The layer covers the host and rides the anchor's keyboard translation; the frame inside it
// places the popover. A layer that spans the host keeps the popover inside its parent's bounds,
// which Android hit-testing requires.
return (
<View ref={anchorRef} onLayout={remeasure} style={anchorStyle} collapsable={false}>
<View ref={setAnchorElement} onLayout={remeasure} style={anchorStyle} collapsable={false}>
{children}
{showPopover ? (
{canMeasure && anchorAtRest ? (
<Portal hostName={portalHostName}>
<KeyboardTranslateView {...anchor.translation} style={baseStyle}>
<Animated.View testID="composer-autocomplete-popover" style={heightStyle}>
<KeyboardTranslateView
{...anchor.translation}
style={StyleSheet.absoluteFill}
pointerEvents="box-none"
>
<PopoverFrame
anchorRef={anchorRef}
anchorAtRest={anchorAtRest}
reservedLift={anchor.reservedLift}
topInset={safeAreaInsets.top}
>
<Autocomplete
options={options}
selectedIndex={selectedIndex}
onSelect={onSelect}
status={status}
/>
</Animated.View>
</PopoverFrame>
</KeyboardTranslateView>
</Portal>
) : null}
@@ -0,0 +1,68 @@
import { useCallback, type ReactElement } from "react";
import Animated, {
measure,
useAnimatedStyle,
useFrameCallback,
useSharedValue,
} from "react-native-reanimated";
import { placeAboveAnchor, type PopoverFrameProps } from "./placement";
/**
* The composer grows upward from a fixed bottom edge as its text wraps. Its height is read on the
* UI thread every frame, so the popover moves in the frame the composer's new layout mounts; an
* asynchronous measurement through JS painted the composer over or away from the popover first.
*
* The frame mounts only with a measured anchor. Reanimated computes the first style when the hook
* first runs and sends later values after the mounting commit, which native paints: a placement
* computed before the measurement painted the popover over the composer for a frame on every open.
*/
export function PopoverFrame({
anchorRef,
anchorAtRest,
reservedLift,
topInset,
children,
}: PopoverFrameProps): ReactElement {
"use no memo";
// React Compiler memoizes effect captures by reading SharedValue.value during render.
const anchorHeight = useSharedValue(anchorAtRest.height);
const anchorLost = useSharedValue(false);
const readAnchorHeight = useCallback(() => {
"worklet";
if (anchorLost.value) return;
const measured = measure(anchorRef);
if (!measured) {
// The anchor left the shadow tree, usually as the composer unmounts; the portal removes the
// frame a commit later. Each failed measure() logs a warning, and a busy JS thread holds
// that commit for many frames, so stop measuring and follow the rest measurement.
anchorLost.value = true;
return;
}
if (measured.height !== anchorHeight.value) {
anchorHeight.value = measured.height;
}
}, [anchorHeight, anchorLost, anchorRef]);
useFrameCallback(readAnchorHeight);
const style = useAnimatedStyle(() => {
const anchorBottom = anchorAtRest.y + anchorAtRest.height;
const liveHeight = anchorLost.value ? anchorAtRest.height : anchorHeight.value;
return {
position: "absolute",
left: anchorAtRest.x,
width: anchorAtRest.width,
...placeAboveAnchor({
anchorTop: anchorBottom - liveHeight,
hostHeight: anchorAtRest.hostHeight,
reservedLift: reservedLift.value,
topInset,
}),
};
}, [anchorAtRest, anchorHeight, anchorLost, reservedLift, topInset]);
return (
<Animated.View testID="composer-autocomplete-popover" style={style}>
{children}
</Animated.View>
);
}
@@ -0,0 +1,68 @@
import { useLayoutEffect, useRef, useState, type ReactElement } from "react";
import { flushSync } from "react-dom";
import { View } from "react-native";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import { placeAboveAnchor, type AnchorAtRest, type PopoverFrameProps } from "./placement";
type AnchorRect = Omit<AnchorAtRest, "height">;
function rectWithin(anchor: HTMLElement, host: HTMLElement): AnchorRect {
const anchorRect = anchor.getBoundingClientRect();
const hostRect = host.getBoundingClientRect();
return {
x: anchorRect.x - hostRect.x,
y: anchorRect.y - hostRect.y,
width: anchorRect.width,
hostHeight: hostRect.height,
};
}
/**
* The composer resizes as its text wraps and as the window resizes. ResizeObserver callbacks run
* after layout and before paint, so committing the new placement synchronously there paints the
* popover against the composer's new rect in the same frame.
*/
export function PopoverFrame({
anchorRef,
anchorAtRest,
reservedLift,
topInset,
children,
}: PopoverFrameProps): ReactElement {
"use no memo";
// React Compiler memoizes effect captures by reading SharedValue.value during render.
const frameRef = useRef<View>(null);
const [anchor, setAnchor] = useState<AnchorRect>(anchorAtRest);
useLayoutEffect(() => {
const anchorElement = anchorRef.current as unknown as HTMLElement | null;
// The frame's parent is the portal layer, which covers the host.
const host = (frameRef.current as unknown as HTMLElement | null)?.parentElement;
if (!anchorElement || !host) return undefined;
setAnchor(rectWithin(anchorElement, host));
const observer = new ResizeObserver(() => {
flushSync(() => setAnchor(rectWithin(anchorElement, host)));
});
observer.observe(anchorElement);
observer.observe(host);
return () => observer.disconnect();
}, [anchorAtRest, anchorRef]);
const style = inlineUnistylesStyle({
position: "absolute" as const,
left: anchor.x,
width: anchor.width,
...placeAboveAnchor({
anchorTop: anchor.y,
hostHeight: anchor.hostHeight,
reservedLift: reservedLift.value,
topInset,
}),
});
return (
<View ref={frameRef} testID="composer-autocomplete-popover" style={style}>
{children}
</View>
);
}
@@ -0,0 +1,41 @@
import type { ReactNode } from "react";
import type { View } from "react-native";
import type { AnimatedRef, DerivedValue } from "react-native-reanimated";
import { SPACING } from "@/styles/theme";
const OFFSET_FROM_ANCHOR = SPACING[3];
/** The anchor's resting rect, relative to the portal host the popover renders in. */
export interface AnchorAtRest {
x: number;
y: number;
width: number;
height: number;
hostHeight: number;
}
export interface PopoverFrameProps {
anchorRef: AnimatedRef<View>;
/** Taken asynchronously; the frame follows the anchor's live size between measurements. */
anchorAtRest: AnchorAtRest;
reservedLift: DerivedValue<number>;
topInset: number;
children: ReactNode;
}
/** Places the popover above an anchor whose top edge is `anchorTop` within the host. */
export function placeAboveAnchor(input: {
anchorTop: number;
hostHeight: number;
reservedLift: number;
topInset: number;
}): { bottom: number; maxHeight: number } {
"worklet";
return {
bottom: input.hostHeight - input.anchorTop + OFFSET_FROM_ANCHOR,
maxHeight: Math.max(
0,
input.anchorTop - input.reservedLift - input.topInset - OFFSET_FROM_ANCHOR * 2,
),
};
}
+217 -122
View File
@@ -1,13 +1,12 @@
import { useCallback, useEffect, useMemo, useRef } from "react";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
ScrollView,
FlatList,
Text,
View,
Pressable,
type LayoutChangeEvent,
type NativeScrollEvent,
type NativeSyntheticEvent,
type ListRenderItemInfo,
type PressableStateCallbackType,
type ViewToken,
} from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
@@ -29,7 +28,6 @@ import { Button } from "./button";
import { HighlightedText } from "./highlighted-text";
import { mutedIconColorMapping } from "./icon-color";
import { LoadingSpinner } from "./loading-spinner";
import { getScrollOffsetToRevealItem } from "./scroll-reveal";
import { StatusBadge } from "./status-badge";
export type AutocompleteOptionIcon =
@@ -80,6 +78,8 @@ const ErrorIcon = withUnistyles(CircleAlert, mutedIconColorMapping);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner, mutedIconColorMapping);
const ROW_ICON_SIZE = 14;
const ROW_MIN_HEIGHT = 36;
const DEFAULT_MAX_HEIGHT = 220;
const BOLT_GLYPH_PATTERN = /⚡|️/gu;
@@ -92,21 +92,17 @@ function removeBoltGlyphs(value?: string): string | undefined {
}
interface AutocompleteRowProps {
index: number;
option: AutocompleteOption;
isSelected: boolean;
showArgumentHint: boolean;
onSelect: (option: AutocompleteOption) => void;
onRowLayout: (index: number, event: LayoutChangeEvent) => void;
}
function AutocompleteRow({
index,
const AutocompleteRow = memo(function AutocompleteRow({
option,
isSelected,
showArgumentHint,
onSelect,
onRowLayout,
}: AutocompleteRowProps) {
const label = removeBoltGlyphs(option.label) ?? option.label;
const description = removeBoltGlyphs(option.description);
@@ -114,10 +110,6 @@ function AutocompleteRow({
const Icon = ROW_ICONS[option.icon];
const labelMatches = label === option.label ? option.labelMatches : undefined;
const handleLayout = useCallback(
(event: LayoutChangeEvent) => onRowLayout(index, event),
[index, onRowLayout],
);
const handlePress = useCallback(() => onSelect(option), [onSelect, option]);
const pressableStyle = useCallback(
({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
@@ -129,7 +121,6 @@ function AutocompleteRow({
return (
<Pressable
onLayout={handleLayout}
onPress={handlePress}
style={pressableStyle}
// Name and description only; the hint and tag repeat what the name implies.
@@ -155,7 +146,7 @@ function AutocompleteRow({
{option.tag ? <StatusBadge label={option.tag} size="xs" /> : null}
</Pressable>
);
}
});
function AutocompleteStatusRow({ status }: { status: AutocompleteStatus }) {
const { t } = useTranslation();
@@ -202,94 +193,212 @@ function AutocompleteStatusRow({ status }: { status: AutocompleteStatus }) {
);
}
type ListEnd = "composer" | "far";
/**
* The scroller a list is shown in: one for each result set, resting at one end of it. A scroller
* starts at its resting end with only the rows nearest it, so the list opens against the composer
* and reaches the far end when the selection wraps there without mounting the rows in between.
* A scroller that outlived its results would keep its scroll position and its mounted rows: on
* native a scroll command reaches the screen after the rows it was meant for.
*/
function useScroller(
options: readonly AutocompleteOption[],
selectedIndex: number,
): { key: string; restsAt: ListEnd } {
const [scroller, setScroller] = useState<{
options: readonly AutocompleteOption[];
resultSet: number;
restsAt: ListEnd;
}>({ options, resultSet: 0, restsAt: "composer" });
let next = scroller;
if (next.options !== options) {
next = isSameResultSet(next.options, options)
? { ...next, options }
: { options, resultSet: next.resultSet + 1, restsAt: "composer" };
}
const restsAt = endSelected(options, selectedIndex) ?? next.restsAt;
if (restsAt !== next.restsAt) {
next = { ...next, restsAt };
}
if (next !== scroller) {
setScroller(next);
}
return { key: `${next.resultSet}:${next.restsAt}`, restsAt: next.restsAt };
}
/** The composer builds a new list on every keystroke and refetch, often of the results it had. */
function isSameResultSet(
shown: readonly AutocompleteOption[],
arrived: readonly AutocompleteOption[],
): boolean {
return (
shown.length === arrived.length && shown.every((option, row) => option.id === arrived[row].id)
);
}
/** The end of the list the selection sits at, if it sits at one. The best match is last. */
function endSelected(
options: readonly AutocompleteOption[],
selectedIndex: number,
): ListEnd | null {
if (selectedIndex === options.length - 1) return "composer";
if (selectedIndex === 0) return "far";
return null;
}
const FULLY_VISIBLE = { itemVisiblePercentThreshold: 100 };
function ListPadding() {
return <View style={styles.listPadding} />;
}
function keyOfOption(option: AutocompleteOption): string {
return option.id;
}
interface AutocompleteRowsProps {
/** Nearest the resting end first. */
rows: readonly AutocompleteOption[];
selectedRow: number;
restsAt: ListEnd;
maxHeight: number;
onSelect: (option: AutocompleteOption) => void;
}
/**
* The rows, read from the end the list rests at. Against the composer the list is inverted. Either
* way its resting scroll position is that end and the rows nearest it mount first, so a list of any
* length opens where it rests.
*/
function AutocompleteRows({
rows,
selectedRow,
restsAt,
maxHeight,
onSelect,
}: AutocompleteRowsProps) {
const isCompact = useIsCompactFormFactor();
const listRef = useRef<FlatList<AutocompleteOption>>(null);
const fullyVisibleRows = useRef<{ first: number; last: number } | null>(null);
// The row the keyboard selected, until it has been brought into the viewport. The row at the
// other end is never revealed here: selecting it gives the list a scroller that rests there.
const pendingReveal = useRef<number | null>(null);
const isPendingRowMeasured = useRef(true);
const revealPendingRow = useCallback(() => {
const list = listRef.current;
const row = pendingReveal.current;
const visible = fullyVisibleRows.current;
if (!list || row === null || !visible) return;
const span = visible.last - visible.first;
if (row >= visible.first && row <= visible.last) {
pendingReveal.current = null;
} else if (row === 0) {
list.scrollToOffset({ offset: 0, animated: false });
pendingReveal.current = null;
fullyVisibleRows.current = { first: 0, last: span };
} else {
// The row comes in at the edge it was beyond.
const isNearer = row < visible.first;
isPendingRowMeasured.current = true;
list.scrollToIndex({ index: row, viewPosition: isNearer ? 0 : 1, animated: false });
// A row that has not mounted has no position yet: the list was sent towards it instead, and
// is asked again as rows mount.
if (!isPendingRowMeasured.current) return;
pendingReveal.current = null;
// The list reports what it shows after it has scrolled; the next key press may come first.
fullyVisibleRows.current = isNearer
? { first: row, last: row + span }
: { first: row - span, last: row };
}
}, []);
const handleScrollToIndexFailed = useCallback(
(info: { index: number; averageItemLength: number }) => {
isPendingRowMeasured.current = false;
listRef.current?.scrollToOffset({
offset: info.index * info.averageItemLength,
animated: false,
});
},
[],
);
useEffect(() => {
pendingReveal.current = selectedRow < 0 ? null : selectedRow;
revealPendingRow();
}, [revealPendingRow, selectedRow]);
const handleViewableItemsChanged = useCallback(
({ viewableItems }: { viewableItems: ViewToken<AutocompleteOption>[] }) => {
const indexes = viewableItems.flatMap((token) => (token.index === null ? [] : [token.index]));
if (indexes.length === 0) return;
fullyVisibleRows.current = { first: Math.min(...indexes), last: Math.max(...indexes) };
revealPendingRow();
},
[revealPendingRow],
);
// A viewport that got shorter can leave the selected row outside it.
const handleLayout = useCallback(() => {
// The list reports the rows its new height shows before the row is checked against them.
listRef.current?.recordInteraction();
pendingReveal.current = selectedRow < 0 ? null : selectedRow;
revealPendingRow();
}, [revealPendingRow, selectedRow]);
const renderRow = useCallback(
({ item, index }: ListRenderItemInfo<AutocompleteOption>) => (
<AutocompleteRow
option={item}
isSelected={index === selectedRow}
showArgumentHint={!isCompact}
onSelect={onSelect}
/>
),
[isCompact, onSelect, selectedRow],
);
return (
<FlatList
ref={listRef}
inverted={restsAt === "composer"}
data={rows}
renderItem={renderRow}
keyExtractor={keyOfOption}
// The first commit fills the tallest popover, so the popover never opens short and grows.
initialNumToRender={Math.ceil(maxHeight / ROW_MIN_HEIGHT) + 1}
removeClippedSubviews={false}
viewabilityConfig={FULLY_VISIBLE}
onViewableItemsChanged={handleViewableItemsChanged}
onLayout={handleLayout}
onContentSizeChange={revealPendingRow}
onScrollToIndexFailed={handleScrollToIndexFailed}
// The list's padding. As a header and footer it counts in the list's own scroll positions.
ListHeaderComponent={ListPadding}
ListFooterComponent={ListPadding}
style={styles.scrollView}
keyboardShouldPersistTaps="always"
/>
);
}
export function Autocomplete({
options,
selectedIndex,
onSelect,
status,
maxHeight = 220,
maxHeight = DEFAULT_MAX_HEIGHT,
}: AutocompleteProps) {
const { t } = useTranslation();
const isCompact = useIsCompactFormFactor();
const scrollRef = useRef<ScrollView>(null);
const rowLayoutsRef = useRef<Map<number, { top: number; height: number }>>(new Map());
const viewportHeightRef = useRef(0);
const scrollOffsetRef = useRef(0);
const ensureActiveItemVisible = useCallback(() => {
if (selectedIndex < 0) {
return;
}
const layout = rowLayoutsRef.current.get(selectedIndex);
if (!layout) {
return;
}
const nextOffset = getScrollOffsetToRevealItem({
currentOffset: scrollOffsetRef.current,
viewportHeight: viewportHeightRef.current,
itemTop: layout.top,
itemHeight: layout.height,
});
if (Math.abs(nextOffset - scrollOffsetRef.current) < 1) {
return;
}
scrollOffsetRef.current = nextOffset;
scrollRef.current?.scrollTo({ y: nextOffset, animated: false });
}, [selectedIndex]);
const pinToBottom = useCallback(() => {
scrollRef.current?.scrollToEnd({ animated: false });
requestAnimationFrame(() => {
scrollRef.current?.scrollToEnd({ animated: false });
});
}, []);
useEffect(() => {
rowLayoutsRef.current.clear();
scrollOffsetRef.current = 0;
}, [options]);
useEffect(() => {
if (options.length === 0) {
return;
}
pinToBottom();
}, [options, pinToBottom]);
useEffect(() => {
const raf = requestAnimationFrame(ensureActiveItemVisible);
return () => {
cancelAnimationFrame(raf);
};
}, [ensureActiveItemVisible, options.length]);
const handleScrollViewLayout = useCallback(
(event: LayoutChangeEvent) => {
viewportHeightRef.current = event.nativeEvent.layout.height;
ensureActiveItemVisible();
},
[ensureActiveItemVisible],
const scroller = useScroller(options, selectedIndex);
const rows = useMemo(
() => (scroller.restsAt === "composer" ? options.toReversed() : options),
[options, scroller.restsAt],
);
const handleScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
scrollOffsetRef.current = event.nativeEvent.contentOffset.y;
}, []);
const handleRowLayout = useCallback(
(index: number, event: LayoutChangeEvent) => {
rowLayoutsRef.current.set(index, {
top: event.nativeEvent.layout.y,
height: event.nativeEvent.layout.height,
});
ensureActiveItemVisible();
},
[ensureActiveItemVisible],
);
const selectedRow =
scroller.restsAt === "composer" && selectedIndex >= 0
? options.length - 1 - selectedIndex
: selectedIndex;
const containerStyle = useMemo(() => [styles.container, { maxHeight }], [maxHeight]);
const noResultsMessage = t("common.empty.noResults");
@@ -308,28 +417,14 @@ export function Autocomplete({
return (
<View style={containerStyle}>
<ScrollView
ref={scrollRef}
onLayout={handleScrollViewLayout}
onContentSizeChange={pinToBottom}
onScroll={handleScroll}
scrollEventThrottle={16}
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="always"
>
{options.map((option, index) => (
<AutocompleteRow
key={option.id}
index={index}
option={option}
isSelected={index === selectedIndex}
showArgumentHint={!isCompact}
onSelect={onSelect}
onRowLayout={handleRowLayout}
/>
))}
</ScrollView>
<AutocompleteRows
key={scroller.key}
rows={rows}
selectedRow={selectedRow}
restsAt={scroller.restsAt}
maxHeight={maxHeight}
onSelect={onSelect}
/>
</View>
);
}
@@ -350,14 +445,14 @@ const styles = StyleSheet.create((theme: Theme) => ({
flexGrow: 0,
flexShrink: 1,
},
scrollContent: {
paddingVertical: theme.spacing[1],
listPadding: {
height: theme.spacing[1],
},
item: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
minHeight: 36,
minHeight: ROW_MIN_HEIGHT,
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
},
@@ -1,11 +1,47 @@
import React, { createContext, useContext, type ReactNode } from "react";
import React, {
createContext,
useContext,
useMemo,
type MutableRefObject,
type ReactNode,
} from "react";
import type { GestureType } from "react-native-gesture-handler";
const BottomSheetScopeContext = createContext(false);
/**
* Ref for a drag gesture inside the sheet's content, set through the gesture's `withRef`. Keep it
* out of worklet closures: Reanimated freezes captured objects in dev, and RNGH can then no longer
* set `.current`.
*/
export type SheetContentDragGesture = MutableRefObject<GestureType | undefined>;
export function BottomSheetScope({ children }: { children: ReactNode }) {
return <BottomSheetScopeContext.Provider value>{children}</BottomSheetScopeContext.Provider>;
/**
* Registers a child's drag with the sheet, so the sheet's content pan waits for it to fail and the
* child keeps the touch instead of moving the sheet. Returns the unregister. Call it after the
* gesture has attached: RNGH resolves the ref to a handler tag when the sheet builds its pan, and
* drops a ref that is still empty.
*/
export type AttachSheetContentDrag = (gesture: SheetContentDragGesture) => () => void;
interface BottomSheetScopeValue {
attachContentDrag: AttachSheetContentDrag;
}
const BottomSheetScopeContext = createContext<BottomSheetScopeValue | null>(null);
export function BottomSheetScope({
attachContentDrag,
children,
}: BottomSheetScopeValue & { children: ReactNode }) {
const value = useMemo(() => ({ attachContentDrag }), [attachContentDrag]);
return (
<BottomSheetScopeContext.Provider value={value}>{children}</BottomSheetScopeContext.Provider>
);
}
export function useIsInsideBottomSheet(): boolean {
return useContext(BottomSheetScopeContext);
return useContext(BottomSheetScopeContext) !== null;
}
export function useAttachSheetContentDrag(): AttachSheetContentDrag | undefined {
return useContext(BottomSheetScopeContext)?.attachContentDrag;
}
@@ -0,0 +1,6 @@
import { Pressable, StyleSheet } from "react-native";
/** The invisible layer behind a dialog that closes it when pressed outside the dialog. */
export function DismissBackdrop({ label, onPress }: { label: string; onPress: () => void }) {
return <Pressable accessibilityLabel={label} style={StyleSheet.absoluteFill} onPress={onPress} />;
}
@@ -8,6 +8,7 @@ import {
TOUCH_ICON_SIZE,
type SizingValue,
} from "@/components/ui/control-geometry";
import type { LayoutValue } from "@/components/ui/control-sizing";
import { ICON_SIZE } from "@/styles/theme";
export { extraMutedIconColorMapping, mutedIconColorMapping } from "@/components/ui/icon-color";
@@ -102,6 +103,25 @@ export function iconButtonChromeGlyphSize(size: IconButtonChromeSize, compact =
return GLYPH_SIZES[size][controlSizingForLayout(compact)];
}
/** The glyph for an icon button, sized by its container's chrome the way its frame is. */
export function useIconButtonChromeGlyphSize(size: IconButtonChromeSize, compact = false): number {
const scope = useContext(IconButtonChromeContext);
return iconButtonChromeGlyphSize(scope === "header-island" ? "large" : size, compact);
}
/**
* The margin that pulls a button out by how far its frame reaches past its glyph. A row that hangs
* its edge buttons by it keeps their glyphs, not their frames, on the row's content rail.
*/
export function iconButtonChromeHang(size: IconButtonChromeSize): LayoutValue {
const frame = ICON_BUTTON_SIZES[size];
const glyph = GLYPH_SIZES[size];
return bySizing({
touch: (glyph.touch - frame.touch) / 2,
pointer: (glyph.pointer - frame.pointer) / 2,
});
}
export function smallIconButtonChromeFrameSize(compact = false): number {
return ICON_BUTTON_SIZES.small[controlSizingForLayout(compact)];
}
@@ -41,25 +41,41 @@ vi.mock("@gorhom/portal", () => ({
PortalHost: () => React.createElement("div", { "data-host": true }),
}));
/** Every `waitFor` Gorhom received, in render order. Gorhom rebuilds its content pan per value. */
const sheetWaitFor = vi.hoisted(() => [] as unknown[]);
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(
(
{
children,
stackBehavior,
waitFor,
}: { children?: React.ReactNode; stackBehavior?: string; waitFor?: unknown },
_ref,
) => {
sheetWaitFor.push(waitFor);
return React.createElement(
Portal,
null,
React.createElement("div", { "data-stack-behavior": stackBehavior }, children),
),
);
},
),
};
});
import { IsolatedBottomSheetModal, type ContextBridge } from ".";
import { useAnimatedLayout } from "@gorhom/bottom-sheet/src/hooks/useAnimatedLayout";
import { useIsInsideBottomSheet } from "@/components/ui/bottom-sheet-scope";
import {
useAttachSheetContentDrag,
useIsInsideBottomSheet,
type SheetContentDragGesture,
} from "@/components/ui/bottom-sheet-scope";
/** Renders whatever the portal is currently holding, from its own place in the tree. */
function PortalHostProbe() {
@@ -233,3 +249,51 @@ describe("bottom sheet layout subscription", () => {
}
});
});
describe("IsolatedBottomSheetModal content drag", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
sheetWaitFor.length = 0;
});
afterEach(() => {
act(() => root.unmount());
container.remove();
portalStore.reset();
});
const dragGesture: SheetContentDragGesture = { current: undefined };
function ContentDrag() {
const attach = useAttachSheetContentDrag();
React.useEffect(() => attach?.(dragGesture), [attach]);
return null;
}
function renderSheet(content: React.ReactNode) {
act(() => {
root.render(
<>
<IsolatedBottomSheetModal contextBridge={null}>{content}</IsolatedBottomSheetModal>
<PortalHostProbe />
</>,
);
});
}
it("makes the sheet wait for a content drag that mounts after it, until it unmounts", () => {
renderSheet(null);
expect(sheetWaitFor.at(-1)).toBeUndefined();
renderSheet(<ContentDrag />);
expect(sheetWaitFor.at(-1)).toBe(dragGesture);
renderSheet(null);
expect(sheetWaitFor.at(-1)).toBeUndefined();
});
});
@@ -4,7 +4,15 @@ import {
type BottomSheetBackdropProps,
} from "@gorhom/bottom-sheet";
import React from "react";
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef } from "react";
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from "react";
import { useDismissibleOverlay } from "@/components/ui/overlay-dismissal";
import type { ElementRef, ReactNode } from "react";
import { systemBackPress } from "./back-press";
@@ -12,7 +20,11 @@ import {
type BottomSheetController,
createBottomSheetVisibilityTracker,
} from "./visibility-tracker";
import { BottomSheetScope } from "@/components/ui/bottom-sheet-scope";
import {
BottomSheetScope,
type AttachSheetContentDrag,
type SheetContentDragGesture,
} from "@/components/ui/bottom-sheet-scope";
import { SheetBackdrop } from "./sheet-backdrop";
type GorhomBottomSheetModalMethods = ElementRef<typeof GorhomBottomSheetModal>;
@@ -95,6 +107,14 @@ export const IsolatedBottomSheetModal = forwardRef<
enableDismissOnClose = true,
...bottomSheetProps
} = props;
// State, not a ref the child fills in: Gorhom builds its pan once per `waitFor`, and RNGH reads
// the ref's handler tag only then. Registering hands Gorhom a new `waitFor`, so a drag that
// mounts after the sheet (content still loading) still gets the relation.
const [contentDragGesture, setContentDragGesture] = useState<SheetContentDragGesture>();
const attachContentDrag = useCallback<AttachSheetContentDrag>((gesture) => {
setContentDragGesture(gesture);
return () => setContentDragGesture((current) => (current === gesture ? undefined : current));
}, []);
const renderBackdrop = useCallback(
(backdropProps: BottomSheetBackdropProps) => (
<SheetBackdrop
@@ -112,8 +132,11 @@ export const IsolatedBottomSheetModal = forwardRef<
enableDismissOnClose={enableDismissOnClose}
stackBehavior={presentation}
backdropComponent={backdropOpacity === undefined ? undefined : renderBackdrop}
waitFor={contentDragGesture}
>
<BottomSheetScope>{contextBridge ? contextBridge(children) : children}</BottomSheetScope>
<BottomSheetScope attachContentDrag={attachContentDrag}>
{contextBridge ? contextBridge(children) : children}
</BottomSheetScope>
</GorhomBottomSheetModal>
);
+25 -3
View File
@@ -1,4 +1,10 @@
import { Modal as NativeModal, type ModalProps as NativeModalProps } from "react-native";
import { useMemo } from "react";
import {
Modal as NativeModal,
Platform,
useWindowDimensions,
type ModalProps as NativeModalProps,
} from "react-native";
import { useDismissibleOverlay } from "@/components/ui/overlay-dismissal";
// React Native defaults iPhone modals to portrait even when the app supports rotation.
@@ -17,7 +23,23 @@ type ModalProps = Omit<NativeModalProps, "onRequestClose"> & {
function noClose() {}
export function Modal(props: ModalProps) {
const { visible = true, onRequestClose } = props;
const { visible = true, onRequestClose, style } = props;
useDismissibleOverlay(visible && onRequestClose !== undefined, onRequestClose ?? noClose);
return <NativeModal supportedOrientations={orientations} {...props} />;
const windowFrame = useAndroidWindowFrame();
const containerStyle = useMemo(() => [windowFrame, style], [windowFrame, style]);
return <NativeModal supportedOrientations={orientations} {...props} style={containerStyle} />;
}
/**
* On Android, React Native lays out a modal's first frames at the screen size minus the status and
* navigation bars, then grows it to the full window once the dialog reports its size. The app draws
* edge-to-edge, so the dialog always covers the whole window: size the content to the window from
* the first frame, or a full-screen backdrop stops short of the bottom and then snaps down.
*/
function useAndroidWindowFrame() {
const { width, height } = useWindowDimensions();
return useMemo(
() => (Platform.OS === "android" ? { flex: 0, width, height } : null),
[width, height],
);
}
@@ -45,7 +45,6 @@ export interface RevealActiveItemHandlers {
* content. Measuring on selection/list changes also covers retained rows that
* move or keep their size without emitting another onLayout event on web.
* A list replacement resets to the top only when selection restarts there.
* Bottom-anchored lists share only getScrollOffsetToRevealItem.
*/
export function useRevealActiveItem(options: {
activeIndex: number;
+42 -4
View File
@@ -1,4 +1,4 @@
import { forwardRef, type Ref } from "react";
import { createContext, forwardRef, useContext, useMemo, useState, type Ref } from "react";
import {
ScrollView as NativeScrollView,
FlatList as NativeFlatList,
@@ -12,7 +12,11 @@ import {
type NativeViewGestureHandlerProps,
} from "react-native-gesture-handler";
import { isNative } from "@/constants/platform";
import { useIsInsideBottomSheet } from "./bottom-sheet-scope";
import {
useAttachSheetContentDrag,
useIsInsideBottomSheet,
type AttachSheetContentDrag,
} from "./bottom-sheet-scope";
// Both libraries forward to the RN FlatList instance. Their published ref types
// disagree about getScrollResponder (Gorhom) and include the component function
@@ -34,9 +38,43 @@ export const ScrollView = forwardRef<NativeScrollView, ScrollViewProps>(
<NativeScrollView {...props} ref={ref} />
);
}
return <SheetScrollView {...props} ref={ref} />;
},
);
/**
* What a child that drags inside a sheet needs to keep the touch: it registers its drag gesture
* through `attach` so the sheet's pan waits for it, and `setActive(true)` stops the sheet's
* scroll until the drag ends. Outside a sheet scroll there is no `attach` and `setActive` does
* nothing.
*/
export interface ContentDrag {
attach: AttachSheetContentDrag | undefined;
setActive: (active: boolean) => void;
}
const ContentDragContext = createContext<ContentDrag | null>(null);
const NO_CONTENT_DRAG: ContentDrag = { attach: undefined, setActive: () => {} };
export function useContentDrag(): ContentDrag {
return useContext(ContentDragContext) ?? NO_CONTENT_DRAG;
}
const SheetScrollView = forwardRef<NativeScrollView, ScrollViewProps>(
function SheetScrollView(props, ref) {
const attach = useAttachSheetContentDrag();
const [dragging, setDragging] = useState(false);
const contentDrag = useMemo(() => ({ attach, setActive: setDragging }), [attach]);
return (
<BottomSheetScrollView {...props} ref={ref}>
{props.children}
<BottomSheetScrollView
{...props}
scrollEnabled={!dragging && props.scrollEnabled !== false}
ref={ref}
>
<ContentDragContext.Provider value={contentDrag}>
{props.children}
</ContentDragContext.Provider>
</BottomSheetScrollView>
);
},
@@ -66,13 +66,15 @@ function createHandleRecorder() {
return { handleRef, received, assignHandle };
}
const attachNoContentDrag = () => () => {};
describe("EditingTextInputNative", () => {
it("uses the bottom-sheet input only inside a bottom sheet", () => {
act(() => {
root?.render(
<>
<EditingTextInput testID="outside" />
<BottomSheetScope>
<BottomSheetScope attachContentDrag={attachNoContentDrag}>
<EditingTextInput testID="inside" />
</BottomSheetScope>
</>,
@@ -0,0 +1,16 @@
import { Image } from "react-native";
import type { ViewportSize } from "./geometry";
/** Resolves the image's natural size; rejects when the image cannot load or has no size. */
export function measureImage(uri: string): Promise<ViewportSize> {
return new Promise((resolve, reject) => {
Image.getSize(
uri,
(width, height) => {
if (width > 0 && height > 0) resolve({ width, height });
else reject(new Error("Image has no size"));
},
reject,
);
});
}
@@ -0,0 +1,24 @@
import type { ViewportSize } from "./geometry";
// The size a browser gives a replaced element that has no intrinsic size of its own.
const NO_INTRINSIC_SIZE: ViewportSize = { width: 300, height: 150 };
/**
* Resolves the image's natural size; rejects when the image cannot load. React Native Web's
* `Image.getSize` never answers for an image that loads with no natural size (an SVG without
* width and height, in some browsers), so the image would never show and never fail.
*/
export function measureImage(uri: string): Promise<ViewportSize> {
return new Promise((resolve, reject) => {
const image = new window.Image();
image.addEventListener("load", () =>
resolve(
image.naturalWidth > 0 && image.naturalHeight > 0
? { width: image.naturalWidth, height: image.naturalHeight }
: NO_INTRINSIC_SIZE,
),
);
image.addEventListener("error", () => reject(new Error("Image failed to load")));
image.src = uri;
});
}
@@ -1,7 +1,8 @@
import { useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Image, View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import type { ViewportFitOptions, ViewportSize } from "./geometry";
import { measureImage } from "./image-size";
import { ZoomableViewport } from "./index";
import type { ZoomableViewportAction } from "./types";
@@ -34,42 +35,33 @@ export function ZoomableImage({
testID = "zoomable-image",
wheelActivation = "always",
}: ZoomableImageProps) {
const [loadedSize, setLoadedSize] = useState<ViewportSize | null>(null);
const resolvedSize = contentSize ?? loadedSize;
const [loaded, setLoaded] = useState<{ uri: string; size: ViewportSize } | null>(null);
const resolvedSize = contentSize ?? (loaded?.uri === uri ? loaded.size : null);
const source = useMemo(() => ({ uri }), [uri]);
const [paintedUri, setPaintedUri] = useState<string | null>(null);
const handleLoad = useCallback(() => setPaintedUri(uri), [uri]);
useEffect(() => {
if (contentSize) return;
let active = true;
setLoadedSize(null);
Image.getSize(
uri,
(width, height) => {
if (active && width > 0 && height > 0) setLoadedSize({ width, height });
},
() => {
void (async () => {
try {
const size = await measureImage(uri);
if (active) setLoaded({ uri, size });
} catch {
if (active) onError?.();
},
);
}
})();
return () => {
active = false;
};
}, [contentSize, onError, uri]);
// Paint nothing until the image can be fitted: an unfitted image fills the whole area for the
// frames before the size arrives, then jumps to its fitted box. Then keep the viewport, toolbar
// included, hidden until the image itself has loaded, so they appear together.
if (!resolvedSize) {
return (
<View style={[styles.root, style]} testID={testID}>
<Image
accessibilityLabel={accessibilityLabel}
accessibilityRole="image"
onError={onError}
resizeMode="contain"
source={source}
style={styles.image}
testID={`${testID}-image`}
/>
</View>
);
return <View style={[styles.root, style]} testID={testID} />;
}
return (
@@ -81,12 +73,15 @@ export function ZoomableImage({
maxScale={maxScale}
minScale={minScale}
onPressOutsideContent={onPressOutsideContent}
style={style}
style={[style, paintedUri !== uri && styles.loading]}
testID={testID}
wheelActivation={wheelActivation}
>
<Image
// Android fades a freshly decoded image in over 300ms; it appears with its toolbar instead.
fadeDuration={0}
onError={onError}
onLoad={handleLoad}
resizeMode="contain"
source={source}
style={styles.image}
@@ -99,4 +94,5 @@ export function ZoomableImage({
const styles = StyleSheet.create(() => ({
root: { flex: 1, minHeight: 0 },
image: { width: "100%", height: "100%" },
loading: { opacity: 0 },
}));
+4 -8
View File
@@ -6,7 +6,6 @@ import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { FolderTree } from "lucide-react-native";
import {
AdaptiveModalSheet,
SHEET_HEADER_CLOSE_PADDING_SCALE,
SHEET_HORIZONTAL_PADDING_SCALE,
} from "@/components/adaptive-modal-sheet";
import {
@@ -27,14 +26,11 @@ import { ICON_SIZE, SPACING, type Theme } from "@/styles/theme";
// difference so a depth-0 file icon lands on the header glyph's rail.
const TREE_LEADING_INSET = SPACING[SHEET_HORIZONTAL_PADDING_SCALE] - treeRowPaddingLeft(0);
// The header's close glyph sits at the content inset plus the button's own
// padding, while a tree row ends its trailing glyph at
// `WORKSPACE_PANE_TRAILING_GLYPH_RAIL`. The body carries the difference so row
// change icons land on the header X's rail.
// The header's close glyph ends on the sheet's content inset, while a tree row
// ends its trailing glyph at `WORKSPACE_PANE_TRAILING_GLYPH_RAIL`. The body
// carries the difference so row change icons land on the header X's rail.
const TREE_TRAILING_INSET =
SPACING[SHEET_HORIZONTAL_PADDING_SCALE] +
SPACING[SHEET_HEADER_CLOSE_PADDING_SCALE] -
WORKSPACE_PANE_TRAILING_GLYPH_RAIL;
SPACING[SHEET_HORIZONTAL_PADDING_SCALE] - WORKSPACE_PANE_TRAILING_GLYPH_RAIL;
const ThemedFolderTree = withUnistyles(FolderTree);
+27 -3
View File
@@ -3,9 +3,10 @@ import type {
FetchAgentHistoryOptions,
FetchAgentHistoryPageInfo,
} from "@getpaseo/client/internal/daemon-client";
import { useInfiniteQuery } from "@tanstack/react-query";
import { keepPreviousData, useInfiniteQuery } from "@tanstack/react-query";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useShallow } from "zustand/shallow";
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
import {
getHostRuntimeStore,
@@ -34,6 +35,8 @@ export interface AgentHistoryResult {
isSearchSupported: boolean;
/** More sessions matched than the ranked page holds. Narrow the query. */
isSearchTruncated: boolean;
/** The agents answer the previous query; the current one is loading (`keepPreviousResults`). */
isPreviousResults: boolean;
/** Hosts that failed while others succeeded. The list still renders. */
hostErrors: AgentHistoryHostError[];
refreshAll: () => Promise<void>;
@@ -222,6 +225,13 @@ export function useAgentHistory(options: {
serverId?: string | null;
enabled?: boolean;
search?: string;
/**
* Leave out hosts that predate search, instead of searching no host. For a surface that adds
* history matches to other results, where a missing host is not a claim of "no matches".
*/
searchableHostsOnly?: boolean;
/** Keep the previous query's results until the next query's arrive. */
keepPreviousResults?: boolean;
}): AgentHistoryResult {
const { t } = useTranslation();
const daemons = useHosts();
@@ -231,10 +241,18 @@ export function useAgentHistory(options: {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}, [options.serverId]);
const enabled = options.enabled ?? true;
const serverIds = useMemo(
const requestedServerIds = useMemo(
() => (serverId ? [serverId] : daemons.map((daemon) => daemon.serverId)),
[daemons, serverId],
);
const searchableServerIds = useSessionStore(
useShallow((state) =>
requestedServerIds.filter(
(id) => state.sessions[id]?.serverInfo?.features?.agentHistorySearch === true,
),
),
);
const serverIds = options.searchableHostsOnly ? searchableServerIds : requestedServerIds;
const connectionStatuses = useHostRuntimeConnectionStatuses(serverIds);
// A host the user asked about splits two ways: one this fetch can reach, and
// one whose sessions will be missing from the answer. Both come out of here,
@@ -287,6 +305,7 @@ export function useAgentHistory(options: {
[daemons],
);
const queryEnabled = Boolean(enabled && targetHosts.length > 0);
const historyQuery = useInfiniteQuery<
AgentHistoryBatchPage,
Error,
@@ -295,8 +314,11 @@ export function useAgentHistory(options: {
AgentHistoryCursorByServerId | null
>({
queryKey,
enabled: Boolean(enabled && targetHosts.length > 0),
enabled: queryEnabled,
staleTime: 30_000,
// Previous results stand in only until the next query's arrive. With no host to search,
// nothing will arrive, so they would stand in, and read as still loading, indefinitely.
...(options.keepPreviousResults && queryEnabled ? { placeholderData: keepPreviousData } : {}),
initialPageParam: null,
getNextPageParam: getNextAgentHistoryPageParam,
queryFn: async ({ pageParam }) => {
@@ -318,6 +340,7 @@ export function useAgentHistory(options: {
isFetching,
isFetchingNextPage,
isLoading,
isPlaceholderData,
refetch,
} = historyQuery;
@@ -363,6 +386,7 @@ export function useAgentHistory(options: {
isLoadingMore: isFetchingNextPage,
isSearchSupported,
isSearchTruncated,
isPreviousResults: isPlaceholderData,
hostErrors,
refreshAll,
loadMore,
@@ -330,6 +330,8 @@ describe("loadAppSettingsFromStorage", () => {
displayAs: "used",
pins: null,
serverId: null,
cardOrder: [],
cardsOff: [],
});
const deps = makeDeps({
@@ -354,6 +356,8 @@ describe("loadAppSettingsFromStorage", () => {
{ sourceId: "claude", windowId: "five-hour" },
],
serverId: "server",
cardOrder: [],
cardsOff: [],
});
});
@@ -369,6 +373,8 @@ describe("loadAppSettingsFromStorage", () => {
displayAs: "remaining",
pins: [],
serverId: "server",
cardOrder: [],
cardsOff: [],
});
});
@@ -389,6 +395,8 @@ describe("loadAppSettingsFromStorage", () => {
displayAs: "used",
pins: [{ sourceId: "claude", windowId: "weekly" }],
serverId: null,
cardOrder: [],
cardsOff: [],
});
});
+4 -4
View File
@@ -319,15 +319,15 @@ describe("translation resources", () => {
expect(en.shell.menu.toggleSidebar).toBe("Toggle sidebar");
expect(en.shell.menu.open).toBe("Open menu");
expect(en.shell.menu.close).toBe("Close menu");
expect(en.shell.commandCenter.placeholder).toBe(
"Search commands, files, workspaces, and agents...",
);
expect(en.shell.commandCenter.placeholder).toBe("Search workspaces, files, and actions...");
expect(en.shell.commandCenter.placeholderWithoutFiles).toBe("Search workspaces and actions...");
expect(en.shell.commandCenter.filePlaceholder).toBe("Search files...");
expect(en.shell.commandCenter.files).toBe("Files");
expect(en.shell.commandCenter.noMatches).toBe("No matches");
expect(en.shell.commandCenter.actions).toBe("Actions");
expect(en.shell.commandCenter.workspaces).toBe("Workspaces");
expect(en.shell.commandCenter.agents).toBe("Agents");
expect(en.shell.commandCenter.recentWorkspaces).toBe("Recent workspaces");
expect(en.shell.commandCenter.tabs.all).toBe("All");
expect(en.shell.commandCenter.newAgent).toBe("New agent");
expect(en.shell.commandCenter.addProject).toBe("Add project");
expect(en.shell.commandCenter.home).toBe("Home");
+14 -2
View File
@@ -90,14 +90,23 @@ export const ar: TranslationResources = {
close: "إغلاق القائمة",
},
commandCenter: {
placeholder: "ابحث في الأوامر والملفات ومساحات العمل والوكلاء...",
placeholder: "البحث في مساحات العمل والملفات والإجراءات...",
placeholderWithoutFiles: "البحث في مساحات العمل والإجراءات...",
filePlaceholder: "البحث في الملفات...",
workspacePlaceholder: "البحث في مساحات العمل...",
actionPlaceholder: "البحث في الإجراءات...",
searchingFiles: "جارٍ البحث في الملفات...",
noMatches: "لا توجد مباريات",
actions: "الإجراءات",
files: "الملفات",
workspaces: "مساحات العمل",
agents: "الوكلاء",
recentWorkspaces: "مساحات العمل الأخيرة",
tabs: {
all: "الكل",
workspaces: "مساحات العمل",
files: "الملفات",
actions: "الإجراءات",
},
newAgent: "وكيل جديد",
open: "فتح {{name}}",
openInSidePane: "فتح {{name}} في اللوحة الجانبية",
@@ -2201,6 +2210,7 @@ export const ar: TranslationResources = {
turnOn: "تشغيل",
options: "الإعدادات",
host: "المضيف",
refreshFailed: "تعذّر تحديث الاستخدام",
noHosts: "لا توجد مضيفات متصلة",
agentError: "تعذّر تحميل الاستخدام: {{reason}}",
@@ -2217,6 +2227,8 @@ export const ar: TranslationResources = {
loginExpired: "انتهت صلاحية تسجيل الدخول {{relativeTime}}. {{remedy}}",
title: "استخدام الخطة",
refresh: "تحديث",
reorderCard: "إعادة ترتيب {{source}}",
showCard: "إظهار {{source}}",
refreshAll: "تحديث الكل",
refreshing: "جارٍ التحديث...",
loading: "جارٍ تحميل بيانات الاستخدام...",

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