Compare commits

..
Author SHA1 Message Date
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
paseo-bot[bot]andClaude Opus 5.5 c1d7d56b71 Drain agent snapshot writes before replacement-cancel test cleanup (#9707)
The "failed replacement cancellation preserves an autonomous running
state" test deleted its storage directory while the snapshot write for
the "running" state could still be in flight. Under CI load that write
lands during rmSync and fails the test with ENOTEMPTY. Flush the manager
and storage first, as the neighbouring cleanup tests do.

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 21:00:57 +00:00
paseo-bot[bot]andhadan8977 2bd9e9d87f fix(server): preserve plugin child outcomes after close (#8563)
* fix(server): keep a plugin child's canceled or failed turn on close

A plugin child that ended its turn as canceled or failed and then sent a
plain session.closed was upserted as completed. Remember the outcome of
the child's latest finished turn and use it when it closes without an
error.

* fix(server): clear plugin child outcomes when replaying a branch

Keep the close-status fix compatible with reused-child running updates and discard remembered outcomes when rewind replaces a binding. Cover terminal close states through the agent API and replay.

Co-authored-by: hadan8977 <zc-huang23@mails.tsinghua.edu.cn>

---------

Co-authored-by: hadan8977 <zc-huang23@mails.tsinghua.edu.cn>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-14 19:59:39 +00:00
Okan YILDIRIM 6e2fee462f fix(server): fork from a response that background work outlives (#9627)
Forking from a reply failed with "This checkpoint changed after it was
created" whenever a tool call started before that reply and finished
after it, the shape of background Bash, Monitor, Task and Codex
terminals. The projection keeps one row per tool call from its start to
its latest update, so the row spans the reply, and the fork refused any
copied row that spans the checkpoint.

Copy such a tool call into the fork marked running: its state at the
checkpoint can't be read back. Plugin cards that span the checkpoint
stay out, and a cursor from another epoch or a reply that is gone is
still refused.
2026-11-14 19:04:00 +00:00
ese111 cec92d0be6 fix(cli): treat full agent UUIDs as exact identifiers (#9680) 2026-11-14 16:56:10 +00:00
Okan YILDIRIM b407990699 fix(archive): warn from fresh git state before removing a worktree (#9625)
* fix(archive): warn from fresh git state before removing a worktree

Archiving a worktree decided whether to warn from the sidebar row's cached git status. Until the
row received that status it was empty, so the archive removed uncommitted work and unpushed commits
without asking. Every archive that confirms now reads the worktree's checkout status from its host
first, and a failed read shows its error and keeps the worktree.

The host's dirty check ran bare `git status --porcelain`, which drops untracked files when the
repository sets `status.showUntrackedFiles=no`, so a worktree holding only new files read clean. It
now passes `--untracked-files=normal`.

* fix(archive): redirect from the current selection after the host answers

A worktree archive now waits for the host's checkout status before it starts. If you opened another
workspace meanwhile, the archive redirected from the selection captured at the click and replaced
the workspace you had just opened with /new. The redirect now runs the latest onArchiveStarted and
receives the workspace being archived, so the git actions controller compares that workspace with
the current selection instead of redirecting from whatever is open.
2026-11-14 16:16:52 +00:00
Mohamed Boudra fc36438f03 fix(app): keep the Android chat still when a tap or long-press lands on its text (#9693)
* fix(app): keep the Android chat still when a tap or long-press lands on its text

Chat text is selectable, and Android focuses selectable text when it is
touched. React Native's ReactScrollView.requestChildFocus scrolls a newly
focused child fully into view, so tapping a link or a word in a reply that
was partly off screen, or long-pressing it, scrolled the timeline by up to
a screen. The inverted timeline had a guard for this, but the guard
depended on the inversion (#4090, then #7131), and #8717 removed it with
the inversion.

PaseoScrollView now skips that reveal when the focused view is selectable,
non-editable text in touch mode. Inputs, and focus moved by a keyboard,
still scroll into view.

* test(app): assert the long press selects the word under the finger

* fix(app): keep the Android chat still when a tapped reply keeps streaming

Touched chat text keeps focus. When a streaming reply's text is replaced,
ArrowKeyMovementMethod collapses the selection to 0, and the focused text
view asks its scroll parent to reveal that line, which scrolled the
timeline back to the reply's first line. PaseoScrollView now drops
rectangle requests while touched read-only text holds focus with nothing
selected. A drag on a selection handle still reveals.

The input control test now makes its EditText selectable, as React
Native's TextInput does, so it covers the editor check.

* fix(app): keep the Android chat still when the timeline resizes after a tap

ScrollView.onSizeChanged scrolls the focused view into view when the
scroll view changes height. Touched chat text keeps focus, so a composer
change below the timeline (a queued message being sent, a send error
appearing) realigned the tapped reply. PaseoScrollView now skips that
reveal while touched read-only text holds focus.
2026-11-14 22:56:18 +07:00
paseo-bot[bot]andClaude Opus 5.5 0fb6473a4b Keep the slash-menu arrow-choice test from erasing its search before it applies (#9677)
* test(app): wait for the slash search to change before erasing it

"forgets an arrow-key choice once the search changes" waited for /help
after typing "h", but /help is already in the unfiltered list, so the
wait passed at once. On web the cursor reaches the slash search after
paint; a Backspace sent before that frame left the search at "" the
whole time, the /exit choice stood, and Enter archived the agent. The
test now waits for /tdd to leave the list, which only happens once the
search is "h".

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

* test(app): wait for the slash search to return before Enter

The input's value runs ahead of the slash list on web, so Enter could
still land on the list for "h". Wait for /tdd to come back first.

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-14 15:36:40 +00:00
FDKevin a730ad1924 fix(server): give restored agent runtimes a fresh idle cleanup window (#8857)
When an archived or persisted agent is restored via resumeAgentFromPersistence, its managed record intentionally preserves the historical updatedAt timestamp so sidebar ordering and activity timestamps are not artificially bumped just by viewing or reopening the agent.

However, getRuntimeCleanupCandidates() previously used updatedAt.getTime() directly as lastActivityAt for idle runtime garbage collection. For any restored agent whose last conversation turn was older than the idle runtime threshold (default 60 minutes), the next periodic idle cleanup sweep (every 60s) immediately saw the newly spawned runtime as idle for >60m and terminated its live process.

Record openedAt on the per-session runtimeCleanupIdentities entry when a session is registered, and compute lastActivityAt in getRuntimeCleanupCandidates() as Math.max(updatedAt.getTime(), identity.openedAt) so a newly restored runtime gets a full idle window without mutating the agent's persisted updatedAt.
2026-11-14 15:29:42 +00:00
Mohamed Boudra 42c7b7f886 fix(app): show the Android ripple on sidebar row presses (#9678)
* fix(app): show the Android ripple on sidebar row presses

PressHighlight owns pressed feedback. Callers pass the pressed fill as
highlightStyle and stop painting it themselves. iOS and web paint the
fill; Android draws the platform ripple, clipped to the row's corners,
after Android's 100 ms tap timeout.

Rows that knock content out of the surface follow the fill through
onHighlightChange instead of tracking press state themselves.

Defaults borderWidth to 0 under clipped ripples: React Native 0.86 sends
NaN when a style drops borderWidth, and an overflow-hidden view then
stops painting (fixed upstream in facebook/react-native#57789).

* fix(app): let PressHighlight own file header press feedback

Changes-tree rows on Android painted their old flat pressed fill under the
new ripple. Lock the Android row's caller border and press handlers in a
test.

* fix(app): skip the sidebar row press fill while renaming

On web the row's pressable stays the responder for clicks inside the
inline rename field, so it painted the pressed fill while the title was
being edited. Drop highlightStyle while editing, as the press handlers
already are.

* fix(app): keep a press's fill and its report on the same edges

usePressFill painted from the live pressed state but reported only at
press-in and press-out, so the row fill and the badge ring could
disagree when highlightStyle appeared or went away mid-press. A press
now keeps the fill it started with; the paint and the report both come
from that one state.
2026-11-14 21:13:11 +07:00
Mohamed Boudra 85fb5b436d Sidebar drops on iOS and Android land without a flash, keeping the current list (remount alternative) (#9681)
* fix(app): drop a sidebar row without a wrong frame on native by remounting moved cells

draggable-flatlist moves a cell with a held drag translate. Reanimated re-applies a view's last animated transform to every React commit, so the reorder commit painted one frame of new order plus old translate. The drop now gives each moved cell a fresh transform host, keyed per cell, so the reorder commit mounts views with no animated transform to re-apply. Moved cells remount their row subtree on each drop.

* fix(app): keep a project's Show more reveal when a drop remounts it

The drop remounts each project block it moves, and the block held its
own reveal state, so a revealed project shrank back after a drop. The
project list now owns the reveal per project, and the group hook takes
it as input.

* docs(app): note that a native drop resets state held inside a moved row

* fix(app): render every row of a non-scrolling native list in its first commit

A drop remounts the nested workspace list of each project block it
moves. FlatList renders 10 rows on mount, so a moved project showing
more than 10 workspaces painted 10 rows and grew a commit later.
2026-11-14 21:10:17 +07:00
Mohamed Boudra c81f38d7ca Give the sidebar list its end back: drop the permanent room for the toast (#9667)
The sidebar list's bottom padding goes back to 16 and stays constant. The archive toast stays over the list's bottom edge, as on main, so nothing moves when it appears or leaves.
2026-11-14 20:46:16 +07:00
Mohamed Boudra be487ce65f Keep seeded dev daemon homes from resuming agents (#9672)
* fix(dev): keep seeded daemon homes from resuming agents

* fix(dev): accept BOM configs when seeding homes

* fix(dev): skip existing homes before reading seed config

* fix(dev): seed homes from symlinked checkout paths

* fix(dev): copy linked metadata into independent seed files
2026-11-14 20:41:37 +07:00
0e0ebc7c9e fix(chat): keep the reading position when older history becomes virtualized (#9664)
* fix(chat): preserve scroll offset when enabling virtualization

Reading older messages in a long web chat could jump to the first
message when older rows moved into the virtualized history section.
When the row virtualizer is enabled it scrolls to its remembered
offset, which falls back to initialOffset (0) the first time. Start
it from the scroll container's current position instead.

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

* test(chat): pass mountTimeline options as an object

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

---------

Co-authored-by: luke <luopeihuan@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: paseo-bot[bot] <paseo-bot[bot]@users.noreply.github.com>
2026-11-14 13:10:04 +00:00
paseo-bot[bot] 7f3967d288 test(server): keep the forge parent retry test from fetching over the network (#9658)
Observing the workspace starts a background git fetch of origin. The test's
origin is an SSH alias, so a real fetch runs ssh in the checkout and can still
be running after the service is disposed. On Windows that holds the folder
open and cleanup fails with EBUSY. The test now supplies a fetch that returns
no ref changes; the parent lookup and status poll it checks are unchanged.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-14 12:23:33 +00:00
paseo-bot[bot]andquickwind ade902f195 Existing OpenCode sessions rebind after a daemon restart when OpenCode runs on Bun (#9653)
* fix(opencode): rebind a session when the previous bridge stops listening

A refused fetch proves the previous bridge is gone, but the check only
matched the Node shape (`cause.code`) and Bun's older spelling
(`code === "ConnectionRefused"`). OpenCode 2.2.5 runs on Bun 1.4.4,
which throws a TypeError with `code === "ECONNREFUSED"` and no
`cause`, so the error was rethrown, the stale binding was never
deleted, and every later bind for that session answered HTTP 500
(#8780).

Accept the code from either location. The narrow refusal check stays:
other connection failures remain inconclusive so two daemons cannot
take over the same session.

The probe is now classified by an exported predicate with the runtime
shapes as explicit cases, and the stale-binding test closes a real
listener instead of stubbing the network, so neither test patches a
global.

* test(opencode): bind the session on the restarted bridge before rebinding

The bridge now serves tools only for sessions it has bound, so the stale-binding
test registers the session on the restarted bridge, as a restarted daemon does
when it takes the agent back.

Co-authored-by: quickwind <wallacewei@live.cn>

---------

Co-authored-by: quickwind <wallacewei@live.cn>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-14 11:59:29 +00:00
Mohamed Boudra 1bd94ac2b0 Collapsing Activity in the PR pane gives the checks the space, and the separator above it resizes (#9656)
* fix(app): let the PR pane's Activity section collapse and resize

The PR pane was one ScrollView. Checks sat in a nested list capped at
268px, and the line above Activity was a plain 1px View. Collapsing
Activity only hid its body, so the checks stayed boxed with empty space
below, and nothing could be dragged.

The pane now splits like the Changes pane's docked Commits section. The
overview (header and checks) scrolls in its own region. While Activity is
open, a ResizeHandle between the two sets the overview's share. While it
is collapsed, the Activity row docks to the bottom and the overview fills
the rest.

* fix(app): keep the PR pane separator under the finger above the home indicator

The split measured the pane including its bottom safe-area padding, but
the overview's percentage height resolves against the box inside that
padding. On a phone with a home indicator the two heights differ, so a
drag jumped at the start and then lagged the finger (a 250px drag moved
the separator 212px on Android). The split is now measured inside the
padding, so the drag and the layout use the same height.

* fix(app): keep focus on the PR pane's Activity header when it toggles

The header moved between two parents as Activity opened and closed, so
React remounted the row and keyboard focus fell to the body. It now keeps
one slot in the split and only the timeline mounts. That also stops the
header shrinking into the space left by a large drag when the pane gets
short, since the header no longer sits in a min-height-0 wrapper.

The header exposes aria-expanded on web, which React Native Web does not
derive from accessibilityState. The Unistyles test stub now lets explicit
props override mapped ones, matching the library's merge order.

* test(app): fill unpinned test-theme tokens from the light theme

The Unistyles stub now runs uniProps mappings, and the markdown renderer's
mapping reads colors.terminal, which the hand-written test theme lacked.
Tokens a test does not pin now come from the real light theme.

* fix(app): save the PR pane separator where it is drawn

Dragging past the end of short checks left the separator at the content's
bottom but saved the pointer's share, so the separator jumped down when
more checks arrived. The saved share is now capped at the overview's
content height.

* fix(app): keep the PR pane's earlier share after a drag past short checks

Capping the saved share at the content height made any downward drag over
short checks pin the overview at that height, even a drag that visibly did
nothing. A drag that ends at or past the content's bottom now keeps the
larger of the current share and the content's, so growing checks return to
the share the overview had, and never to the pointer's position.

* fix(app): decide the PR pane's saved share when a drag ends

Drag previews wrote straight into the saved share, so a move above the end
of short checks during a drag replaced the share the overview had before
it, and a drag that ended back at the content's bottom left the overview
pinned there. Previews now live in their own drag state, and the saved
share is resolved once, on commit, against the share from before the drag.
2026-11-14 18:03:46 +07:00
hankyoandClaude Opus 5.5 7deb338d4c fix(opencode): re-probe a v1 runtime when the provider is refreshed (#9593)
Updating OpenCode from 1.x to 2.x while the daemon runs left it on the v1
client: the version probe result was cached for the client's lifetime and the
binary update does not change provider config, so the client was never rebuilt.
Once the old v1 server exited, every request failed with "Request is not
supported by this version of OpenCode Server (Server responded with
text/html)". Refresh and Reload did not recover; only a daemon restart did.

A forced catalog refresh now drops a cached v1 selection so the next call
re-probes the binary. A v2 selection owns live sessions and stays. A guard keeps
an overlapping forced refresh from discarding the selection the first one made.

Fixes #9583

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 11:00:45 +00:00
paseo-bot[bot]andClaude Opus 5.5 2fa0e4b169 fix(app): load older history when an upward wheel lands at the start (#9570)
A wheel listener can run before or after the browser applies the scroll it
causes. When the reader had already stopped following output and the last
notch landed exactly at the history start, the listener saw the pre-scroll
position, did not re-arm history-start pagination, and no older page was
requested. The upward scroll now re-arms loading itself.

The turn-collapse journey hung on this race until its 120s timeout; the
older-history page wait now fails with a message after the expect timeout.

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 09:32:44 +00:00
ese111 d11239fd95 fix(plugin): accept all review attachment mode (#9590) 2026-11-14 09:04:58 +00:00
d30aa16beb Retrying a message that failed before reaching the provider delivers it (#9580)
* fix(server): let a message rejected before dispatch be retried

A send_agent_message_request with a messageId wrote its pending receipt
before loading the agent, setting its mode, and starting the run. When one
of those steps failed, nothing reached the provider, yet every retry with
the same messageId reported agent_request_outcome_unknown.

The pending receipt is now written at the dispatch boundary: the send
awaits beforeDispatch after the agent is loaded and its mode is set, just
before the prompt is handed to a run. A failure before that point leaves
no receipt, so the retry submits the message once. Failures after it stay
ambiguous, as before.

Closes #9297

Co-authored-by: tmad4000 <1430007+tmad4000@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(server): lock that a send failing after dispatch is never resubmitted

Scope the docs/hub.md retry sentence to direct sends: agent-sourced sends
still write their receipt before queueing.

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

* docs(hub): scope the retry promise to failures before the run starts

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

* docs(hub): name run startup as the ambiguous boundary

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: tmad4000 <1430007+tmad4000@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 08:10:41 +00:00
Mohamed Boudra a6a37b568d fix(app): correct composer glyph and placeholder tones (#9608)
* fix(app): mute labelled toolbar glyphs to match their labels

Touch-sized composer glyphs rested at foreground while the label beside them used foregroundMuted. Use the existing label/icon glyph roles: a labelled glyph takes the muted tone, while an icon-only glyph keeps the foreground header-icon treatment from #8909.

* test(app): keep toolbar tone coverage in the browser journey

The component-level probe asserted the color prop handed to a stub icon in JSDOM, which the testing rules forbid. The real-browser journey already checks the rendered stroke and label colors for both glyph roles.

* test(app): expect the muted tone for a labelled toolbar glyph

The composer layout test still asserted the old foreground tone for the label role.

* fix(app): quiet the composer placeholder to extra muted

The empty composer painted its placeholder with surface4, a surface token, instead of a text tone. Use foregroundExtraMuted so it sits below foregroundMuted and above the background, and document the composer-specific exception.

* test(app): clean up a composer tone fixture when setup fails

The helper now releases the seeded agent and repository if navigation or the readiness check throws before the test owns cleanup.
2026-11-14 13:44:33 +07:00
Mohamed Boudra 93d21e5612 Preserve the mobile sidebar when returning from Settings (#9641)
* fix(app): preserve mobile sidebar continuity through Settings

Retain the sidebar for the Settings visit so Back resumes the original
navigation surface without a new drawer animation. Native screen appearance
owns the handoff because pathname and layout updates precede presentation.

* fix(app): keep settings navigation imports lightweight

Keep the Settings visit state accessible without loading native presentation hooks, and exercise continuity with a scrolled list and complete paint capture.
2026-11-14 13:27:02 +07:00
Mohamed Boudra caae991a0f Keep sidebar toasts and bottom scrolling stable (#9639)
* fix(app): keep sidebar toast dismissal and bottom scrolling stable

Keep the positioned toast surface mounted through its native exit so its bottom anchor cannot collapse beneath the fading card. Reserve scrollable space below the final sidebar row without changing the scroll range when a toast appears.

* fix(app): preserve toast safety across themes and sidebar groups

Keep animated positioning in static React Native styles, extend the permanent scroll clearance to status grouping, and bound recording analysis to the requested frames.

* fix(app): preserve the toast surface when content changes

Replacing upload and reconnect status toasts must update the existing surface. A per-toast key retained the old surface during its exit, duplicating messages and breaking continuity.
2026-11-14 13:26:56 +07:00
Mohamed Boudra 046ac7b339 fix(app): keep cached workspace commands available during refresh (#9642)
Branch status updates reset draft command queries and discarded usable catalogs. Invalidate them instead and share the session freshness policy so drafts refresh in the background.
2026-11-14 13:26:36 +07:00
Mohamed Boudra 6213df73dc Download files from unavailable previews (#9640)
* feat(app): download files from unavailable previews

* fix(app): download files outside the workspace
2026-11-14 13:06:42 +07:00
Mohamed Boudra 02d3a05d2b Let scrolling cancel sidebar press highlights (#9637)
* fix(app): let scrolling cancel sidebar press feedback

The independent native highlight gesture painted on finger-down before a tap was recognized and competed with Pressable for mouse clicks. Use the standard Pressable lifecycle with a short activation delay so scrolling cancels pending feedback.

* fix(app): show press feedback on the usage summary

The summary only styled hover, leaving touch presses without visual acknowledgement. Share the standard delayed press primitive and paint its pressed state before opening Usage.

* fix(app): keep floating action press feedback opaque

Press styles replace the surface background. Give floating actions a solid pressed fill rather than a translucent overlay tint, and verify held feedback and release through the standard pressable.
2026-11-14 12:13:45 +07:00
Mohamed Boudra 175f36d64a Keep foreground Codex commands out of background tasks (#9635)
* fix(codex): keep foreground commands out of background tasks

Codex lists live terminals before their initial tool call returns. Exclude commands owned by an active turn so ordinary shell calls do not flash the background-work pill, while preserving processes left running by earlier turns.

* fix(codex): preserve foreground ownership during resume

Start terminal inventory ownership before installing notification handlers so autonomous commands emitted during thread resume remain foreground until their turn settles. Use named fields for the new inventory inputs.
2026-11-14 12:13:07 +07:00
Mohamed Boudra dd2281d82c fix(app): shorten reconnecting toast text (#9636) 2026-11-14 12:12:50 +07:00
Mohamed BoudraandNathan Green 2a9395e2d1 Light plugin themes and Pure Black keep their selected and hovered rows readable (#9579)
* fix(app): derive theme states and plugin fills so light and Pure Black themes stay readable

Hover, selected, the neutral fill and tertiary text were colors a theme
picked by eye, and plugin palettes filled them by borrowing `ring` and
`border`. A light plugin theme got a dark selected row under dark text,
Pure Black's hover barely separated from its surface, and light terminal
ANSI white was white on white.

States are now fixed WCAG-contrast steps from the surface they sit on,
toward the foreground, derived in styles/derive-colors.ts. Plugin
themes derive their neutral fill and tertiary text instead of aliasing
`ring` and `border`, and may set an optional `sidebar` color. The web
focus outline follows the theme's `ring`, and light terminal white and
bright white are derived to read on the background. Tokens with no
consumers are removed.

Existing plugin themes change look with no author action, and the
composer placeholder uses foregroundMuted like every other placeholder.

Co-authored-by: Nathan Green <342098+nathansgreen@users.noreply.github.com>

* fix(app): accept alpha hex in plugin theme colors when deriving

The plugin schema accepts #rgba and #rrggbbaa, but contrast derivation parsed
only opaque hex and threw, which would crash rendering for such a theme.
Derivation now reads those colors as the opaque color they sit on.

* fix(app): keep derived plugin fills and tertiary text readable on low-contrast palettes

A fixed contrast step for the neutral fill left text on message bubbles, secondary
buttons and badges at 2.6:1 on a dark palette whose body text is 4.75:1, and the
fixed fade made Solarized Light tertiary text 1.92:1. The fill step is now bounded by
the text it carries and tertiary text has a floor. The plugin reference states the
minimum Paseo version for sidebar and that the terminal white is derived on light
themes only.

* fix(app): keep the derived neutral fill visible when a theme's own text is quiet

Bounding the fill by text contrast could reach a step of 1 or less on a palette whose
body text is under about 3:1, returning the page background itself. The step now has a
minimum.

* docs(app): state the trade-off between the neutral fill step and text contrast

Below about 3.8:1 body text the fill keeps its minimum step and text on it falls under
3:1. The comment now says so and a test pins both outcomes.

* fix(app): leave the light terminal's ANSI white as it was

ANSI white is both text and a cell background, so no single palette value is readable in
both roles; making it readable as text broke black text on white cells. That needs a
contrast rule in the terminal renderer, so light white and bright white return to their
previous values and the derivation and its test floors are removed.

* feat(plugins): make derived theme colors overridable

Sidebar passive text was derived against the page even though timestamps render on hover and selected surfaces. Derive it against the resolved state surface and let contributed themes override each derived semantic role explicitly.

---------

Co-authored-by: Nathan Green <342098+nathansgreen@users.noreply.github.com>
2026-11-14 10:35:43 +07:00
Mohamed BoudraandJosh 85ce1aa828 fix(app): preserve settings scroll through appearance changes (#9605)
Preserve settings scroll and form edits across appearance changes while refreshing numeric typography at text and input leaves. Normalize displayed font sizes explicitly after commits.

Closes #9600
Closes #6931
Closes #6915

Co-authored-by: Josh <joshuaamayer@gmail.com>
2026-11-14 10:33:50 +07:00
265 changed files with 10736 additions and 2732 deletions
+13 -1
View File
@@ -186,7 +186,8 @@ Drop a hunk when the installed LegendList ships its fix.
`modules/paseo-scroll` replaces React Native's scroll view on both platforms with a subclass:
Android's `PaseoScrollView` and iOS's `PaseoScrollViewComponentView`.
`VisibleContentPositionClampTest` covers Android; iOS has no native test harness.
`VisibleContentPositionClampTest` and `SelectableTextTouchTest` cover Android; iOS has no native
test harness.
**Content clamp.** A mount that shrinks the content while `maintainVisibleContentPosition` is set
applies the shrink twice on both platforms: the scroll view clamps its offset to the new end during
@@ -206,5 +207,16 @@ React Native 0.88 skips the adjustment for a removed anchor
and skip its correction while the user drags ([user scroll](#user-scroll)). This one has no React
Native counterpart; it stays while the timeline uses the pin.
**Touched text (Android).** Chat text is selectable, and Android focuses selectable text when it is
touched. React Native scrolls a newly focused view fully into view, so tapping a link or a word, or
long-pressing to select, in a message partly off screen moved the timeline by up to a screen.
`PaseoScrollView` skips that scroll when the focused view is selectable, non-editable text in touch
mode. The text keeps focus, and a streaming reply's new text collapses its selection to the start,
which asks to reveal the reply's first line; that request is skipped too while nothing is selected.
A change in the timeline's height, such as the composer below it growing, also reveals the focused
view, and is skipped the same way. Inputs, focus moved by a keyboard, and a selection the user drags
still scroll into view. The inverted timeline guarded this with a rule tied to the inversion, which
went with it; keep this one while chat text is selectable.
Remove the content clamp and removed-anchor overrides once the installed React Native includes
their fixes.
+4 -4
View File
@@ -186,10 +186,10 @@ This is the Android counterpart of the iOS local-simulator flow in [development.
## Scroll view corrections
`modules/paseo-scroll` replaces React Native's vertical scroll view with `PaseoScrollView`, which
keeps React Native's scroll manager interface and corrects `maintainVisibleContentPosition` for the
chat timeline. [agent-stream-disclosure.md](agent-stream-disclosure.md#scroll-view-corrections)
lists the corrections and the React Native issues they work around. Its instrumentation test,
`VisibleContentPositionClampTest`, mounts through React Native's own mounting manager:
keeps React Native's scroll manager interface and corrects how the chat timeline holds its position.
[agent-stream-disclosure.md](agent-stream-disclosure.md#scroll-view-corrections) lists the
corrections and the React Native issues they work around. Its instrumentation tests run the scroll
view in a real window, mounting through React Native's own mounting manager and injecting touches:
```bash
cd packages/app/android
+7 -5
View File
@@ -20,7 +20,7 @@ A semantic element used in three or more places is a primitive. One of a kind is
Primitives live in `packages/app/src/components/ui/` and `packages/app/src/components/headers/`. Card and row layout live in `packages/app/src/styles/settings.ts`. Section structure lives in `packages/app/src/screens/settings/settings-section.tsx`.
A pressable styled to look like a button is wrong; the button is `<Button>` (`packages/app/src/components/ui/button.tsx`). A pressable wrapping an icon is wrong; the icon action is `<ToolbarButton>` (`packages/app/src/components/ui/pane-content-toolbar.tsx`), which owns the hitbox, the rounded `interactionHighlight` fill on hover and press, the compact size, and the tooltip. Rows on a menu surface are `<MenuItem>`, or `<ComposerTrackRow>` in a track panel; other rows are `<PressHighlight>` (`packages/app/src/components/ui/press-highlight.tsx`), which flashes on native the moment a finger lands. A bare `<Text>` styled to look like a section header is wrong; the section header is `<SettingsSection>` (`packages/app/src/components/settings/headings/settings-section.tsx`). A docs link in its header is its `link` prop, which renders `Label ↗` on the title's rail; a ghost button with an arrow is wrong. A custom `Modal` for a confirmation is wrong; the confirmation is `confirmDialog` (`packages/app/src/utils/confirm-dialog.ts`). A hand-rolled overflow menu is wrong; the menu is `<DropdownMenu>` (`packages/app/src/components/ui/dropdown-menu.tsx`). A hand-rolled status pill is wrong; the pill is `<StatusBadge>` (`packages/app/src/components/ui/status-badge.tsx`). A circle pinned over a scrolling surface is wrong; the floating action is `<FloatingActionButton>` (`packages/app/src/components/ui/floating-action-button.tsx`), one per surface, and the surface reserves `FLOATING_ACTION_BUTTON_CLEARANCE` below its content so the button never traps the last row.
A pressable styled to look like a button is wrong; the button is `<Button>` (`packages/app/src/components/ui/button.tsx`). A pressable wrapping an icon is wrong; the icon action is `<ToolbarButton>` (`packages/app/src/components/ui/pane-content-toolbar.tsx`), which owns the hitbox, the rounded `interactionHighlight` fill on hover and press, the compact size, and the tooltip. Rows on a menu surface are `<MenuItem>`, or `<ComposerTrackRow>` in a track panel; other rows are `<PressHighlight>` (`packages/app/src/components/ui/press-highlight.tsx`), which owns the pressed feedback: pass the pressed fill as `highlightStyle` and never paint it in your own style. iOS and web paint that fill; Android draws the platform ripple over the content instead, clipped to the row's corners. On native, feedback waits for a short press delay so a scroll that claims the touch shows none. Content that knocks out of the row's surface, such as a badge ring, follows the fill through `onHighlightChange`. A bare `<Text>` styled to look like a section header is wrong; the section header is `<SettingsSection>` (`packages/app/src/components/settings/headings/settings-section.tsx`). A docs link in its header is its `link` prop, which renders `Label ↗` on the title's rail; a ghost button with an arrow is wrong. A custom `Modal` for a confirmation is wrong; the confirmation is `confirmDialog` (`packages/app/src/utils/confirm-dialog.ts`). A hand-rolled overflow menu is wrong; the menu is `<DropdownMenu>` (`packages/app/src/components/ui/dropdown-menu.tsx`). A hand-rolled status pill is wrong; the pill is `<StatusBadge>` (`packages/app/src/components/ui/status-badge.tsx`). A circle pinned over a scrolling surface is wrong; the floating action is `<FloatingActionButton>` (`packages/app/src/components/ui/floating-action-button.tsx`), one per surface, and the surface reserves `FLOATING_ACTION_BUTTON_CLEARANCE` below its content so the button never traps the last row.
Before adding a new component, read `components/ui/`. The primitive usually exists.
@@ -40,9 +40,11 @@ Weight has three tiers, applied by role:
The rule, condensed: text that _names_ a surface or a group is `medium`. Text that lives _inside_ a surface or a group is `normal`. Top-of-screen titles are `<ScreenTitle>`, which is lighter still.
Foreground is for the thing being acted on: row titles, section headings, the selected sidebar item. `foregroundMuted` is for context: hints, descriptions, secondary metadata, idle sidebar items, placeholders, status text.
Foreground is for the thing being acted on: row titles, section headings, the selected sidebar item. `foregroundMuted` is for context: hints, descriptions, secondary metadata, idle sidebar items, placeholders, status text. The composer's empty placeholder is quieter; it uses `foregroundExtraMuted`.
`foregroundExtraMuted` is reserved for passive chrome that must sit behind muted text, such as an always-visible window control. Use the solid token instead of lowering SVG opacity; per-path opacity makes overlapping icon strokes render unevenly. Interactive hover and pressed states return to `foreground`.
`foregroundExtraMuted` is reserved for passive chrome that must sit behind muted text, such as an always-visible window control, and for the composer's empty placeholder. Use the solid token instead of lowering SVG opacity; per-path opacity makes overlapping icon strokes render unevenly. Interactive hover and pressed states return to `foreground`.
A theme states places and identity; `packages/app/src/styles/derive-colors.ts` derives their relationships. Sidebar hover and selected fills step from the sidebar toward the foreground. For plugin themes, passive text on those states derives against the actual state surface; page tertiary text is a separate relationship. Explicit plugin semantic overrides take precedence over derived defaults. Built-in themes retain their chosen passive text. `packages/app/src/styles/theme-contrast.test.ts` checks the built-in and representative plugin palettes against contrast floors.
Accent is the one CTA per surface. A `<Button variant="default">` filled with `accent` appears at most once on a page. Most pages have zero — settings is mostly toggles and text, the workspace pane is mostly content, the chat composer is the input itself.
@@ -70,7 +72,7 @@ the sidebar. Apply it as `backgroundColor`; setting `opacity` on the control als
Controls come in two sizes, defined once in `control-geometry.ts`. The default is the right size almost everywhere — pages, dialogs, forms, settings rows, the setup flow — so don't pass a size. It is 32px tall where a pointer drives it and 44px where controls are touch-sized, so every touch control meets the iOS HIG's 44pt hit region. `sm` is 28px pointer and 32px touch, with `fontSize.sm` labels, and is for tight chrome only: toolbars, nested settings rows, dense inline row actions. A `size="sm"` on an ordinary page or dialog is wrong, and so is choosing a size from `useIsCompactFormFactor()`.
Dense toolbar rows, such as the composer's, are the exception to the 44pt control. Touch-sized the row is 44pt tall and each control is a 36pt circle (`TOOLBAR_DISC_SIZE`): send and stop fill it, and every other control's pressed highlight fills it, so every highlight is the same fully rounded shape. Labelled pickers are 36pt pills. A control lays out at its full highlight (`toolbarSlot`, `toolbarContentSlot`) with no gap between controls (`TOOLBAR_CONTROL_GAP`), so neighbouring highlights touch and never overlap. Toolbar glyphs match the header icons: 20pt, the app's 1.5px stroke, resting at `foreground` (`toolbarGlyphsFor`). The context ring is 18pt with a 2px stroke (`TOOLBAR_RINGS`): a circle fills its box where an outline glyph does not, and a thin circle reads lighter than a glyph at the same stroke. A disc's solid glyph, such as the stop square, stays at the `md` size (`TOOLBAR_DISC_GLYPH_SIZES`). Pointer-sized toolbars keep 28px controls, a 4px gap, and a 16pt muted glyph that brightens on hover.
Dense toolbar rows, such as the composer's, are the exception to the 44pt control. Touch-sized the row is 44pt tall and each control is a 36pt circle (`TOOLBAR_DISC_SIZE`): send and stop fill it, and every other control's pressed highlight fills it, so every highlight is the same fully rounded shape. Labelled pickers are 36pt pills. A control lays out at its full highlight (`toolbarSlot`, `toolbarContentSlot`) with no gap between controls (`TOOLBAR_CONTROL_GAP`), so neighbouring highlights touch and never overlap. Touch-sized toolbar glyphs are 20pt at the app's 1.5px stroke. An icon-only control's glyph matches the header icons and rests at `foreground`; a glyph beside a label rests at `foregroundMuted` so the two read as one quiet unit (`toolbarGlyphsFor`). The context ring is 18pt with a 2px stroke (`TOOLBAR_RINGS`): a circle fills its box where an outline glyph does not, and a thin circle reads lighter than a glyph at the same stroke. A disc's solid glyph, such as the stop square, stays at the `md` size (`TOOLBAR_DISC_GLYPH_SIZES`). Pointer-sized toolbars keep 28px controls, a 4px gap, and a 16pt muted glyph that brightens on hover.
In the composer, the first glyph's ink starts on the text column's edge, and the trailing control is always a disc, so the row does not shift as it changes between voice mode, send and stop. The disc nests into the corner, as far from the right edge as from the bottom, and touch-sized the composer's corner radius is concentric with it (`packages/app/src/composer/input/toolbar-rails.ts`). Touch-sized the disc keeps a 6pt clearance from its neighbour, because it is always painted and a pressed neighbour would merge with it. The attach button sits 2pt closer to the picker than its highlight would put it: the plus is all thin arms, so the full gap reads wide. That offset is set by eye.
@@ -267,7 +269,7 @@ New status pills use `<StatusBadge>`. Identity, shortcut, and interactive link b
- A "Settings" CTA on a detail page. Detail pages are settings; settings is reached from the sidebar, the host entry, or a row's kebab menu.
- The word "checkout" in UI strings or identifiers. The term is "workspace".
- New color tokens or hardcoded hex outside the palette. The identity color table is the documented exception (§13), not a license.
- Placeholder text dimmed beyond `foregroundMuted`. No extra opacity, no italics, no ghost-text.
- Placeholder text dimmed beyond `foregroundMuted`, except the composer's empty placeholder, which uses `foregroundExtraMuted`. No extra opacity, no italics, no ghost-text.
- `onPointerEnter` and `onPointerLeave`. They do not fire on native iOS. Hover uses Pressable's `onHoverIn`/`onHoverOut` gated with `isHovered || isCompact || isNative`.
- Raw DOM APIs without an `isWeb` guard.
- Spacing values outside the scale. `padding: 20` and `gap: 10` are wrong.
+1 -1
View File
@@ -49,7 +49,7 @@ than downloading a published desktop release.
- The **server itself** (e.g. when launched by the desktop app or `npm run start`) defaults to `~/.paseo` (see `packages/server/src/server/paseo-home.ts`).
- **Repo dev scripts** default to `$ROOT/.dev/paseo-home`, where `$ROOT` is the current checkout or worktree root. This keeps all dev state scoped to the checkout instead of the packaged desktop app.
- **`npm run cli -- ...`** runs through the same dev-home wrapper as the dev scripts, so the in-repo CLI automatically targets the current checkout's `.dev/paseo-home` and configured dev daemon endpoint.
- **Paseo-created worktrees** seed `$PASEO_WORKTREE_PATH/.dev/paseo-home` from `$PASEO_SOURCE_CHECKOUT_PATH/.dev/paseo-home` by copying durable JSON metadata. Runtime files like pid files, sockets, and logs are not copied.
- **Paseo-created worktrees** seed `$PASEO_WORKTREE_PATH/.dev/paseo-home` from `$PASEO_SOURCE_CHECKOUT_PATH/.dev/paseo-home` by copying agent and project JSON metadata and configuration. Both this setup and `PASEO_DEV_SEED_HOME` use the same seeder, which disables `agents.resumeOnStartup` in the copied config. Startup repairs copied running records without resuming agents or sending recovery prompts. Schedules and heartbeats (stored under `schedules/`), runtime files, identities, sockets, and logs are not copied. Existing nonempty homes are left unchanged unless explicitly reset. Copied agents still reference the original provider sessions and working directories: manually prompting one reaches that real session.
- **This repo's worktree setup** also best-effort seeds `packages/app/ios` and the newest `.dev/ios-build` entry from the source checkout so iOS simulator services can reuse native project and Xcode cache state when it is safe enough to do so.
Override knobs:
+39 -9
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.
+4
View File
@@ -61,6 +61,10 @@ The daemon journals the assigned agent ID before creation. Concurrent retries sh
and a retry after a lost acknowledgement or restart returns the durable agent without creating a
workspace or starting a turn. Deleting that agent does not make its old creation key reusable.
Confirmed message delivery is also journaled, so retrying its message ID does not submit it again.
A direct send (one without `sourceAgentId`) rejected before its prompt is handed to a run, for
example because the agent could not be loaded or the requested mode could not be set, leaves no
receipt, so retrying its message ID submits it. A failure during or after run startup is treated
as an unfinished delivery.
An unfinished delivery after restart reports `agent_request_outcome_unknown`: a provider can have
accepted a prompt before the daemon recorded success, so automatic resubmission could duplicate
work. Inspect the agent before choosing a new message ID. Receipts contain identity and request
+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
+13 -4
View File
@@ -27,9 +27,17 @@ interface MobilePanelSelection {
Every semantic target change increments `revision`. Repeating the current target is idempotent.
Compact panel selection is not persisted; a cold start begins at `agent`.
Only an overlay sidebar holds `agent-list`, and only a compact layout holds `file-explorer`. App
chrome publishes the layout (compact or wide, and the left sidebar presentation `overlay`, `dock`, or
`hidden`) to `panel-store`, which drops any target the layout does not present. A stale
An overlay sidebar holds `agent-list`, and only a compact layout holds `file-explorer`.
A compact Settings visit entered from the sidebar suspends it: the host stays mounted and
laid out on native. Its target and scroll position survive until return to the originating
workspace. Route changes and `onLayout` both precede native screen appearance, so the sidebar
stays visible until Settings emits the native navigator's `transitionEnd`. Web uses layout.
Input and activity stop during the handoff. Widening or leaving for another destination discards
the return. Commands issued while suspended settle immediately behind Settings, so explicit
agent navigation does not reveal a closing sidebar animation.
App chrome publishes the layout (compact or wide, and the left sidebar presentation `overlay`,
`dock`, `hidden`, or `suspended`) to `panel-store`, which drops any target the layout does not present. A stale
`agent-list` would reopen the overlay on the next collapse; a stale `file-explorer` after widening
would block the navigation swipe, which begins only from `agent`.
@@ -120,7 +128,8 @@ definition, no longer eligible to begin.
the overlay host fills the window.
- The overlay's backdrop covers the whole window; safe-area padding applies to the sidebar and
workspace content only. It closes by swiping back, tapping the backdrop, or picking a destination:
every sidebar destination closes the overlay before it navigates.
workspace destinations close the overlay before navigating. Settings suspends the compact
sidebar for the duration of its visit instead.
- Animated panel nodes use React Native static styles plus inline theme values. Do not attach
Unistyles-generated styles to those nodes; Unistyles and Reanimated patching the same Fabric node
has caused native crashes.
+8 -15
View File
@@ -49,17 +49,9 @@ Large unbounded timeline responses can exceed relay frame limits, so catch-up us
Page limits are projected-item targets. A tool call lifecycle is one projected item even if it spans many source sequence numbers, and assistant/reasoning chunks are merged before counting. The response carries `seqStart`, `seqEnd`, `sourceSeqRanges`, and `collapsed` so clients can advance sequence cursors without rendering delta rows.
When live delivery detects a sequence gap, the app fetches `direction: "after"` its end cursor. If
the daemon responds with `hasNewer: true`, the app immediately fetches the next page from
`endCursor`. Gap recovery is complete only when `hasNewer: false`.
A chat that was empty when it last caught up has no end cursor to compare a live row against. Such a
row starts coverage only if it is the epoch's first row, seq 1. A later row means the chat missed the
rows before it, such as a prompt sent while the app was offline, so the app drops it and recovers
after seq 0. Coverage started at that later row would make the resume page look already applied, and
the chat and its replica cache would keep only the end of the reply. A recovery that waits behind a
running catch-up starts after whatever that catch-up applied, because the app applies a forward page
row by row and an older cursor repeats rows.
When live delivery detects a sequence gap, the app fetches `direction: "after"`. If the daemon
responds with `hasNewer: true`, the app immediately fetches the next page from `endCursor`. Gap
recovery is complete only when `hasNewer: false`.
Initialization timeouts guard lack of catch-up progress, not the full multi-page sync. A successful page that queues the next `after` page refreshes the watchdog.
@@ -103,10 +95,11 @@ Provider message IDs are not guaranteed for every displayed item. Paseo-generate
Actions that address a point in chat history, such as Fork, use the daemon timeline `epoch` plus the projected item's `seqEnd`. The app carries that position on the rendered assistant item for both live and fetched history. When adjacent projected chunks merge, the merged item retains the newer chunk's position.
The daemon validates the epoch and locates the projected item at the selected position. A fork
includes projected items through that checkpoint. If an item the fork copies spans the checkpoint
and changed afterward, the daemon refuses that fork with an actionable error: discarded historical
payloads cannot be reconstructed from sequence metadata. Plugin cards are not copied, so a card
updated after the checkpoint does not block it. Forking the current context remains available.
includes projected items through that checkpoint. A tool call that started before the checkpoint
and changed after it, such as background work that outlives a reply, is copied as still running:
the projection keeps only its latest payload, so its state at the checkpoint can't be read back.
Plugin cards are not copied. A position from another epoch, or one that is gone, is refused;
forking the current context remains available.
## Resume behavior
+1 -1
View File
@@ -346,7 +346,7 @@ Gotchas:
- **Narrow the discriminated union before spreading.** `updateTheme`'s updater returns the theme union; spreading the union widens `colorScheme` to `"light" | "dark"`, which is assignable to neither concrete member. Branch on `t.colorScheme` so each branch spreads a single narrowed theme type (no `as`).
- **`lineHeight.diff` is the code/diff line-height axis** — it is coupled to the code-font-size control (≈ `codeFontSize * 1.5`). Do NOT use it for prose. Markdown body line-height scales with content size (`Math.round(theme.fontSize.content * 1.4)`); routing prose through `lineHeight.diff` clips text at small code sizes.
- **High-churn draft values** (live-while-typing in the appearance preview) bypass the theme: apply them as inline styles marked with `inlineUnistylesStyle` so per-keystroke values don't grow the `#unistyles-web` CSS registry.
- **Web numeric appearance tokens need a render; native tracked styles update in place.** On web, string tokens such as colors become CSS variables, but numeric tokens (font sizes, line heights) are baked into generated classes. `AppearanceStyleBoundary` remounts web shell chrome and explorer content to refresh those classes. Native sidebars and explorer hosts stay outside appearance keys: their gesture refs outlive detached dependents until Gesture Handler's passive cleanup, so remounting a related gesture can query an unmounted view. Their tracked native styles and existing themed leaf props update without replacing the gesture hosts or draggable lists. Parsed PR markdown uses its own `withUnistyles` style mapping.
- **Web numeric appearance tokens need a render; native tracked styles update in place.** On web, string tokens such as colors become CSS variables, but numeric tokens (font sizes, line heights) are baked into generated classes. `AppearanceStyleBoundary` remounts web shell chrome and explorer content to refresh those classes. Settings retains its screen and form; `AppearanceText` refreshes numeric text styles at the leaf, and its font fields use `withUnistyles` to refresh input styles without replacing the inputs. Native sidebars and explorer hosts stay outside appearance keys: their gesture refs outlive detached dependents until Gesture Handler's passive cleanup, so remounting a related gesture can query an unmounted view. Their tracked native styles and existing themed leaf props update without replacing the gesture hosts or draggable lists. Parsed PR markdown uses its own `withUnistyles` style mapping.
- **Appearance keys belong below native lifetime owners.** `ThemedStack` wraps screen content through `screenLayout`, preserving navigator identity; nested navigators are listed in `nestedNavigatorScreens` and own their own screen boundaries. The remaining shell surfaces retain their appearance refresh separately from native panel hosts. A key above a native stack can detach its screen container inside a FragmentManager transaction during settings hydration. Keep both navigators and retained panel gesture hosts outside those keys. `applyAppearance` patches the active theme before inactive registry entries so subscribers receive the committed values in the same update. Do not add local appearance keys or a boundary above a navigator.
- **Dynamic font tokens stay widened.** `fontFamily`, `fontSize`, and `lineHeight` on `commonTheme` are annotated `string`/`number` (not narrowed by `as const`) so the updater's return assigns; the platform default stacks live in `DEFAULT_UI_FONT_STACK` / `DEFAULT_MONO_FONT_STACK`.
@@ -527,17 +527,7 @@ async function expectHiddenStreamingSubmissionOrderAfterWorkspaceEviction(
expect(gate.getClientRequestCount("agent.timeline.set_subscription.request")).toBe(
subscriptionRequests,
);
const requestsBeforeRelease = rememberTimelineRequestCounts(gate, target.agentId);
gate.releaseHeldAgentStreamEvent("turn_started");
// The chat never saw the hidden prompt's live row, so the reply's first row is a gap.
// The chat recovers the missing rows once, while its workspace is still evicted.
await expect
.poll(() => gate.getTimelineRequestCount("after", target.agentId))
.toBe(requestsBeforeRelease.after + 1);
await gate.waitForServerMessage(
"fetch_agent_timeline_response",
gate.getClientRequestCount("fetch_agent_timeline_request"),
);
const requestsBeforeReturn = rememberTimelineRequestCounts(gate, target.agentId);
await waitForWorkspaceInSidebar(page, {
serverId: getServerId(),
@@ -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");
@@ -14,8 +14,6 @@ import {
answerFreshnessCheck,
commitTimelineTurnsWhileDisconnected,
disconnectViewedTimeline,
expectEmptyChatSynced,
expectPromptAndWholeReply,
expectResumeAnsweredWithoutFetch,
expectResumeOverflowFallsBackToOneTail,
hideTabAndReturn,
@@ -24,11 +22,7 @@ import {
restoreUnchangedViewedTimeline,
restoreViewedTimelineHoldingCatchUp,
restoreViewedTimelineWithHeldResponse,
restoreViewedTimelineWithLiveRowsFirst,
reopenWithFreshnessCheckUnanswered,
seedStreamingReplyAgent,
sendPromptWhileDisconnected,
stopStreamingReply,
waitForResumeAnswer,
reopenAppWatchingForUpdatingToast,
watchForUpdatingToast,
@@ -153,27 +147,4 @@ test.describe("Agent timeline resume", () => {
await agent.cleanup();
}
});
test("an empty chat that reconnects mid-reply keeps the prompt and the whole reply", async ({
page,
}) => {
test.setTimeout(120_000);
const gate = await installDaemonWebSocketGate(page);
const agent = await seedStreamingReplyAgent();
try {
await openAgentTimeline(page, agent);
await expectEmptyChatSynced(page, gate);
await disconnectViewedTimeline(page, gate);
await sendPromptWhileDisconnected(agent);
await restoreViewedTimelineWithLiveRowsFirst(gate);
const reply = await stopStreamingReply(agent);
await expectPromptAndWholeReply(page, agent, reply);
await page.reload();
await expectPromptAndWholeReply(page, agent, reply);
} finally {
gate.restore();
await agent.cleanup();
}
});
});
@@ -37,7 +37,7 @@ test("keeps the selected workspace visible in Light", async ({ page }, testInfo)
await row.click();
await expect(row).toHaveAttribute("aria-selected", "true");
await expect(row).toHaveCSS("background-color", "rgb(212, 212, 216)");
await expect(row).toHaveCSS("background-color", "rgb(212, 212, 213)");
await page.screenshot({
path: testInfo.outputPath("light-selected-workspace.png"),
fullPage: true,
@@ -64,7 +64,7 @@ test("keeps the selected workspace visible in Pure black", async ({ page }, test
await row.click();
await expect(row).toHaveAttribute("aria-selected", "true");
await expect(row).toHaveCSS("background-color", "rgb(17, 17, 17)");
await expect(row).toHaveCSS("background-color", "rgb(29, 29, 29)");
await page.screenshot({
path: testInfo.outputPath("pure-black-selected-workspace.png"),
fullPage: true,
@@ -2297,13 +2297,9 @@ async function changeCodeTypographyFromSettings(
await page.getByTestId("sidebar-settings").click();
await expect(page).toHaveURL(new RegExp(`${buildSettingsSectionRoute("general")}|/settings$`));
await page.getByRole("button", { name: "Appearance" }).click();
// A committed font change remounts the screen (AppearanceStyleBoundary), and the new form starts
// from the stored size. Edit the size only in the remounted form, or the remount drops it.
const sizeField = page.getByLabel("Code font size");
await sizeField.evaluate((field) => field.setAttribute("data-e2e-before-font-family", ""));
await page.getByLabel("Code font family").fill(typography.fontFamily);
await page.getByLabel("Code font family").press("Enter");
await expect(page.locator("[data-e2e-before-font-family]")).toHaveCount(0);
await sizeField.fill(String(typography.fontSize));
await page.getByLabel("Code font size").press("Enter");
await expect(page.getByLabel("Code font family")).toHaveValue(typography.fontFamily);
@@ -1,5 +1,12 @@
import { expect, test } from "../support/fixtures";
import { openCommandCenter } from "../support/helpers/command-center";
import { openSettings } from "../support/helpers/app";
import {
closeCommandCenter,
expectCommandCenterTab,
expectCommandCenterTabs,
openCommandCenter,
selectCommandCenterTab,
} from "../support/helpers/command-center";
import {
delayDirectorySuggestionResponses,
holdDirectorySuggestionResponses,
@@ -17,6 +24,7 @@ import {
} from "../support/helpers/command-center-scroll";
import { expectFileTabOpen } from "../support/helpers/file-explorer";
import { gotoWorkspace } from "../support/helpers/launcher";
import { getServerId } from "../support/helpers/server-id";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
const FILE_PATH =
@@ -67,7 +75,7 @@ test("workspace file search stays geometrically stable through delayed loading a
const panel = page.getByTestId("command-center-panel");
await expect(panel).toBeVisible({ timeout: 30_000 });
await expect(panel.getByRole("button", { name: "Files" })).toBeVisible();
await expectCommandCenterTab(panel, "Files");
await startCommandCenterLayoutObservation(page);
await panel.getByTestId("command-center-input").fill(FILE_NAME);
@@ -97,26 +105,90 @@ test("workspace file search stays geometrically stable through delayed loading a
}
});
test("dropping the files scope leaves the search row the same height", async ({ page }) => {
test("command center tabs rotate with Tab and never move the header", async ({ page }) => {
const seeded = await seedWorkspace({
repoPrefix: "command-center-file-search-scope-",
title: "Scope chip height",
repoPrefix: "command-center-tabs-",
title: "Tab rotation",
});
try {
await gotoWorkspace(page, seeded.workspaceId);
await page.keyboard.press("Meta+P");
const panel = page.getByTestId("command-center-panel");
await expect(panel).toBeVisible({ timeout: 30_000 });
const header = panel.getByTestId("command-center-header");
const scoped = await header.boundingBox();
const input = panel.getByTestId("command-center-input");
await page.getByTestId("command-center-files-scope").click();
await expect(page.getByTestId("command-center-files-scope")).toHaveCount(0);
const unscoped = await header.boundingBox();
await test.step("⌘P opens on Files and Backspace on an empty field returns to All", async () => {
await page.keyboard.press("Meta+P");
await expect(panel).toBeVisible({ timeout: 30_000 });
await expectCommandCenterTab(panel, "Files");
await page.keyboard.press("Backspace");
await expectCommandCenterTab(panel, "All");
});
expect(scoped?.height).toBe(unscoped?.height);
const results = panel.getByTestId("command-center-results");
const headerOnAll = await header.boundingBox();
const resultsTopOnAll = (await results.boundingBox())?.y;
await test.step("Tab walks forward and wraps; Shift+Tab walks back", async () => {
await page.keyboard.press("Tab");
await expectCommandCenterTab(panel, "Workspaces");
await page.keyboard.press("Tab");
await expectCommandCenterTab(panel, "Files");
await page.keyboard.press("Tab");
await expectCommandCenterTab(panel, "Actions");
expect(await header.boundingBox()).toEqual(headerOnAll);
expect((await results.boundingBox())?.y).toBe(resultsTopOnAll);
await page.keyboard.press("Tab");
await expectCommandCenterTab(panel, "All");
await page.keyboard.press("Shift+Tab");
await expectCommandCenterTab(panel, "Actions");
await page.keyboard.press("Shift+Tab");
await expectCommandCenterTab(panel, "Files");
});
await test.step("rotating keeps the query", async () => {
await input.fill("tab rot");
await page.keyboard.press("Tab");
await expectCommandCenterTab(panel, "Actions");
await expect(input).toHaveValue("tab rot");
});
await test.step("clicking a tab keeps typing in the search field", async () => {
await selectCommandCenterTab(panel, "Workspaces");
await page.keyboard.press("Backspace");
await expect(input).toHaveValue("tab ro");
await expect(
panel.getByTestId(`command-center-workspace-${getServerId()}:${seeded.workspaceId}`),
).toBeVisible();
});
await test.step("⌘K opens on All", async () => {
await page.keyboard.press("Escape");
await expect(panel).not.toBeVisible();
await page.keyboard.press("Meta+K");
await expectCommandCenterTab(panel, "All");
});
await test.step("in a workspace every tab is offered", async () => {
await expectCommandCenterTabs(panel, ["All", "Workspaces", "Files", "Actions"]);
});
await test.step("outside a workspace there is no Files tab, and Tab skips it", async () => {
const outside = { workspaceOpen: false };
await closeCommandCenter(page);
await openSettings(page);
await page.keyboard.press("Meta+K");
await expectCommandCenterTab(panel, "All", outside);
await expectCommandCenterTabs(panel, ["All", "Workspaces", "Actions"]);
await page.keyboard.press("Tab");
await expectCommandCenterTab(panel, "Workspaces", outside);
await page.keyboard.press("Tab");
await expectCommandCenterTab(panel, "Actions", outside);
await page.keyboard.press("Tab");
await expectCommandCenterTab(panel, "All", outside);
await page.keyboard.press("Shift+Tab");
await expectCommandCenterTab(panel, "Actions", outside);
});
} finally {
await seeded.cleanup();
}
@@ -7,8 +7,8 @@ import {
searchCommandCenter,
warmCommandCenterRows,
expectOnlyCommandCenterRowRendered,
expectCommandCenterAgentWindow,
scrollToOldestCommandCenterAgent,
expectCommandCenterWorkspaceWindow,
scrollToOldestCommandCenterWorkspace,
} from "../support/helpers/command-center-performance";
import { gotoAppShell } from "../support/helpers/app";
import {
@@ -19,7 +19,7 @@ import {
expectPrimaryCommandCenterActions,
expectTightTwoLineResultSpacing,
expectVisibleKeyboardNavigationNotToScroll,
expectWorkspaceResultsToBeLimitedUntilSearch,
expectRecentWorkspacesUntilSearch,
observeCommandCenterScroll,
openCommandCenterWithKeyboard,
} from "../support/helpers/command-center-scroll";
@@ -63,9 +63,9 @@ test("Command Center preserves manual scrolling through live updates and resumes
await expectCommandCenterToRemainAtTheTop(page);
await expectPrimaryCommandCenterActions(panel);
await expectVisibleKeyboardNavigationNotToScroll(page, panel);
// The empty query lists the most recent workspaces, so the last one seeded leads it.
// All leads with the most recent workspaces, so the last one seeded leads it.
await expectTightTwoLineResultSpacing(panel, "Command Center Scroll 08");
await expectWorkspaceResultsToBeLimitedUntilSearch(panel);
await expectRecentWorkspacesUntilSearch(panel);
const offset = await scrollCommandCenterAwayFromSelection(page, panel);
await seeded.client.setWorkspaceTitle(newestWorkspaceId, "Command Center Scroll renamed");
await expect(panel.getByText("Command Center Scroll renamed", { exact: true })).toHaveCount(1);
@@ -76,42 +76,50 @@ test("Command Center preserves manual scrolling through live updates and resumes
}
});
test("live search updates render only the changed row and keep agents virtualized", async ({
test("live search updates render only the changed row and keep workspaces virtualized", async ({
page,
}) => {
test.setTimeout(120_000);
const seeded = await seedSearchableCommandCenter();
const agentWorkspaceRowId = `workspace:${getServerId()}:${seeded.workspaceId}`;
try {
await observeCommandCenterRowRenders(page);
await gotoAppShell(page);
const panel = await openCommandCenterWithKeyboard(page);
await searchCommandCenter(panel, "Search agent");
await expect(panel.getByText("Search agent 059", { exact: true })).toBeVisible();
await warmCommandCenterRows(page);
await expectCommandCenterAgentWindow(panel, seeded.agents.length);
await seeded.client.updateAgent(seeded.agents[59].id, { name: "Search agent 059 revised" });
await expect(panel.getByText("Search agent 059 revised", { exact: true })).toBeVisible();
await expectOnlyCommandCenterRowRendered(
page,
`agent:${getServerId()}:${seeded.agents[59].id}`,
);
await test.step("renaming a matched agent re-renders only its workspace", async () => {
await searchCommandCenter(panel, "Search agent 002");
const agentLine = panel
.getByTestId(`command-center-workspace-${getServerId()}:${seeded.workspaceId}`)
.getByTestId("command-center-workspace-agent");
await expect(agentLine).toHaveText("Search agent 002");
await warmCommandCenterRows(page);
await seeded.client.updateAgent(seeded.agents[2].id, { name: "Search agent 002 revised" });
await expect(agentLine).toHaveText("Search agent 002 revised");
await expectOnlyCommandCenterRowRendered(page, agentWorkspaceRowId);
});
await searchCommandCenter(panel, "Search workspace");
await expect(panel.getByText("Search workspace 039", { exact: true })).toBeVisible();
await warmCommandCenterRows(page);
await seeded.client.setWorkspaceTitle(seeded.workspaces[39].id, "Search workspace 039 revised");
await expect(panel.getByText("Search workspace 039 revised", { exact: true })).toBeVisible();
await expectOnlyCommandCenterRowRendered(
page,
`workspace:${getServerId()}:${seeded.workspaces[39].id}`,
);
await test.step("renaming a workspace re-renders only its row", async () => {
await searchCommandCenter(panel, "Search workspace");
await expect(panel.getByText("Search workspace 039", { exact: true })).toBeVisible();
await warmCommandCenterRows(page);
await seeded.client.setWorkspaceTitle(
seeded.workspaces[39].id,
"Search workspace 039 revised",
);
await expect(panel.getByText("Search workspace 039 revised", { exact: true })).toBeVisible();
await expectOnlyCommandCenterRowRendered(
page,
`workspace:${getServerId()}:${seeded.workspaces[39].id}`,
);
});
await searchCommandCenter(panel, "Search agent");
await warmCommandCenterRows(page);
await scrollToOldestCommandCenterAgent(panel);
await expect(panel.getByText("Search agent 000", { exact: true })).toBeVisible();
await expectCommandCenterAgentWindow(panel, seeded.agents.length);
await test.step("a long list mounts only a window of rows", async () => {
await expectCommandCenterWorkspaceWindow(panel, seeded.workspaces.length + 1);
await scrollToOldestCommandCenterWorkspace(panel);
await expect(panel.getByText("Search workspace 000", { exact: true })).toBeVisible();
await expectCommandCenterWorkspaceWindow(panel, seeded.workspaces.length + 1);
});
} finally {
await seeded.cleanup();
}
@@ -4,7 +4,12 @@ import { test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { createIdleAgent } from "../support/helpers/archive-tab";
import { expectAgentTabActive } from "../support/helpers/launcher";
import { openCommandCenter } from "../support/helpers/command-center";
import {
commandCenterSection,
expectCommandCenterTab,
openCommandCenter,
selectCommandCenterTab,
} from "../support/helpers/command-center";
import { addOfflineHostAndReload } from "../support/helpers/hosts";
import { expectAppRoute } from "../support/helpers/route-assertions";
import { seedWorkspace } from "../support/helpers/seed-client";
@@ -21,9 +26,7 @@ const AGENT_TITLE = "Fix checkout retries";
test.describe("Command center workspaces", () => {
test.describe.configure({ timeout: 180_000 });
test("agent and workspace results retain their icons and overlay live status", async ({
page,
}) => {
test("workspace results overlay live status", async ({ page }) => {
const workspace = await seedRunningMockAgentWorkspace({
repoPrefix: "command-center-status-",
title: "Review checkout retries",
@@ -34,15 +37,13 @@ test.describe("Command center workspaces", () => {
await openAgentRoute(page, workspace);
await expectAgentTabActive(page, workspace.agentId);
const panel = await openCommandCenter(page);
const agent = panel.getByTestId(`command-center-agent-${getServerId()}:${workspace.agentId}`);
// Recent workspaces leaves out the open one; the Workspaces tab lists every workspace.
await selectCommandCenterTab(panel, "Workspaces");
const row = panel.getByTestId(
`command-center-workspace-${getServerId()}:${workspace.workspaceId}`,
);
await expect(agent.getByRole("progressbar", { name: "Agent running" })).toBeVisible();
await expect(row.getByRole("progressbar", { name: "Agent running" })).toBeVisible();
await expect(agent.getByRole("img", { name: "Mock agent" })).toBeVisible();
await workspace.client.cancelAgent(workspace.agentId);
await expect(agent.getByRole("progressbar")).toHaveCount(0);
await expect(row.getByRole("progressbar")).toHaveCount(0);
} finally {
await workspace.cleanup();
@@ -80,9 +81,11 @@ test.describe("Command center workspaces", () => {
});
const panel = await openCommandCenter(page);
await selectCommandCenterTab(panel, "Workspaces");
const row = panel.getByTestId(
`command-center-workspace-${getServerId()}:${seeded.workspaceId}`,
);
const agentLine = row.getByTestId("command-center-workspace-agent");
await expect(row).toBeVisible({ timeout: 30_000 });
await expect(row).toContainText(WORKSPACE_TITLE);
await expect(row).toContainText(WORKSPACE_BRANCH);
@@ -92,65 +95,151 @@ test.describe("Command center workspaces", () => {
await expect(subtitle).toContainText(PRIMARY_HOST_LABEL);
await expect(subtitle).toContainText(seeded.projectDisplayName);
await expect(subtitle).toContainText(WORKSPACE_BRANCH);
await expect(agentLine).toHaveCount(0);
// The agent subtitle is unchanged by the shared-helper refactor.
const agentRow = panel.getByTestId(`command-center-agent-${getServerId()}:${agent.id}`);
await expect(agentRow).toContainText(AGENT_TITLE);
await expect(agentRow).toContainText(PRIMARY_HOST_LABEL);
await expect(agentRow).toContainText(WORKSPACE_TITLE);
await expect(agentRow).not.toContainText(seeded.repoPath);
const workspaceSectionTop = await panel
.getByText("Workspaces", { exact: true })
.evaluate((element) => element.getBoundingClientRect().top);
const agentSectionTop = await panel
.getByText("Agents", { exact: true })
.evaluate((element) => element.getBoundingClientRect().top);
expect(workspaceSectionTop).toBeLessThan(agentSectionTop);
const input = panel.getByTestId("command-center-input");
await input.fill(PRIMARY_HOST_LABEL);
await expect(row).toBeVisible();
await expect(agentRow).toBeVisible();
await input.fill(WORKSPACE_BRANCH);
await expect(row).toBeVisible();
await expect(agentRow).not.toBeVisible();
await input.fill(WORKSPACE_TITLE);
await expect(row).toBeVisible();
await expect(agentRow).toBeVisible();
await input.fill(seeded.repoPath);
await expect(agentRow).toBeVisible();
await expect(row).not.toBeVisible();
// The project name is now part of the workspace searchText.
await input.fill(seeded.projectDisplayName);
await expect(row).toBeVisible();
await input.fill(AGENT_TITLE);
await expect(agentRow).toBeVisible();
await expect(row).not.toBeVisible();
await input.fill(WORKSPACE_TITLE);
await page.keyboard.press("Enter");
await expectAppRoute(page, buildHostWorkspaceRoute(getServerId(), seeded.workspaceId), {
timeout: 30_000,
await test.step("a workspace matches on its own fields", async () => {
const input = panel.getByTestId("command-center-input");
for (const query of [
PRIMARY_HOST_LABEL,
WORKSPACE_BRANCH,
WORKSPACE_TITLE,
seeded.projectDisplayName,
]) {
await input.fill(query);
await expect(row).toBeVisible();
await expect(agentLine).toHaveCount(0);
}
// `cwd` is never shown, so it must never match.
await input.fill(seeded.repoPath);
await expect(row).not.toBeVisible();
});
await test.step("find an agent and open its workspace tab", async () => {
const agentPanel = await openCommandCenter(page);
await agentPanel.getByTestId("command-center-input").fill(AGENT_TITLE);
await test.step("an agent title finds its workspace and names the agent", async () => {
await panel.getByTestId("command-center-input").fill(AGENT_TITLE);
await expect(agentLine).toHaveText(AGENT_TITLE);
await expect(panel.getByText("Agents", { exact: true })).toHaveCount(0);
await page.keyboard.press("Enter");
await expectAgentTabActive(page, agent.id);
});
await test.step("a workspace match opens the workspace", async () => {
const searchAgain = await openCommandCenter(page);
await expectCommandCenterTab(searchAgain, "All");
await searchAgain.getByTestId("command-center-input").fill(WORKSPACE_TITLE);
await page.keyboard.press("Enter");
await expectAppRoute(page, buildHostWorkspaceRoute(getServerId(), seeded.workspaceId), {
timeout: 30_000,
});
});
} finally {
await seeded.cleanup();
}
});
test("All leads with the three most recent workspaces other than the open one", async ({
page,
}) => {
const older = [];
for (const title of ["Recent oldest", "Recent third", "Recent second", "Recent newest"]) {
older.push(await seedWorkspace({ repoPrefix: "command-center-recent-", title }));
}
const current = await seedWorkspace({
repoPrefix: "command-center-recent-",
title: "Open one",
});
try {
await page.goto(buildHostWorkspaceRoute(getServerId(), current.workspaceId));
const panel = await openCommandCenter(page);
await expectCommandCenterTab(panel, "All");
await expect(commandCenterSection(panel, "Recent workspaces")).toBeVisible();
const rowOf = (workspaceId: string) =>
panel.getByTestId(`command-center-workspace-${getServerId()}:${workspaceId}`);
await expect(rowOf(older[3].workspaceId)).toBeVisible({ timeout: 30_000 });
await expect(rowOf(older[2].workspaceId)).toBeVisible();
await expect(rowOf(older[1].workspaceId)).toBeVisible();
await expect(rowOf(older[0].workspaceId)).toHaveCount(0);
await expect(rowOf(current.workspaceId)).toHaveCount(0);
await expect(commandCenterSection(panel, "Workspaces")).toHaveCount(0);
// The newest recent workspace is selected, so Enter goes straight back to it.
await page.keyboard.press("Enter");
await expectAppRoute(page, buildHostWorkspaceRoute(getServerId(), older[3].workspaceId), {
timeout: 30_000,
});
} finally {
for (const seeded of [current, ...older]) await seeded.cleanup();
}
});
test("archived workspaces follow active matches and open where they can be unarchived", async ({
page,
}) => {
const active = await seedWorkspace({
repoPrefix: "command-center-archived-active-",
title: "Billing dashboard",
});
const archived = await seedWorkspace({
repoPrefix: "command-center-archived-",
title: "Billing cleanup",
});
try {
await createIdleAgent(archived.client, {
cwd: archived.repoPath,
workspaceId: archived.workspaceId,
title: "Audit invoice totals",
});
expect((await archived.client.archiveWorkspace(archived.workspaceId)).error).toBeNull();
await gotoAppShell(page);
const panel = await openCommandCenter(page);
const input = panel.getByTestId("command-center-input");
const activeRow = panel.getByTestId(
`command-center-workspace-${getServerId()}:${active.workspaceId}`,
);
const archivedRow = panel.getByTestId(
`command-center-workspace-${getServerId()}:${archived.workspaceId}`,
);
await test.step("an empty query never lists archived workspaces", async () => {
await selectCommandCenterTab(panel, "Workspaces");
await expect(activeRow).toBeVisible({ timeout: 30_000 });
await expect(archivedRow).toHaveCount(0);
});
await test.step("a query lists the archived workspace after the active one", async () => {
await input.fill("billing");
await expect(archivedRow).toBeVisible({ timeout: 30_000 });
await expect(archivedRow).toContainText("Archived");
await expect(activeRow).not.toContainText("Archived");
const activeTop = (await activeRow.boundingBox())?.y ?? Number.NaN;
const archivedTop = (await archivedRow.boundingBox())?.y ?? Number.NaN;
expect(archivedTop).toBeGreaterThan(activeTop);
});
await test.step("an agent title finds the archived workspace on All", async () => {
// Opened from the app shell, outside any workspace.
await selectCommandCenterTab(panel, "All", { workspaceOpen: false });
await input.fill("invoice totals");
await expect(archivedRow.getByTestId("command-center-workspace-agent")).toHaveText(
"Audit invoice totals",
{ timeout: 30_000 },
);
await expect(activeRow).toHaveCount(0);
});
await test.step("opening it lands where the workspace can be unarchived", async () => {
await page.keyboard.press("Enter");
await expect(page.getByText("Workspace archived", { exact: true })).toBeVisible({
timeout: 30_000,
});
await expect(page.getByRole("button", { name: "Unarchive", exact: true })).toBeVisible();
});
} finally {
await active.cleanup();
await archived.cleanup();
}
});
test("single-host workspace subtitle omits the host and shows project · branch", async ({
page,
}) => {
@@ -229,7 +318,7 @@ test.describe("Compact sidebar inspection", () => {
title: "Compact destination",
});
try {
const agent = await createIdleAgent(seeded.client, {
await createIdleAgent(seeded.client, {
cwd: seeded.repoPath,
workspaceId: seeded.workspaceId,
title: "Compact root agent",
@@ -246,7 +335,10 @@ test.describe("Compact sidebar inspection", () => {
await openSidebar(page);
const agentSearch = await openCommandCenter(page);
await agentSearch.getByTestId("command-center-input").fill("Compact root agent");
await agentSearch.getByTestId(`command-center-agent-${getServerId()}:${agent.id}`).click();
await agentSearch
.getByTestId(`command-center-workspace-${getServerId()}:${seeded.workspaceId}`)
.getByTestId("command-center-workspace-agent")
.click();
await expect(page.getByRole("button", { name: "Open menu", exact: true })).toBeVisible();
await expect(
page.getByRole("textbox", { name: "Message agent...", exact: true }),
@@ -787,9 +787,13 @@ test.describe("Composer autocomplete", () => {
await input.press("ArrowDown");
await input.press("ArrowDown");
await input.press("h");
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible();
// On web the search reads the cursor after paint, so the input's value runs ahead of
// the list. Wait on the list itself: /tdd is hidden only while the search is "h", and
// back only once the search is "" again.
await expect(popover.getByText("/tdd", { exact: true })).toHaveCount(0);
await input.press("Backspace");
await expect(input).toHaveValue("/");
await expect(popover.getByText("/tdd", { exact: true }).first()).toBeVisible();
await input.press("Enter");
await expect(input).toHaveValue("/tdd ");
@@ -0,0 +1,131 @@
import { expect, test } from "../support/fixtures";
import { composerLocator, expectComposerVisible } from "../support/helpers/composer";
import {
composerControl,
expectGlyphMatchesLabel,
expectIconOnlyGlyph,
readComposerControlTone,
readComposerPlaceholderTone,
readComposerTextTone,
} from "../support/helpers/composer-toolbar-tone";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
const THINKING = "agent-thinking-selector";
const MODE = "mode-control";
const ATTACH = "message-input-attach-button";
const COMBINED_MODEL = "combined-model-selector";
async function openSeededComposer(
page: import("@playwright/test").Page,
repoPrefix: string,
readyTestId = MODE,
) {
const agent = await seedMockAgentWorkspace({
repoPrefix,
title: "Toolbar tone",
model: "ten-second-stream",
});
try {
await openAgentRoute(page, agent);
await expectComposerVisible(page);
await expect(composerControl(page, readyTestId)).toBeVisible({ timeout: 30_000 });
return agent;
} catch (error) {
await agent.cleanup();
throw error;
}
}
test.describe("Composer toolbar glyph tone", () => {
test.describe.configure({ timeout: 180_000 });
test.describe("on a touch screen", () => {
test.use({ hasTouch: true, viewport: { width: 1024, height: 1366 } });
test("a glyph beside its label matches the label while an icon-only glyph stays foreground", async ({
page,
}) => {
const agent = await openSeededComposer(page, "composer-tone-touch-");
try {
const thinking = await readComposerControlTone(composerControl(page, THINKING));
const mode = await readComposerControlTone(composerControl(page, MODE));
const attach = await readComposerControlTone(composerControl(page, ATTACH));
expectGlyphMatchesLabel(thinking, "thinking");
expectGlyphMatchesLabel(mode, "mode");
expectIconOnlyGlyph(attach, "attach");
expect(attach.glyph, "an icon-only glyph is brighter than a labelled glyph").not.toBe(
thinking.label,
);
} finally {
await agent.cleanup();
}
});
});
test.describe("on a compact non-touch window", () => {
test.use({ hasTouch: false, viewport: { width: 590, height: 844 } });
test("the icon-only thinking glyph stays foreground and the labelled mode glyph matches its label", async ({
page,
}) => {
const agent = await openSeededComposer(page, "composer-tone-compact-");
try {
const thinking = await readComposerControlTone(composerControl(page, THINKING));
const mode = await readComposerControlTone(composerControl(page, MODE));
const attach = await readComposerControlTone(composerControl(page, ATTACH));
expectIconOnlyGlyph(thinking, "thinking");
expectGlyphMatchesLabel(mode, "mode");
expectIconOnlyGlyph(attach, "attach");
expect(thinking.glyph, "the icon-only glyphs share one tone").toBe(attach.glyph);
expect(thinking.glyph, "an icon-only glyph is brighter than a labelled glyph").not.toBe(
mode.label,
);
} finally {
await agent.cleanup();
}
});
});
test.describe("on a folded compact non-touch window", () => {
test.use({ hasTouch: false, viewport: { width: 480, height: 844 } });
test("the empty composer placeholder is quieter than its labels", async ({ page }) => {
const agent = await openSeededComposer(page, "composer-tone-placeholder-", COMBINED_MODEL);
try {
const placeholder = await readComposerPlaceholderTone(composerLocator(page));
const extraMuted = await readComposerTextTone(
composerControl(page, "combined-model-selector-thinking-label"),
);
const muted = await readComposerTextTone(
composerControl(page, "combined-model-selector-model-label"),
);
expect(placeholder, "the composer paints a placeholder").not.toBeNull();
expect(extraMuted, "the folded thinking label paints the extra-muted tone").not.toBeNull();
expect(placeholder, "the placeholder uses the extra-muted tone").toBe(extraMuted);
expect(placeholder, "the placeholder is quieter than a labelled glyph").not.toBe(muted);
} finally {
await agent.cleanup();
}
});
});
test.describe("with a wide pointer", () => {
test.use({ hasTouch: false, viewport: { width: 1280, height: 800 } });
test("a labelled glyph stays muted and matches its label", async ({ page }) => {
const agent = await openSeededComposer(page, "composer-tone-pointer-");
try {
const thinking = await readComposerControlTone(composerControl(page, THINKING));
const mode = await readComposerControlTone(composerControl(page, MODE));
expectGlyphMatchesLabel(thinking, "thinking");
expectGlyphMatchesLabel(mode, "mode");
} finally {
await agent.cleanup();
}
});
});
});
@@ -3,6 +3,7 @@ import path from "node:path";
import type { Locator } from "@playwright/test";
import { expect, test, type Page } from "../support/fixtures";
import {
downloadFileFromPreview,
expandFolder,
openFileExplorer,
openFileFromExplorer,
@@ -141,6 +142,29 @@ async function seedAgentWithFileLink(input: LinkedFile) {
}
test.describe("CodeMirror workspace file editing", () => {
test("downloads an assistant-linked binary outside the workspace", async ({ page }, testInfo) => {
const filePath = testInfo.outputPath("outside.bin");
const source = Buffer.from([0, 1, 128, 255]);
await writeFile(filePath, source);
const session = await seedMockAgentWorkspace({
repoPrefix: "outside-binary-",
title: "Outside binary",
initialPrompt: "Show the file",
featureValues: { mockAssistantResponse: `[Open binary](${filePath})` },
});
try {
await openAgentRoute(page, session);
await openAssistantMarkdownLink(page, "Open binary");
const downloaded = await downloadFileFromPreview(page);
expect(downloaded.suggestedFilename()).toBe("outside.bin");
const savedFile = testInfo.outputPath("downloaded.bin");
await downloaded.saveAs(savedFile);
expect(await readFile(savedFile)).toEqual(source);
} finally {
await session.cleanup();
}
});
test("opens explicit screenshot links and explains missing files", async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "screenshot-markdown-link-",
@@ -2,7 +2,12 @@ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, type Page } from "@playwright/test";
import { test } from "../support/fixtures";
import { downloadFileFromExplorer, openFileExplorer } from "../support/helpers/file-explorer";
import {
downloadFileFromExplorer,
downloadFileFromPreview,
openFileFromExplorer,
openFileExplorer,
} from "../support/helpers/file-explorer";
import { openChangesPanel } from "../support/helpers/workspace-tabs";
import { gotoWorkspace } from "../support/helpers/launcher";
import { daemonWsRoutePattern, getE2EDaemonPort } from "../support/helpers/daemon-port";
@@ -473,3 +478,21 @@ test("downloads a file over direct TCP with its original filename and bytes", as
expect(await downloaded.failure()).toBeNull();
expect(await readFile(savedFile)).toEqual(source);
});
test("downloads an unsupported binary from its preview with original filename and bytes", async ({
page,
}, testInfo) => {
const fileName = "rapport données.bin";
const source = Buffer.from([0, 1, 2, 127, 128, 255, 10]);
await writeFile(path.join(workspace.workspaceDirectory, fileName), source);
await gotoWorkspace(page, workspace.workspaceId);
await openFileExplorer(page);
await openFileFromExplorer(page, fileName);
const downloaded = await downloadFileFromPreview(page);
expect(downloaded.suggestedFilename()).toBe(fileName);
const savedFile = testInfo.outputPath(fileName);
await downloaded.saveAs(savedFile);
expect(await downloaded.failure()).toBeNull();
expect(await readFile(savedFile)).toEqual(source);
});
@@ -125,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 () => {
@@ -5,6 +5,10 @@ import path from "node:path";
import { expect, test } from "../support/fixtures";
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
import { openSettingsSection } from "../support/helpers/settings";
import { seedMockAgentWorkspace, openAgentRoute } from "../support/helpers/mock-agent";
import { getServerId } from "../support/helpers/server-id";
import { contrastRatio } from "../../src/utils/color";
import type { Locator, Page } from "@playwright/test";
const PLUGIN_ID = "plugin-theme-e2e";
@@ -40,6 +44,16 @@ const PLUGIN_SOURCE = `export default function contribute(plugin) {
ring: "#9ca0b0",
},
});
for (const [id, name, overrides] of [
["solarized-dark", "Solarized Dark", {}],
["solarized-dark-custom", "Solarized Dark Custom", { sidebarSelected: "#334455", foregroundExtraMuted: "#ddeeff" }],
]) {
plugin.addTheme({ id, name, appearance: "dark", colors: {
background: "#002b36", foreground: "#839496", raised: "#073642",
control: "#073642", border: "#073642", accent: "#268bd2",
mutedForeground: "#586e75", ring: "#93a1a1", ...overrides,
}});
}
return () => {};
}`;
@@ -47,6 +61,88 @@ const PLUGIN_SOURCE = `export default function contribute(plugin) {
// contributed palette reached the semantic tokens rather than just the swatch.
const MOCHA_MUTED_FOREGROUND = "rgb(166, 173, 200)";
const LATTE_MUTED_FOREGROUND = "rgb(108, 111, 133)";
const LATTE_RING = "rgb(156, 160, 176)";
async function selectTheme(page: Page, name: string) {
await page.goto("/settings");
await openSettingsSection(page, "appearance");
const theme = page.getByRole("radio", { name, exact: true });
await expect(theme).toBeVisible({ timeout: 30_000 });
await theme.click();
await expect(theme).toBeChecked();
}
async function expectTimestampContrast(row: Locator) {
const timestamp = row.getByTestId("sidebar-workspace-timestamp");
await expect(timestamp).toBeVisible();
const hex = (rgb: string) =>
`#${rgb
.match(/\d+/g)!
.slice(0, 3)
.map((value) => Number(value).toString(16).padStart(2, "0"))
.join("")}`;
const background = await row.evaluate((element) => getComputedStyle(element).backgroundColor);
const foreground = await timestamp.evaluate((element) => getComputedStyle(element).color);
expect(contrastRatio(hex(background), hex(foreground))).toBeGreaterThanOrEqual(2.5);
}
test("keeps plugin workspace timestamps readable on state surfaces and honors overrides", async ({
page,
}) => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-theme-state-"));
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previousConfig = await client.getDaemonConfig();
const selected = await seedMockAgentWorkspace({
repoPrefix: "theme-selected-",
title: "Selected theme workspace",
});
const hovered = await seedMockAgentWorkspace({
repoPrefix: "theme-hovered-",
title: "Hovered theme workspace",
});
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
);
await writeFile(path.join(directory, "index.client.ts"), PLUGIN_SOURCE);
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(directory);
await selectTheme(page, "Solarized Dark");
await openAgentRoute(page, selected);
const selectedRow = page.getByTestId(
`sidebar-workspace-row-${getServerId()}:${selected.workspaceId}`,
);
const hoveredRow = page.getByTestId(
`sidebar-workspace-row-${getServerId()}:${hovered.workspaceId}`,
);
await expect(selectedRow).toHaveAttribute("aria-selected", "true", { timeout: 30_000 });
await page.mouse.move(0, 0);
await expect(selectedRow).toHaveCSS("background-color", "rgb(33, 58, 66)");
await expectTimestampContrast(selectedRow);
await hoveredRow.hover();
await expect(hoveredRow).toHaveCSS("background-color", "rgb(19, 50, 60)");
await expectTimestampContrast(hoveredRow);
await selectTheme(page, "Solarized Dark Custom");
await openAgentRoute(page, selected);
await page.mouse.move(0, 0);
await expect(selectedRow).toHaveCSS("background-color", "rgb(51, 68, 85)");
await expect(selectedRow.getByTestId("sidebar-workspace-timestamp")).toHaveCSS(
"color",
"rgb(221, 238, 255)",
);
} finally {
await client.removePlugin(PLUGIN_ID).catch(() => undefined);
await client
.patchDaemonConfig({ pluginsEnabled: previousConfig.config.pluginsEnabled ?? false })
.catch(() => undefined);
await client.close();
await selected.cleanup();
await hovered.cleanup();
await rm(directory, { recursive: true, force: true });
}
});
test("applies a contributed theme and falls back when its plugin is gone", async ({
page,
@@ -83,6 +179,13 @@ test("applies a contributed theme and falls back when its plugin is gone", async
await expect(sectionTitle).toHaveCSS("color", LATTE_MUTED_FOREGROUND);
});
await test.step("keyboard focus draws the outline in the theme's ring color", async () => {
await page.getByRole("radio", { name: "Catppuccin Latte", exact: true }).click();
await page.mouse.move(0, 0);
await page.keyboard.press("Tab");
await expect(page.locator(":focus-visible").first()).toHaveCSS("outline-color", LATTE_RING);
});
await mochaItem.click();
await expect(mochaItem).toBeChecked();
await expect(sectionTitle).toHaveCSS("color", MOCHA_MUTED_FOREGROUND);
@@ -1,3 +1,4 @@
import { writeFile } from "node:fs/promises";
import { test, expect } from "../support/fixtures";
import {
buildHostWorkspaceRoute,
@@ -6,20 +7,31 @@ import {
buildSettingsRoute,
buildSettingsSectionRoute,
} from "@/utils/host-routes";
import { openCompactSidebar } from "../support/helpers/usage-sidebar-item";
import { gotoAppShell, openSettings } from "../support/helpers/app";
import { getE2EDaemonPort } from "../support/helpers/daemon-port";
import {
closeCompactSettings,
captureCompactSidebarReturn,
openSettingsFromCompactSidebar,
captureCompactSidebarContext,
expectCompactSidebarOpen,
closeCompactSidebar,
scrollSettingsSidebarToEnd,
revealSettingsSidebarSection,
expectSettingsSidebarScrollSettled,
scrollSettingsSidebarUp,
changeSettingsInterfaceFontSize,
selectSettingsHostSectionsRapidly,
expectSettingsSidebarScrollOffset,
scrollSettingsDetailDown,
expectSettingsDetailScrollOffset,
openSettingsSection,
changeSettingsSyntaxTheme,
revealSettingsSyntaxTheme,
expectSettingsSyntaxColorChanged,
changeSettingsTypography,
normalizeSettingsFontSizes,
changeSettingsInterfaceFontSizeKeepingSidebarPosition,
expectSettingsHeader,
openAddHostFlow,
selectHostConnectionType,
@@ -84,13 +96,32 @@ test.describe("Settings sidebar scroll position", () => {
await expectSettingsSidebarScrollOffset(page, 0);
});
test("keeps the sidebar position through interface font changes", async ({ page }) => {
test("updates appearance without losing the detail scroll or form edits", async ({ page }) => {
await gotoAppShell(page);
await openSettings(page);
await openSettingsSection(page, "appearance");
const offset = await scrollSettingsSidebarToEnd(page);
await changeSettingsInterfaceFontSize(page, 20);
await expectSettingsSidebarScrollOffset(page, offset);
const { offset, keywordColor } = await revealSettingsSyntaxTheme(page);
await changeSettingsSyntaxTheme(page, "Dracula");
await expectSettingsDetailScrollOffset(page, offset);
await expectSettingsSyntaxColorChanged(page, keywordColor);
await changeSettingsTypography(page);
});
test("shows normalized font sizes after committing out-of-range and empty values", async ({
page,
}) => {
await gotoAppShell(page);
await openSettings(page);
await openSettingsSection(page, "appearance");
await normalizeSettingsFontSizes(page);
});
test("keeps the sidebar context through interface font changes", async ({ page }) => {
await gotoAppShell(page);
await openSettings(page);
await openSettingsSection(page, "appearance");
await scrollSettingsSidebarToEnd(page);
const offset = await changeSettingsInterfaceFontSizeKeepingSidebarPosition(page, 20);
await openSettingsHostSection(page, getServerId(), "providers");
await expectSettingsSidebarScrollOffset(page, offset);
});
@@ -350,6 +381,87 @@ test.describe("Settings sidebar navigation", () => {
test.describe("Settings — compact master-detail", () => {
test.use({ viewport: { width: 390, height: 844 } });
test("returning from Settings preserves the open sidebar from the first paint", async ({
page,
withWorkspace,
}, testInfo) => {
const workspace = await withWorkspace({ prefix: "settings-sidebar-continuity-" });
for (let i = 0; i < 12; i++) await withWorkspace({ prefix: "settings-sidebar-scroll-" });
await openWorkspace(page, workspace);
await openCompactSidebar(page);
await expectCompactSidebarOpen(page);
const expectContextPreserved = await captureCompactSidebarContext(page);
const openingFrames = await captureCompactSidebarReturn(page, () =>
openSettingsFromCompactSidebar(page),
);
await writeFile(
testInfo.outputPath("sidebar-opening-frames.json"),
JSON.stringify(openingFrames, null, 2),
);
expect(openingFrames.some((frame) => frame.settingsVisible)).toBe(true);
expect(openingFrames.every((frame) => frame.sidebarVisible || frame.settingsVisible)).toBe(
true,
);
await openSettingsSection(page, "appearance");
await goBackInSettings(page);
const frames = await captureCompactSidebarReturn(page, () => closeCompactSettings(page));
await writeFile(
testInfo.outputPath("sidebar-return-frames.json"),
JSON.stringify(frames, null, 2),
);
await testInfo.attach("sidebar-return-frames", {
body: JSON.stringify(frames, null, 2),
contentType: "application/json",
});
const workspaceFrames = frames.filter((frame) => !frame.pathname.startsWith("/settings"));
expect(workspaceFrames.length).toBeGreaterThan(0);
expect(workspaceFrames.every((frame) => frame.sidebarVisible)).toBe(true);
const firstX = workspaceFrames[0]!.closeX;
expect(
workspaceFrames.every((frame) => Math.abs((frame.closeX ?? Infinity) - firstX!) < 1),
).toBe(true);
await expectCompactSidebarOpen(page);
await expectContextPreserved();
await openSettingsFromCompactSidebar(page);
const backFrames = await captureCompactSidebarReturn(page, async () => {
await page.goBack();
});
await writeFile(
testInfo.outputPath("browser-back-frames.json"),
JSON.stringify(backFrames, null, 2),
);
await testInfo.attach("browser-back-frames", {
body: JSON.stringify(backFrames, null, 2),
contentType: "application/json",
});
const backWorkspaceFrames = backFrames.filter(
(frame) => !frame.pathname.startsWith("/settings"),
);
expect(backWorkspaceFrames.length).toBeGreaterThan(0);
expect(backWorkspaceFrames.every((frame) => frame.sidebarVisible)).toBe(true);
await expectCompactSidebarOpen(page);
await closeCompactSidebar(page);
});
test("widening during Settings discards the mobile sidebar return", async ({
page,
withWorkspace,
}) => {
const workspace = await withWorkspace({ prefix: "settings-sidebar-resize-" });
await openWorkspace(page, workspace);
await openCompactSettings(page, buildHostWorkspaceRoute(getServerId(), workspace.workspaceId));
await page.setViewportSize({ width: 1000, height: 844 });
await expect(page.getByTestId("settings-detail-pane")).toBeVisible();
await page.setViewportSize({ width: 390, height: 844 });
await goBackInSettings(page);
await expectCompactSettingsList(page);
await closeCompactSettings(page);
await expect(page.getByRole("button", { name: "Close sidebar", exact: true })).toBeHidden();
await expect(
page.getByRole("button", { name: "Open menu", exact: true }).first(),
).toBeInViewport();
});
test("the compact tab switcher reopens after Settings hides its workspace", async ({ page }) => {
const workspace = await seedMockAgentWorkspace({
repoPrefix: "settings-tab-switcher-",
@@ -377,6 +489,7 @@ test.describe("Settings — compact master-detail", () => {
await expect(newAgent).toHaveCount(0);
await closeCompactSettings(page);
await closeCompactSidebar(page);
await expect(switcher).toBeVisible();
await expect(newAgent).toHaveCount(0);
await switcher.click();
@@ -29,6 +29,7 @@ import {
openCompactSidebar,
openUsageFromIcon,
openUsageFromItem,
openUsageFromItemWithPressFeedback,
pinRow,
scrollUsageToWindow,
setSummaryInSidebar,
@@ -197,7 +198,7 @@ test.describe("Usage item", () => {
});
await test.step("the summary opens the same sheet", async () => {
await openUsageFromItem(page);
await openUsageFromItemWithPressFeedback(page);
await expect(usageModal(page).getByText("Codex", { exact: true })).toBeVisible();
await expect(page).toHaveURL(startingUrl);
await qaScreenshot(page, "compact-usage-summary-sheet");
@@ -136,7 +136,7 @@ async function expectForkFailureWithoutOverlappingStatus(page: Page) {
async function observeToastReplacement(page: Page) {
const observation = await page
.getByRole("alert")
.filter({ hasText: "Reconnecting to host" })
.filter({ hasText: "Reconnecting" })
.evaluateHandle((toast) => {
const frames: Array<{ connected: boolean; opacity: number; transform: string }> = [];
let frame = 0;
@@ -14,6 +14,7 @@ import { connectSeedClient } from "../support/helpers/seed-client";
import { createTempGitRepo } from "../support/helpers/workspace";
import { getWorkspaceTabTestIds } from "../support/helpers/workspace-tabs";
import {
archivedToast,
archiveFromRowButton,
createGatedTeardown,
expectDirectoryPresence,
@@ -25,6 +26,7 @@ import {
resizeSidebarToMinimum,
rowArchiveButton,
rowKebab,
scrollSidebarListToEnd,
undoButton,
workspaceRow,
workspaceTitleWidth,
@@ -119,6 +121,41 @@ test.describe("Workspace archive undo", () => {
expect(shortcutBox.x - (labelBox.x + labelBox.width)).toBeLessThanOrEqual(8.5);
});
test("a long list ends just below its last row, and the toast over it moves no row", async ({
page,
}) => {
await page.clock.install({ time: Date.now() });
const fillers: Array<{ path: string; cleanup: () => Promise<void> }> = [];
const fillerProjectIds: string[] = [];
try {
for (let index = 0; index < 10; index += 1) {
const repo = await createTempGitRepo(`archive-toast-filler-${index}-`);
fillers.push(repo);
fillerProjectIds.push((await openProjectViaDaemon(client, repo.path)).projectId);
}
const worktree = await seedWorktree(page, "archive-toast-long-list");
const resting = await scrollSidebarListToEnd(page);
expect(resting.listBottom - resting.rowBottoms.at(-1)!).toBeLessThan(resting.rowHeight);
await archiveFromRowButton(page, worktree.workspaceId);
await page.mouse.move(0, 0);
const withToast = await scrollSidebarListToEnd(page);
const toastBox = await archivedToast(page).boundingBox();
if (!toastBox) throw new Error("Toast is not laid out");
expect(toastBox.y + toastBox.height).toBeLessThanOrEqual(withToast.listBottom);
await outlastUndoWindow(page);
await expect(archivedToast(page)).toHaveCount(0);
expect(await scrollSidebarListToEnd(page)).toEqual(withToast);
} finally {
for (const projectId of fillerProjectIds) {
await client.removeProject(projectId).catch(() => undefined);
}
for (const repo of fillers) await repo.cleanup().catch(() => undefined);
}
});
test("an archive that fails after the sidebar was replaced still shows its error", async ({
page,
}) => {
@@ -5,27 +5,34 @@ import path from "node:path";
import type { Dialog, Page } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import {
archiveWorkspaceFromDaemon,
connectNewWorkspaceDaemonClient,
createWorktreeViaDaemon,
openProjectViaDaemon,
} from "../support/helpers/new-workspace";
import { expectAppRoute } from "../support/helpers/route-assertions";
import { getServerId } from "../support/helpers/server-id";
import {
clickArchiveWorkspaceMenuItem,
expectWorkspaceAbsentFromSidebar,
} from "../support/helpers/sidebar";
import { createTempGitRepo } from "../support/helpers/workspace";
import {
rowArchiveButton,
undoButton,
workspaceRow,
} from "../support/helpers/workspace-archive-undo";
import {
waitForSidebarHydration,
waitForWorkspaceInSidebar,
} from "../support/helpers/workspace-ui";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
async function seedRiskyWorktree(
client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>,
worktreeDirectory: string,
): Promise<void> {
type DaemonClient = Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
async function seedRiskyWorktree(client: DaemonClient, worktreeDirectory: string): Promise<void> {
// The daemon only reports unpushed commits when the branch has a configured
// upstream (aheadOfOrigin is computed against `branch.<name>.merge`). Push the
// worktree branch at its current head first so it tracks origin with 0 ahead,
@@ -55,35 +62,189 @@ async function seedRiskyWorktree(
const dirtyFile = path.join(worktreeDirectory, "DIRTY.md");
await writeFile(dirtyFile, "# dirty\n");
const refreshed = await client.checkoutRefresh(worktreeDirectory);
await refreshCheckout(client, worktreeDirectory);
}
async function refreshCheckout(client: DaemonClient, directory: string): Promise<void> {
const refreshed = await client.checkoutRefresh(directory);
if (!refreshed.success) {
throw new Error(`Failed to refresh checkout for ${worktreeDirectory}`);
throw new Error(`Failed to refresh checkout for ${directory}`);
}
}
// The archive confirmation is a synchronous web `window.confirm()`. The click that
// opens it does not resolve until the dialog is answered, so the handler must
// accept/dismiss inline — awaiting the dialog only *after* the click deadlocks, as
// the click waits for an answer that is gated behind that same click.
async function clickArchiveAndAnswerWarning(
// The archive confirmation is a synchronous web `window.confirm()` that opens once the app has
// read the worktree's git state from the host. Wait for the dialog itself: on web the click does
// not resolve until it is answered, so answer it from the event.
async function answerArchiveWarning(
page: Page,
workspaceId: string,
answer: "accept" | "dismiss",
openArchive: () => Promise<void>,
): Promise<Dialog> {
let warning: Dialog | undefined;
page.once("dialog", (dialog) => {
warning = dialog;
void (answer === "accept" ? dialog.accept() : dialog.dismiss());
const warning = page.waitForEvent("dialog", { timeout: 10_000 }).then(async (dialog) => {
await (answer === "accept" ? dialog.accept() : dialog.dismiss());
return dialog;
});
await clickArchiveWorkspaceMenuItem(page, workspaceId);
if (!warning) {
throw new Error("Expected an archive confirmation dialog, but none was shown.");
await openArchive();
return await warning;
}
// A clean Paseo worktree's primary git action in the workspace header is Archive.
function gitActionsArchiveButton(page: Page) {
return page.getByTestId("changes-primary-cta").and(page.getByLabel("Archive workspace"));
}
async function clickRowArchiveButton(page: Page, workspaceId: string): Promise<void> {
await workspaceRow(page, workspaceId).hover();
await rowArchiveButton(page, workspaceId).click();
}
interface SessionEnvelope {
type?: unknown;
message?: {
type?: unknown;
workspaceId?: unknown;
cwd?: unknown;
payload?: Record<string, unknown>;
};
}
function parseSessionEnvelope(message: string): SessionEnvelope | null {
try {
return JSON.parse(message) as SessionEnvelope;
} catch {
return null;
}
return warning;
}
function removeGitStatusFromDescriptors(value: unknown, workspaceId: string): number {
if (Array.isArray(value)) {
return value.reduce<number>(
(count, item) => count + removeGitStatusFromDescriptors(item, workspaceId),
0,
);
}
if (!value || typeof value !== "object") return 0;
const record = value as Record<string, unknown>;
let count = 0;
if (record.id === workspaceId && "workspaceDirectory" in record) {
delete record.gitRuntime;
record.diffStat = null;
count += 1;
}
for (const child of Object.values(record)) {
count += removeGitStatusFromDescriptors(child, workspaceId);
}
return count;
}
/**
* Relays the app's connection to the daemon. Every workspace descriptor for `workspaceId` reaches
* the app without git status, as the host sends it before its first checkout snapshot of the
* worktree, so the sidebar row has nothing cached to warn from. `failCheckoutStatusFor` turns the
* host's checkout status answer for that directory into a git error.
*/
async function relayDaemon(
page: Page,
input: { workspaceId: string; failCheckoutStatusFor?: string },
) {
let descriptorRewrites = 0;
let archiveRequests = 0;
await page.routeWebSocket(daemonWsRoutePattern(), (browser) => {
const server = browser.connectToServer();
browser.onMessage((message) => {
if (typeof message === "string") {
const request = parseSessionEnvelope(message)?.message;
if (
request?.type === "archive_workspace_request" &&
request.workspaceId === input.workspaceId
) {
archiveRequests += 1;
}
}
server.send(message);
});
server.onMessage((message) => {
if (typeof message !== "string") {
browser.send(message);
return;
}
const envelope = parseSessionEnvelope(message);
const response = envelope?.message;
if (
input.failCheckoutStatusFor &&
response?.type === "checkout_status_response" &&
response.payload?.cwd === input.failCheckoutStatusFor
) {
response.payload = {
...response.payload,
isGit: false,
isPaseoOwnedWorktree: false,
repoRoot: null,
currentBranch: null,
isDirty: null,
baseRef: null,
aheadBehind: null,
aheadOfOrigin: null,
behindOfOrigin: null,
hasRemote: false,
remoteUrl: null,
error: { code: "UNKNOWN", message: "git status failed" },
};
browser.send(JSON.stringify(envelope));
return;
}
if (!envelope || !message.includes(input.workspaceId)) {
browser.send(message);
return;
}
descriptorRewrites += removeGitStatusFromDescriptors(envelope, input.workspaceId);
browser.send(JSON.stringify(envelope));
});
});
return {
descriptorRewrites: () => descriptorRewrites,
archiveRequests: () => archiveRequests,
};
}
/**
* Holds the app's next checkout status request for `directory` once armed, so a test can act
* while the archive waits for the host's answer.
*/
async function holdCheckoutStatusRequest(page: Page, directory: string) {
let armed = false;
let held: (() => void) | null = null;
await page.routeWebSocket(daemonWsRoutePattern(), (browser) => {
const server = browser.connectToServer();
browser.onMessage((message) => {
const request = typeof message === "string" ? parseSessionEnvelope(message)?.message : null;
if (
armed &&
!held &&
request?.type === "checkout_status_request" &&
request.cwd === directory
) {
held = () => server.send(message);
return;
}
server.send(message);
});
server.onMessage((message) => browser.send(message));
});
return {
arm: () => {
armed = true;
},
waitForHeld: () => expect.poll(() => held !== null).toBe(true),
release: () => {
if (!held) throw new Error("No checkout status request is held");
held();
},
};
}
test.describe("Workspace archive risk warning for worktree backing", () => {
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
let client: DaemonClient;
let tempRepo: { path: string; cleanup: () => Promise<void> };
const createdWorktreeDirectories = new Set<string>();
@@ -103,36 +264,53 @@ test.describe("Workspace archive risk warning for worktree backing", () => {
await tempRepo?.cleanup().catch(() => undefined);
});
test("a risky workspace archive is gated by confirmation and removes its worktree after acceptance", async ({
page,
}) => {
const serverId = getServerId();
await openProjectViaDaemon(client, tempRepo.path);
async function createWorktree(slug: string) {
const project = await openProjectViaDaemon(client, tempRepo.path);
const worktree = await createWorktreeViaDaemon(client, {
cwd: tempRepo.path,
slug: `archive-risk-${Date.now()}`,
slug: `${slug}-${Date.now()}`,
});
createdWorktreeDirectories.add(worktree.workspaceDirectory);
expect(existsSync(worktree.workspaceDirectory)).toBe(true);
return { ...worktree, project };
}
await seedRiskyWorktree(client, worktree.workspaceDirectory);
async function openSidebarRow(page: Page, workspaceId: string): Promise<void> {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await waitForWorkspaceInSidebar(page, { serverId, workspaceId: worktree.workspaceId });
await waitForWorkspaceInSidebar(page, { serverId: getServerId(), workspaceId });
}
const firstWarning = await clickArchiveAndAnswerWarning(page, worktree.workspaceId, "dismiss");
async function expectWorktreeKept(page: Page, worktree: { workspaceId: string }) {
await expect(workspaceRow(page, worktree.workspaceId)).toBeVisible({ timeout: 10_000 });
}
async function hostListsWorkspace(workspaceId: string): Promise<boolean> {
const { entries } = await client.fetchWorkspaces();
return entries.some((entry) => entry.id === workspaceId);
}
test("a risky workspace archive is gated by confirmation and removes its worktree after acceptance", async ({
page,
}) => {
const worktree = await createWorktree("archive-risk");
await seedRiskyWorktree(client, worktree.workspaceDirectory);
await openSidebarRow(page, worktree.workspaceId);
const firstWarning = await answerArchiveWarning(page, "dismiss", () =>
clickArchiveWorkspaceMenuItem(page, worktree.workspaceId),
);
expect(firstWarning.type()).toBe("confirm");
expect(firstWarning.message()).toContain(`Archive "${worktree.workspaceName}"?`);
expect(firstWarning.message()).toContain("Uncommitted changes");
expect(firstWarning.message()).toContain("1 unpushed commit");
await expect(
page.getByTestId(`sidebar-workspace-row-${serverId}:${worktree.workspaceId}`),
).toBeVisible({ timeout: 10_000 });
await expectWorktreeKept(page, worktree);
expect(existsSync(worktree.workspaceDirectory)).toBe(true);
const secondWarning = await clickArchiveAndAnswerWarning(page, worktree.workspaceId, "accept");
const secondWarning = await answerArchiveWarning(page, "accept", () =>
clickArchiveWorkspaceMenuItem(page, worktree.workspaceId),
);
expect(secondWarning.message()).toContain("Uncommitted changes");
expect(secondWarning.message()).toContain("1 unpushed commit");
@@ -143,4 +321,128 @@ test.describe("Workspace archive risk warning for worktree backing", () => {
createdWorktreeDirectories.delete(worktree.workspaceDirectory);
});
test("the row's archive button warns from the host's git state while the row has none, and Cancel keeps the worktree", async ({
page,
}) => {
const worktree = await createWorktree("archive-risk-uncached");
await seedRiskyWorktree(client, worktree.workspaceDirectory);
const relay = await relayDaemon(page, { workspaceId: worktree.workspaceId });
await openSidebarRow(page, worktree.workspaceId);
expect(relay.descriptorRewrites()).toBeGreaterThan(0);
const warning = await answerArchiveWarning(page, "dismiss", () =>
clickRowArchiveButton(page, worktree.workspaceId),
);
expect(warning.message()).toContain(`Archive "${worktree.workspaceName}"?`);
expect(warning.message()).toContain("Uncommitted changes");
expect(warning.message()).toContain("1 unpushed commit");
await expectWorktreeKept(page, worktree);
expect(relay.archiveRequests()).toBe(0);
expect(existsSync(path.join(worktree.workspaceDirectory, "DIRTY.md"))).toBe(true);
});
test("a worktree holding only new files warns when the repository hides untracked files", async ({
page,
}) => {
execSync("git config status.showUntrackedFiles no", { cwd: tempRepo.path, stdio: "ignore" });
const worktree = await createWorktree("archive-risk-untracked");
await writeFile(path.join(worktree.workspaceDirectory, "NEW.md"), "# not yet added\n");
await refreshCheckout(client, worktree.workspaceDirectory);
await openSidebarRow(page, worktree.workspaceId);
const warning = await answerArchiveWarning(page, "dismiss", () =>
clickRowArchiveButton(page, worktree.workspaceId),
);
expect(warning.message()).toContain(`Archive "${worktree.workspaceName}"?`);
expect(warning.message()).toContain("Uncommitted changes");
await expectWorktreeKept(page, worktree);
expect(existsSync(path.join(worktree.workspaceDirectory, "NEW.md"))).toBe(true);
});
test("a failed git read shows its error and keeps the worktree", async ({ page }) => {
const worktree = await createWorktree("archive-risk-read-failure");
await seedRiskyWorktree(client, worktree.workspaceDirectory);
const relay = await relayDaemon(page, {
workspaceId: worktree.workspaceId,
failCheckoutStatusFor: worktree.workspaceDirectory,
});
await openSidebarRow(page, worktree.workspaceId);
await clickRowArchiveButton(page, worktree.workspaceId);
await expect(page.getByTestId("app-toast-message")).toHaveText("git status failed");
await expectWorktreeKept(page, worktree);
expect(relay.archiveRequests()).toBe(0);
expect(existsSync(worktree.workspaceDirectory)).toBe(true);
});
// Archives the open worktree with `startArchive`, opens the project's main workspace while the
// host's checkout status answer is held, then lets the answer through.
async function archiveWhileOpeningAnotherWorkspace(
page: Page,
slug: string,
startArchive: (worktree: { workspaceId: string }) => Promise<void>,
): Promise<string> {
const serverId = getServerId();
const worktree = await createWorktree(slug);
const statusRequest = await holdCheckoutStatusRequest(page, worktree.workspaceDirectory);
await openSidebarRow(page, worktree.workspaceId);
await workspaceRow(page, worktree.workspaceId).click();
await expectAppRoute(page, buildHostWorkspaceRoute(serverId, worktree.workspaceId));
statusRequest.arm();
await startArchive(worktree);
await statusRequest.waitForHeld();
await workspaceRow(page, worktree.project.workspaceId).click();
const openedRoute = buildHostWorkspaceRoute(serverId, worktree.project.workspaceId);
await expectAppRoute(page, openedRoute);
statusRequest.release();
await expect(undoButton(page)).toBeVisible();
// The redirect runs before the archive request, so once the host has archived the worktree
// any redirect has already happened.
await expect
.poll(() => hostListsWorkspace(worktree.workspaceId), { timeout: 30_000 })
.toBe(false);
return openedRoute;
}
test("opening another workspace while the host answers keeps it open after a row archive", async ({
page,
}) => {
const openedRoute = await archiveWhileOpeningAnotherWorkspace(
page,
"archive-risk-switch-row",
(worktree) => clickRowArchiveButton(page, worktree.workspaceId),
);
await expectAppRoute(page, openedRoute);
});
test("opening another workspace while the host answers keeps it open after a git actions archive", async ({
page,
}) => {
const openedRoute = await archiveWhileOpeningAnotherWorkspace(
page,
"archive-risk-switch-git",
() => gitActionsArchiveButton(page).click(),
);
await expectAppRoute(page, openedRoute);
});
test("archiving the open worktree from its git actions leaves it for a new workspace", async ({
page,
}) => {
const worktree = await createWorktree("archive-risk-git-redirect");
await openSidebarRow(page, worktree.workspaceId);
await workspaceRow(page, worktree.workspaceId).click();
await expectAppRoute(page, buildHostWorkspaceRoute(getServerId(), worktree.workspaceId));
await gitActionsArchiveButton(page).click();
await expect.poll(() => new URL(page.url()).pathname).toBe("/new");
await expectWorkspaceAbsentFromSidebar(page, worktree.workspaceId);
});
});
@@ -3,7 +3,7 @@ env APP_ID=sh.paseo.debug
# Start with the sidebar closed. WORKSPACE_ROW is the testID of a visible,
# unselected PINNED workspace row. Ordinary workspace rows are not draggable.
# Hold past drag arming (180ms), then release.
# Hold past press feedback (130ms) and drag arming (180ms), then release.
open "${APP_ID}"
press "label=\"Open menu\""
wait "id=\"sidebar-close\"" 10000
@@ -12,7 +12,7 @@ press "id=\"sidebar-close\""
wait "label=\"Open menu\"" 5000
press "label=\"Open menu\""
wait "id=\"${WORKSPACE_ROW}\"" 10000
longpress "id=\"${WORKSPACE_ROW}\"" 250
longpress "id=\"${WORKSPACE_ROW}\"" 400
press "id=\"${WORKSPACE_ROW}\""
# A released drag must not swallow the next tap: navigation closes the sidebar.
wait "label=\"Open menu\"" 5000
@@ -0,0 +1,83 @@
// Record a sidebar toast through timeout on the 2560×1600 Android tablet.
// Pass a vertical sample line through the card's plain interior, away from text,
// icons, and Android's clipboard preview, plus the visible-to-dismissed interval:
// node .../toast-dismissal.mjs recording.mp4 sample-x start-seconds end-seconds
// This checks actual native frames; a browser layout test misses this regression.
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
const [, , recording, sampleX, start, end] = process.argv;
assert(recording && sampleX && start && end, "Pass recording, sample x, start and end seconds");
const { frames } = JSON.parse(
execFileSync(
"ffprobe",
[
"-v",
"error",
"-select_streams",
"v",
"-show_entries",
"frame=best_effort_timestamp_time",
"-of",
"json",
recording,
],
{ encoding: "utf8" },
),
);
const selectedFrames = frames
.map((frame, index) => ({ time: Number(frame.best_effort_timestamp_time), index }))
.filter(({ time }) => time >= Number(start) && time <= Number(end));
assert(selectedFrames.length > 2, "Capture at least three frames in the supplied interval");
const firstFrame = selectedFrames[0].index;
const endFrame = selectedFrames.at(-1).index + 1;
const bytesPerFrame = 800;
// Bound both output and buffer to the requested frames, even for a long recording.
const pixels = execFileSync(
"ffmpeg",
[
"-v",
"fatal",
"-i",
recording,
"-vf",
`trim=start_frame=${firstFrame}:end_frame=${endFrame},crop=2:400:${Number(sampleX)}:1100,format=gray`,
"-fps_mode",
"passthrough",
"-f",
"rawvideo",
"-",
],
{ maxBuffer: selectedFrames.length * bytesPerFrame + 1024 },
);
assert.equal(
pixels.length,
selectedFrames.length * bytesPerFrame,
"Every selected frame must be decoded",
);
const positions = [];
let disappeared = false;
for (const [index, { time }] of selectedFrames.entries()) {
const column = pixels.subarray(index * bytesPerFrame, (index + 1) * bytesPerFrame);
const runs = [];
let run = [];
for (let y = 0; y < 400; y++) {
if (column[y * 2] > 250) run.push(y);
else {
if (run.length > 40) runs.push(run);
run = [];
}
}
if (run.length > 40) runs.push(run);
runs.sort((a, b) => b.length - a.length);
if (runs.length) {
const card = runs[0];
positions.push({ time, centre: 1100 + (card[0] + card.at(-1)) / 2 });
} else if (positions.length) disappeared = true;
}
assert(positions.length > 2, "Capture at least three visible card frames");
assert(disappeared, "Recording must include the card disappearing");
const centres = positions.map(({ centre }) => centre);
const shift = Math.max(...centres) - Math.min(...centres);
console.log(JSON.stringify({ positions, maximumShiftPixels: shift }, null, 2));
assert(shift <= 2, `Toast moved ${shift}px during dismissal`);
@@ -21,7 +21,6 @@ export interface OlderTimelinePagesGate {
getRepeatedEntryCount(): number;
getOwnedEntryCountContaining(text: string): number;
releasePage(pageNumber: number): void;
waitForRequestCount(count: number): Promise<void>;
}
export interface DaemonHydrationGate {
@@ -371,15 +370,6 @@ export async function holdAgentOlderTimelinePages(
const ownedEntries = new Map<string, string>();
const releasedPages = new Set<number>();
const delayedForwards = new Map<number, Array<() => void>>();
const requestWaiters = new Map<number, Array<() => void>>();
const resolveRequestWaiters = () => {
for (const [count, resolvers] of requestWaiters) {
if (requestCount < count) continue;
requestWaiters.delete(count);
for (const resolve of resolvers) resolve();
}
};
const routePattern =
daemonPort === undefined ? daemonWsRoutePattern() : wsRoutePatternForPort(String(daemonPort));
@@ -393,7 +383,6 @@ export async function holdAgentOlderTimelinePages(
sessionMessage.direction === "before"
) {
requestCount += 1;
resolveRequestWaiters();
}
server.send(message);
});
@@ -439,14 +428,6 @@ export async function holdAgentOlderTimelinePages(
for (const forward of delayedForwards.get(pageNumber) ?? []) forward();
delayedForwards.delete(pageNumber);
},
waitForRequestCount(count) {
if (requestCount >= count) return Promise.resolve();
return new Promise<void>((resolve) => {
const resolvers = requestWaiters.get(count) ?? [];
resolvers.push(resolve);
requestWaiters.set(count, resolvers);
});
},
};
}
@@ -26,7 +26,7 @@ const SURFACE_SELECTORS = {
input: '[data-testid="command-center-input"]',
fileSearchStatus: '[data-testid="command-center-file-search-status"]',
results: '[data-testid="command-center-results"]',
scope: '[data-testid="command-center-files-scope"]',
tabs: '[data-testid="command-center-tabs"]',
fileRow: '[data-testid^="command-center-file-row-"]',
fileIcon: '[data-testid="command-center-file-icon"]',
fileLine: '[data-testid="command-center-file-line"]',
@@ -11,7 +11,7 @@ export async function seedSearchableCommandCenter() {
});
try {
const agents = [];
for (let index = 0; index < 60; index += 1) {
for (let index = 0; index < 3; index += 1) {
agents.push(
await createMockIdleAgent(seed.client, {
cwd: seed.repoPath,
@@ -49,11 +49,27 @@ export async function searchCommandCenter(panel: Locator, query: string) {
}
export async function warmCommandCenterRows(page: Page) {
// FlatList fills its overscan window in timed batches after the first visible frame.
await page.waitForTimeout(600);
await page.evaluate(() => {
globalThis.__PASEO_RENDER_PROFILE_REASONS__ = {};
});
// FlatList fills its overscan window in timed batches after the first visible frame,
// and a slow runner can still be mounting rows long after it. Start counting only
// once a quiet period passes with no row render.
await expect
.poll(() => page.evaluate(countRowRendersOverQuietPeriod), {
intervals: [0],
timeout: 15_000,
})
.toBe(0);
}
// Runs in the page: resets the render profile and counts row renders over two frames and 300ms.
async function countRowRendersOverQuietPeriod() {
globalThis.__PASEO_RENDER_PROFILE_REASONS__ = {};
await new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
);
await new Promise((resolve) => setTimeout(resolve, 300));
return Object.keys(globalThis.__PASEO_RENDER_PROFILE_REASONS__ ?? {}).filter((id) =>
id.startsWith("CommandCenterResultRow:"),
).length;
}
export async function expectOnlyCommandCenterRowRendered(page: Page, resultId: string) {
@@ -73,16 +89,17 @@ export async function expectOnlyCommandCenterRowRendered(page: Page, resultId: s
expect(renders).toEqual({ [`CommandCenterResultRow:${resultId}`]: { render: 1 } });
}
export async function expectCommandCenterAgentWindow(panel: Locator, total: number) {
const rows = panel.locator(
'[data-testid^="command-center-agent-"]:not([data-testid="command-center-agent-subtitle"])',
);
const count = await rows.count();
const WORKSPACE_ROWS =
'[data-testid^="command-center-workspace-"]:not([data-testid="command-center-workspace-subtitle"]):not([data-testid="command-center-workspace-agent"])';
/** The list mounts a window of rows, not every match. */
export async function expectCommandCenterWorkspaceWindow(panel: Locator, total: number) {
const count = await panel.locator(WORKSPACE_ROWS).count();
expect(count).toBeGreaterThan(0);
expect(count).toBeLessThan(total);
}
export async function scrollToOldestCommandCenterAgent(panel: Locator) {
export async function scrollToOldestCommandCenterWorkspace(panel: Locator) {
await panel.getByTestId("command-center-results").evaluate((element) => {
element.scrollTop = element.scrollHeight;
element.dispatchEvent(new Event("scroll"));
@@ -22,14 +22,9 @@ export async function observeCommandCenterScroll(page: Page): Promise<void> {
const discoverPanel = new MutationObserver(() => {
const panel = document.querySelector<HTMLElement>('[data-testid="command-center-panel"]');
const firstResult = panel?.querySelector<HTMLElement>('[role="button"]');
if (!panel || !firstResult) return;
let viewport = firstResult.parentElement;
while (viewport && viewport !== panel && viewport.scrollHeight <= viewport.clientHeight) {
viewport = viewport.parentElement;
}
if (!viewport || viewport === panel) return;
const viewport = panel?.querySelector<HTMLElement>('[data-testid="command-center-results"]');
const firstResult = viewport?.querySelector<HTMLElement>('[role="button"]');
if (!viewport || !firstResult) return;
discoverPanel.disconnect();
let frame = 0;
@@ -119,15 +114,8 @@ export async function expectVisibleKeyboardNavigationNotToScroll(
for (let index = 0; index < 4; index += 1) await page.keyboard.press("ArrowDown");
const scrollSamples = await panel
.getByRole("button")
.first()
.evaluate(async (firstResult) => {
let viewport = firstResult.parentElement;
while (viewport && viewport.scrollHeight <= viewport.clientHeight) {
viewport = viewport.parentElement;
}
if (!viewport) throw new Error("Could not find the command center scroll viewport");
.getByTestId("command-center-results")
.evaluate(async (viewport) => {
const samples: number[] = [];
for (let frame = 0; frame < 20; frame += 1) {
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
@@ -139,7 +127,8 @@ export async function expectVisibleKeyboardNavigationNotToScroll(
expect(Math.max(...scrollSamples)).toBe(0);
}
export async function expectWorkspaceResultsToBeLimitedUntilSearch(panel: Locator): Promise<void> {
/** All leads with three recent workspaces; a query searches every workspace. */
export async function expectRecentWorkspacesUntilSearch(panel: Locator): Promise<void> {
const workspaces = panel.locator(
'[data-testid^="command-center-workspace-"]:not([data-testid="command-center-workspace-subtitle"])',
);
@@ -150,9 +139,11 @@ export async function expectWorkspaceResultsToBeLimitedUntilSearch(panel: Locato
),
).size;
await expect.poll(distinctWorkspaceCount).toBe(5);
await expect(panel.getByText("Recent workspaces", { exact: true })).toBeVisible();
await expect.poll(distinctWorkspaceCount).toBe(3);
await panel.getByTestId("command-center-input").fill("Command Center Scroll");
await expect(panel.getByText("Recent workspaces", { exact: true })).toHaveCount(0);
await expect.poll(distinctWorkspaceCount).toBe(9);
}
@@ -1,5 +1,20 @@
import { expect, type Locator, type Page } from "@playwright/test";
export type CommandCenterTabName = "All" | "Workspaces" | "Files" | "Actions";
const TAB_PLACEHOLDERS: Record<CommandCenterTabName, string> = {
All: "Search workspaces, files, and actions...",
Workspaces: "Search workspaces...",
Files: "Search files...",
Actions: "Search actions...",
};
/** Outside a workspace there are no files to search, so All does not offer them. */
const TAB_PLACEHOLDERS_WITHOUT_WORKSPACE: Record<CommandCenterTabName, string> = {
...TAB_PLACEHOLDERS,
All: "Search workspaces and actions...",
};
// Opens the command center / global search palette from the sidebar and returns its panel.
export async function openCommandCenter(page: Page): Promise<Locator> {
await page.getByTestId("sidebar-search").click();
@@ -12,3 +27,46 @@ export async function closeCommandCenter(page: Page): Promise<void> {
await page.keyboard.press("Escape");
await expect(page.getByTestId("command-center-panel")).not.toBeVisible();
}
function commandCenterTab(panel: Locator, name: CommandCenterTabName): Locator {
return panel.getByTestId("command-center-tabs").getByRole("button", { name, exact: true });
}
export async function selectCommandCenterTab(
panel: Locator,
name: CommandCenterTabName,
options: { workspaceOpen?: boolean } = {},
): Promise<void> {
await commandCenterTab(panel, name).click();
await expectCommandCenterTab(panel, name, options);
}
/**
* The tab is selected and the search field asks for what that tab lists. Pass
* `{ workspaceOpen: false }` when the command center was opened outside a workspace.
*/
export async function expectCommandCenterTab(
panel: Locator,
name: CommandCenterTabName,
options: { workspaceOpen?: boolean } = {},
): Promise<void> {
const placeholders =
options.workspaceOpen === false ? TAB_PLACEHOLDERS_WITHOUT_WORKSPACE : TAB_PLACEHOLDERS;
await expect(commandCenterTab(panel, name)).toHaveAttribute("aria-selected", "true");
await expect(panel.getByTestId("command-center-input")).toHaveAttribute(
"placeholder",
placeholders[name],
);
}
/** Exactly these tabs are offered, in this order. */
export async function expectCommandCenterTabs(
panel: Locator,
names: readonly CommandCenterTabName[],
): Promise<void> {
await expect(panel.getByTestId("command-center-tabs").getByRole("button")).toHaveText([...names]);
}
export function commandCenterSection(panel: Locator, title: string): Locator {
return panel.getByTestId("command-center-results").getByText(title, { exact: true });
}
@@ -0,0 +1,55 @@
import { expect, type Locator, type Page } from "@playwright/test";
/** The tones a composer toolbar control paints: its glyph's stroke and its label's text color. */
export interface ComposerControlTone {
/** The visible glyph's stroke, or null when the control paints no glyph. */
glyph: string | null;
/** The visible label's color, or null when the control is icon-only. */
label: string | null;
}
export function composerControl(page: Page, testId: string): Locator {
return page.getByTestId(testId).filter({ visible: true }).first();
}
/**
* Reads the colors the control actually paints. The toolbar glyph and its label are separate
* nodes, so this is the only way to tell whether they match.
*/
export async function readComposerControlTone(control: Locator): Promise<ComposerControlTone> {
return control.evaluate((element) => {
const visible = (node: Element) => node.getClientRects().length > 0;
const glyph = Array.from(element.querySelectorAll("svg")).find(visible);
const label = Array.from(element.querySelectorAll("*"))
.filter(visible)
.find((node) =>
[...node.childNodes].some(
(child) => child.nodeType === Node.TEXT_NODE && child.textContent?.trim(),
),
);
return {
glyph: glyph ? getComputedStyle(glyph).stroke : null,
label: label ? getComputedStyle(label).color : null,
};
});
}
export function expectGlyphMatchesLabel(tone: ComposerControlTone, name: string): void {
expect(tone.label, `${name} paints a label`).not.toBeNull();
expect(tone.glyph, `${name}'s glyph matches its label`).toBe(tone.label);
}
export function expectIconOnlyGlyph(tone: ComposerControlTone, name: string): void {
expect(tone.label, `${name} is icon-only`).toBeNull();
expect(tone.glyph, `${name} paints a glyph`).not.toBeNull();
}
/** The color the browser paints in a text input's placeholder. */
export async function readComposerPlaceholderTone(input: Locator): Promise<string | null> {
return input.evaluate((element) => getComputedStyle(element, "::placeholder").color);
}
/** The color the browser paints for a text node. */
export async function readComposerTextTone(text: Locator): Promise<string | null> {
return text.evaluate((element) => getComputedStyle(element).color);
}
@@ -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();
@@ -46,3 +46,12 @@ export async function downloadFileFromExplorer(page: Page, fileName: string): Pr
await page.getByRole("menuitem", { name: "Download", exact: true }).click();
return downloaded;
}
export async function downloadFileFromPreview(page: Page): Promise<Download> {
await expect(page.getByText("Binary preview unavailable", { exact: true })).toBeVisible();
const button = page.getByRole("button", { name: "Download", exact: true });
await expect(button).toBeVisible();
const downloaded = page.waitForEvent("download");
await button.click();
return downloaded;
}
@@ -92,7 +92,7 @@ export async function measureRelayRestartDuringStream(input: {
agentTitle: string;
}): Promise<RelayDeploymentMeasurements> {
const { page, relay, agentTitle } = input;
const toast = page.getByRole("alert").filter({ hasText: "Reconnecting to host" });
const toast = page.getByRole("alert").filter({ hasText: "Reconnecting" });
const beforeOutage = await latestAssistantText(page);
await waitForAssistantTextToGrow(page, beforeOutage);
@@ -106,6 +106,33 @@ export async function changeSettingsInterfaceFontSize(page: Page, size: number):
await input.fill(String(size));
await input.press("Tab");
await expect(input).toHaveValue(String(size));
await expect(
page.getByRole("navigation", { name: "Settings" }).getByText("Appearance", { exact: true }),
).toHaveCSS("font-size", `${size}px`);
}
export async function changeSettingsInterfaceFontSizeKeepingSidebarPosition(
page: Page,
size: number,
): Promise<number> {
const navigation = page.getByRole("navigation", { name: "Settings" });
const anchor = navigation.getByRole("button", { name: "About", exact: true });
const before = await anchor.evaluate((node) => ({
top: node.getBoundingClientRect().top,
height: node.getBoundingClientRect().height,
}));
await changeSettingsInterfaceFontSize(page, size);
// Larger fonts reflow rows. Preserve the visible destination, rather than its old pixel offset.
await expect
.poll(() =>
anchor.evaluate((node, top) => Math.abs(node.getBoundingClientRect().top - top), before.top),
)
.toBeLessThan(before.height);
const offset = await page
.getByTestId("settings-sidebar-scroll-body")
.evaluate((node) => node.scrollTop);
expect(offset).toBeGreaterThan(0);
return offset;
}
export async function scrollSettingsSidebarToEnd(page: Page): Promise<number> {
@@ -613,3 +640,192 @@ export async function expectRestartRecoveryPrompt(page: Page, prompt: string) {
await sheet.getByRole("button", { name: "Cancel", exact: true }).click();
await expect(sheet).toHaveCount(0);
}
export async function revealSettingsSyntaxTheme(page: Page) {
await page.getByLabel(/Highlight theme:/).scrollIntoViewIfNeeded();
const offset = await page
.getByTestId("settings-detail-scroll-body")
.evaluate((node) => node.scrollTop);
expect(offset).toBeGreaterThan(0);
const keywordColor = await page
.getByRole("img", { name: "Live preview of content typography, syntax theme, and code font" })
.getByText("export", { exact: true })
.first()
.evaluate((node) => getComputedStyle(node).color);
return { offset, keywordColor };
}
export async function changeSettingsSyntaxTheme(page: Page, theme: string): Promise<void> {
await page.getByLabel(/Highlight theme:/).click();
await page.getByRole("menuitem", { name: theme, exact: true }).click();
await expect(page.getByLabel(`Highlight theme: ${theme}`, { exact: true })).toBeVisible();
}
export async function expectSettingsSyntaxColorChanged(
page: Page,
previousColor: string,
): Promise<void> {
await expect(
page
.getByRole("img", { name: "Live preview of content typography, syntax theme, and code font" })
.getByText("export", { exact: true })
.first(),
).not.toHaveCSS("color", previousColor);
}
export async function changeSettingsTypography(page: Page): Promise<void> {
const input = page.getByRole("textbox", { name: "Interface font size", exact: true });
await input.fill("18");
await input.press("Enter");
await expect(input).toHaveValue("18");
await expect(input).toHaveCSS("font-size", "18px");
await expect(page.getByText("Code size", { exact: true })).toHaveCSS("font-size", "18px");
const navigation = page.getByRole("navigation", { name: "Settings" });
await expect(navigation.getByText("Appearance", { exact: true })).toHaveCSS("font-size", "18px");
await expect(navigation.getByText("Providers", { exact: true })).toHaveCSS("font-size", "18px");
await expect(page.getByText("Dracula", { exact: true })).toHaveCSS("font-size", "18px");
const familyInput = page.getByRole("textbox", { name: "Code font family", exact: true });
await familyInput.fill("Courier New, Courier, monospace");
await familyInput.press("Enter");
const codeInput = page.getByRole("textbox", { name: "Code font size", exact: true });
await codeInput.fill("16");
await codeInput.press("Enter");
await expect(codeInput).toHaveValue("16");
const preview = page.getByRole("img", {
name: "Live preview of content typography, syntax theme, and code font",
});
await expect(preview.getByText("export", { exact: true }).first()).toHaveCSS("font-size", "16px");
await expect(preview.getByText("export", { exact: true }).first()).toHaveCSS(
"line-height",
"24px",
);
await expect(preview.getByText("export", { exact: true }).first()).toHaveCSS(
"font-family",
/Courier/,
);
const contentInput = page.getByRole("textbox", { name: "Content font size", exact: true });
await contentInput.fill("20");
await contentInput.press("Enter");
await expect(preview.getByText("Readable content and code preview", { exact: true })).toHaveCSS(
"font-size",
"20px",
);
}
export async function normalizeSettingsFontSizes(page: Page): Promise<void> {
for (const [name, minimum, maximum] of [
["Interface font size", "10", "21"],
["Content font size", "10", "21"],
["Code font size", "9", "22"],
] as const) {
const input = page.getByRole("textbox", { name, exact: true });
await input.fill("999");
await input.press("Enter");
await expect(input).toHaveValue(maximum);
// Clamp again when the saved value is already at the maximum.
await input.fill("999");
await input.press("Tab");
await expect(input).toHaveValue(maximum);
await input.fill("");
await input.press("Enter");
await expect(input).toHaveValue(maximum);
await input.fill("1");
await input.press("Enter");
await expect(input).toHaveValue(minimum);
await input.fill("1");
await input.press("Tab");
await expect(input).toHaveValue(minimum);
}
}
/** Sample actual paints, including the first workspace frame after Settings. */
export async function captureCompactSidebarReturn(page: Page, returnAction: () => Promise<void>) {
await page.evaluate(() => {
const capture = {
frames: [] as {
time: number;
pathname: string;
sidebarVisible: boolean;
settingsVisible: boolean;
closeX: number | null;
}[],
stopped: false,
};
Object.assign(window, { __sidebarFrameCapture: capture });
const start = performance.now();
function sample() {
if (capture.stopped) return;
const close = document.querySelector('[data-testid="sidebar-close"]');
capture.frames.push({
time: performance.now() - start,
pathname: location.pathname,
settingsVisible:
document
.querySelector('[data-testid="settings-sidebar"]')
?.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true }) ?? false,
sidebarVisible:
(close?.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true }) ?? false) &&
close!.getBoundingClientRect().x >= 0 &&
close!.getBoundingClientRect().right <= innerWidth,
closeX: close?.getBoundingClientRect().x ?? null,
});
requestAnimationFrame(sample);
}
requestAnimationFrame(sample);
});
await returnAction();
return page.evaluate(async () => {
const capture = (
window as typeof window & {
__sidebarFrameCapture: {
frames: {
time: number;
pathname: string;
sidebarVisible: boolean;
settingsVisible: boolean;
closeX: number | null;
}[];
stopped: boolean;
};
}
).__sidebarFrameCapture;
await new Promise<void>((resolve) => setTimeout(resolve, 1500));
capture.stopped = true;
return capture.frames;
});
}
export async function captureCompactSidebarContext(page: Page) {
const sidebar = await page.getByTestId("sidebar-project-workspace-list-scroll").elementHandle();
if (!sidebar) throw new Error("Open sidebar has no workspace list");
await expect
.poll(() => sidebar.evaluate((el) => el.scrollHeight - el.clientHeight))
.toBeGreaterThan(0);
const offset = await sidebar.evaluate((el) => {
el.scrollTop = el.scrollHeight;
return el.scrollTop;
});
expect(offset).toBeGreaterThan(0);
return async () => {
expect(await sidebar.evaluate((el) => el.isConnected)).toBe(true);
expect(await sidebar.evaluate((el) => el.scrollTop)).toBe(offset);
await sidebar.dispose();
};
}
export async function expectCompactSidebarOpen(page: Page) {
await expect(page.getByRole("button", { name: "Close sidebar", exact: true })).toBeInViewport();
}
export async function closeCompactSidebar(page: Page) {
await page.getByRole("button", { name: "Close sidebar", exact: true }).click();
await expect(
page.getByRole("button", { name: "Open menu", exact: true }).first(),
).toBeInViewport();
}
export async function openSettingsFromCompactSidebar(page: Page) {
await page.getByRole("button", { name: "Settings", exact: true }).click();
await expectAppRoute(page, buildSettingsRoute());
await expectCompactSettingsList(page);
}
@@ -427,7 +427,11 @@ export async function holdOlderHistoryPages(
const loader = page.getByTestId("load-older-history-spinner");
return {
async expectRequestedPages(count) {
await gate.waitForRequestCount(count);
await expect
.poll(() => gate.getRequestCount(), {
message: `older history requests ${count} page(s)`,
})
.toBeGreaterThanOrEqual(count);
expect(gate.getRequestCount()).toBe(count);
},
async expectSettledWithRequestedPages(count) {
@@ -1,6 +1,5 @@
import { expect, type Page } from "@playwright/test";
import type { installDaemonWebSocketGate } from "./daemon-websocket-gate";
import { seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
import { readReplicaCache } from "./replica-cache-storage";
import type { LongTimelineAgent } from "./timeline-pagination";
import {
@@ -242,100 +241,3 @@ export async function restoreUnchangedViewedTimeline(
gate.restore();
await expectReconnectingToastGone(page);
}
export interface StreamingReplyAgent extends MockAgentWorkspace {
prompt: string;
}
/**
* A mock agent with no history whose reply streams one word every 100 ms. At 2,000 words the
* reply outlasts the test's timeout, so it is still streaming however long the app takes to
* reconnect. The test ends the turn with {@link stopStreamingReply}.
*/
export async function seedStreamingReplyAgent(): Promise<StreamingReplyAgent> {
const reply = Array.from({ length: 2_000 }, (_unused, index) => `word${index}`).join(" ");
const agent = await seedMockAgentWorkspace({
repoPrefix: "reply-reconnect-",
title: "Reply through reconnect",
featureValues: {
mockStreamingAssistantResponse: reply,
mockStreamingAssistantIntervalMs: 100,
},
});
return {
...agent,
prompt: "stream through a reconnect",
cleanup: async () => {
await agent.client.cancelAgent(agent.agentId).catch(() => undefined);
await agent.cleanup();
},
};
}
interface TimelineReader {
fetchAgentTimeline(
agentId: string,
request: { direction: "tail"; limit: number },
): Promise<{ entries: Array<{ item: { type: string; text?: string } }> }>;
}
async function readDaemonReply(agent: StreamingReplyAgent): Promise<string> {
const timeline = await (agent.client as unknown as TimelineReader).fetchAgentTimeline(
agent.agentId,
{ direction: "tail", limit: 0 },
);
return timeline.entries
.map(({ item }) => (item.type === "assistant_message" ? (item.text ?? "") : ""))
.join("");
}
/** Waits for the empty chat's first catch-up, so the chat is synced with no range. */
export async function expectEmptyChatSynced(page: Page, gate: DaemonWebSocketGate): Promise<void> {
await gate.waitForServerMessage(TIMELINE_RESPONSE);
await expect(page.getByTestId("agent-chat-scroll").filter({ visible: true })).toHaveCount(1, {
timeout: 30_000,
});
}
/** Another client sends the prompt, and the reply starts, while the chat is away. */
export async function sendPromptWhileDisconnected(agent: StreamingReplyAgent): Promise<void> {
await agent.client.sendAgentMessage(agent.agentId, agent.prompt);
await expect.poll(() => readDaemonReply(agent), { timeout: 30_000 }).toContain("word10");
}
/** Reconnects with live reply chunks reaching the chat before the catch-up page that holds them. */
export async function restoreViewedTimelineWithLiveRowsFirst(
gate: DaemonWebSocketGate,
): Promise<void> {
gate.holdNextClientRequest(TIMELINE_REQUEST);
const chunksBeforeReconnect = gate.getAgentStreamItemCount("assistant_message");
gate.restore();
await gate.waitForHeldClientRequest();
await gate.waitForAgentStreamItem("assistant_message", chunksBeforeReconnect + 5);
gate.releaseHeldClientRequest();
}
/** Stops the turn and returns the reply as the daemon recorded it. */
export async function stopStreamingReply(agent: StreamingReplyAgent): Promise<string> {
await agent.client.cancelAgent(agent.agentId);
await agent.client.waitForFinish(agent.agentId, 30_000);
const reply = (await readDaemonReply(agent)).trim();
expect(reply).toMatch(/^word0 word1 word2 .* word10 /);
return reply;
}
export async function expectPromptAndWholeReply(
page: Page,
agent: StreamingReplyAgent,
reply: string,
): Promise<void> {
const chat = page.getByTestId("agent-chat-scroll").filter({ visible: true }).first();
await expect(chat.getByText(agent.prompt, { exact: true })).toBeVisible({ timeout: 30_000 });
await expect
.poll(
async () =>
(await chat.getByTestId("assistant-message").allInnerTexts()).map((text) => text.trim()),
{ timeout: 30_000 },
)
.toEqual([reply]);
}
@@ -200,6 +200,18 @@ export async function openUsageFromItem(page: Page): Promise<void> {
await expect(usageModal(page)).toBeVisible();
}
export async function openUsageFromItemWithPressFeedback(page: Page): Promise<void> {
const summary = page.getByRole("button", { name: /^Usage:/ });
await summary.hover();
const hoveredFill = await summary.evaluate(
(element) => getComputedStyle(element).backgroundColor,
);
await page.mouse.down();
await expect(summary).not.toHaveCSS("background-color", hoveredFill);
await page.mouse.up();
await expect(usageModal(page)).toBeVisible();
}
/** Scrolls the Usage modal's body with the mouse wheel, as a user does. */
export async function scrollUsage(page: Page, deltaY: number): Promise<void> {
const body = await usageModal(page).boundingBox();
@@ -162,3 +162,38 @@ export function createGatedTeardown(): GatedTeardown {
release: () => writeFileSync(releasePath, "release\n"),
};
}
export function archivedToast(page: Page) {
return page.getByTestId("workspace-archived-toast");
}
export interface SidebarListLayout {
/** Bottom edge of the list's visible area. */
listBottom: number;
/** Bottom edges of the visible project and workspace rows, top to bottom. */
rowBottoms: number[];
rowHeight: number;
}
/** Scrolls the sidebar's workspace list to its end and reads where its rows sit. */
export async function scrollSidebarListToEnd(page: Page): Promise<SidebarListLayout> {
const list = page.getByTestId("sidebar-project-workspace-list-scroll");
await list.evaluate((element) => {
element.scrollTop = element.scrollHeight;
});
return list.evaluate((element) => {
const visible = element.getBoundingClientRect();
const rows = Array.from(
element.querySelectorAll<HTMLElement>(
'[data-testid^="sidebar-workspace-row-"],[data-testid^="sidebar-project-row-"]',
),
)
.map((row) => row.getBoundingClientRect())
.filter((row) => row.height > 0 && row.bottom > visible.top && row.top < visible.bottom);
return {
listBottom: visible.bottom,
rowBottoms: rows.map((row) => row.bottom).sort((a, b) => a - b),
rowHeight: Math.min(...rows.map((row) => row.height)),
};
});
}
@@ -109,18 +109,18 @@ export async function expectReconnectingToastVisible(
page: Page,
options?: { timeout?: number },
): Promise<void> {
const toast = page.getByRole("alert").filter({ hasText: "Reconnecting to host" });
const toast = page.getByRole("alert").filter({ hasText: "Reconnecting" });
await expect(toast).toBeVisible({
timeout: options?.timeout ?? 30_000,
});
await expect(toast).toHaveText("Reconnecting to host");
await expect(toast).toHaveText("Reconnecting");
}
export async function expectReconnectingToastGone(
page: Page,
options?: { timeout?: number },
): Promise<void> {
await expect(page.getByRole("alert").filter({ hasText: "Reconnecting to host" })).toHaveCount(0, {
await expect(page.getByRole("alert").filter({ hasText: "Reconnecting" })).toHaveCount(0, {
timeout: options?.timeout ?? 30_000,
});
}
@@ -0,0 +1,282 @@
package sh.paseo.scroll
import android.app.Activity
import android.content.Intent
import android.os.SystemClock
import android.text.InputType
import android.view.MotionEvent
import android.view.View
import android.view.ViewConfiguration
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputConnection
import android.widget.EditText
import android.widget.FrameLayout
import android.widget.TextView
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.facebook.react.soloader.OpenSourceMergedSoMapping
import com.facebook.react.uimanager.DisplayMetricsHolder
import com.facebook.react.uimanager.ThemedReactContext
import com.facebook.react.views.scroll.ReactScrollView
import com.facebook.react.views.scroll.ReactScrollViewManager
import com.facebook.react.views.view.ReactViewGroup
import com.facebook.soloader.SoLoader
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
/**
* Touches on chat text in the timeline's scroll view. Chat text is selectable, so a tap or a long
* press focuses it, and the focus may not scroll the timeline. The touches are injected into a real
* window, so touch mode, focus and the selection controller run as on device.
*/
@RunWith(AndroidJUnit4::class)
class SelectableTextTouchTest {
private val instrumentation = InstrumentationRegistry.getInstrumentation()
private var activity: Activity? = null
@After fun finish() {
activity?.finish()
}
// A reply whose top is on screen and whose bottom is below the viewport.
@Test fun tappingTextThatRunsBelowTheViewportKeepsTheOffset() {
val timeline = openTimeline(edge = Edge.TOP)
timeline.tap(visibleY = EDGE_Y + 100)
assertTrue("The tap focuses the text", timeline.textHasFocus())
assertEquals("Offset after the tap", OFFSET, timeline.settledScrollY())
}
// A reply whose bottom is on screen and whose top is above the viewport, like a link at its end.
@Test fun tappingTextThatStartsAboveTheViewportKeepsTheOffset() {
val timeline = openTimeline(edge = Edge.BOTTOM)
timeline.tap(visibleY = EDGE_Y - 100)
assertTrue("The tap focuses the text", timeline.textHasFocus())
assertEquals("Offset after the tap", OFFSET, timeline.settledScrollY())
}
// A reply that is still streaming gets new text while the user's tap holds its focus.
@Test fun aStreamingUpdateToTappedTextKeepsTheOffset() {
val timeline = openTimeline(edge = Edge.BOTTOM)
timeline.tap(visibleY = EDGE_Y - 100)
assertTrue("The tap focuses the text", timeline.textHasFocus())
timeline.updateText("$CHAT_TEXT\nOne more line of the reply.")
assertEquals("Offset after the update", OFFSET, timeline.settledScrollY())
}
// The timeline changes height while tapped text holds focus, as when the composer above the
// keyboard loses its queued message.
@Test fun aTimelineResizeAfterATapKeepsTheOffset() {
val timeline = openTimeline(edge = Edge.TOP)
timeline.tap(visibleY = EDGE_Y + 100)
assertTrue("The tap focuses the text", timeline.textHasFocus())
timeline.resize(height = VIEWPORT + 200)
assertEquals("Offset after the resize", OFFSET, timeline.settledScrollY())
}
@Test fun longPressSelectsAWordAndKeepsTheOffset() {
val timeline = openTimeline(edge = Edge.TOP)
val word = timeline.wordAt(visibleY = EDGE_Y + 100)
timeline.longPress(visibleY = EDGE_Y + 100)
assertEquals("Selected text", word, timeline.selectedText())
assertEquals("Offset after the long press", OFFSET, timeline.settledScrollY())
}
// Inputs in the timeline, such as an agent's question form, still scroll into view to be typed in.
@Test fun tappingAnInputThatRunsBelowTheViewportRevealsIt() {
val timeline = openTimeline(edge = Edge.TOP, editable = true)
timeline.tap(visibleY = EDGE_Y + 100)
assertTrue("The tap focuses the input", timeline.textHasFocus())
assertNotEquals("Offset after the tap", OFFSET, timeline.settledScrollY())
}
private inner class Timeline(private val scroll: ReactScrollView, private val text: TextView) {
fun scrollTo(y: Int) {
instrumentation.runOnMainSync { scroll.scrollTo(0, y) }
awaitFrames(2)
}
fun tap(visibleY: Int) {
val downTime = SystemClock.uptimeMillis()
touch(downTime, MotionEvent.ACTION_DOWN, visibleY)
touch(downTime, MotionEvent.ACTION_UP, visibleY)
}
fun longPress(visibleY: Int) {
val downTime = SystemClock.uptimeMillis()
touch(downTime, MotionEvent.ACTION_DOWN, visibleY)
SystemClock.sleep(ViewConfiguration.getLongPressTimeout() * 2L)
touch(downTime, MotionEvent.ACTION_UP, visibleY)
}
fun resize(height: Int) {
instrumentation.runOnMainSync {
scroll.layoutParams = scroll.layoutParams.apply { this.height = height }
}
}
// Replaces the text and lays it out again at its top, the way a streaming reply mounts.
fun updateText(value: String) {
instrumentation.runOnMainSync {
text.setText(value)
text.measure(exact(WIDTH), unspecified())
text.layout(0, text.top, WIDTH, text.top + text.measuredHeight)
}
}
fun textHasFocus(): Boolean {
awaitFrames(2)
var focused = false
instrumentation.runOnMainSync { focused = text.isFocused }
return focused
}
fun selectedText(): String {
awaitFrames(2)
var selected = ""
instrumentation.runOnMainSync {
selected = text.text.substring(text.selectionStart, text.selectionEnd)
}
return selected
}
/** The word under [visibleY] at the current offset. */
fun wordAt(visibleY: Int): String {
var word = ""
instrumentation.runOnMainSync {
val chars = text.text
val y = scroll.scrollY + visibleY - text.top
val offset = text.getOffsetForPosition(TOUCH_X - text.left, y.toFloat())
var start = offset
var end = offset
while (start > 0 && chars[start - 1].isLetterOrDigit()) start--
while (end < chars.length && chars[end].isLetterOrDigit()) end++
word = chars.substring(start, end)
}
assertTrue("The touch lands on a word", word.isNotEmpty())
return word
}
fun settledScrollY(): Int {
var previous = scrollY()
repeat(60) {
awaitFrames(3)
val current = scrollY()
if (current == previous) return current
previous = current
}
throw AssertionError("Offset never settled")
}
private fun scrollY(): Int {
var y = 0
instrumentation.runOnMainSync { y = scroll.scrollY }
return y
}
// Injected through the window like a finger, which puts the window in touch mode.
private fun touch(downTime: Long, action: Int, visibleY: Int) {
val location = IntArray(2)
instrumentation.runOnMainSync { scroll.getLocationOnScreen(location) }
val x = location[0] + TOUCH_X
val y = location[1] + visibleY.toFloat()
val event = MotionEvent.obtain(downTime, SystemClock.uptimeMillis(), action, x, y, 0)
instrumentation.sendPointerSync(event)
event.recycle()
}
fun awaitFrames(count: Int) {
repeat(count) {
instrumentation.waitForIdleSync()
SystemClock.sleep(16)
}
}
}
/** Which edge of the text is on screen, at [EDGE_Y]; the other is past the viewport. */
private enum class Edge { TOP, BOTTOM }
/** A timeline scrolled to [OFFSET] with one block of chat text taller than the viewport. */
private fun openTimeline(edge: Edge, editable: Boolean = false): Timeline {
val started =
instrumentation.startActivitySync(
Intent(instrumentation.targetContext, Activity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
activity = started
lateinit var timeline: Timeline
instrumentation.runOnMainSync {
DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(started)
SoLoader.init(started, OpenSourceMergedSoMapping)
val reactContext = MountingReactContext(started.applicationContext)
val context = ThemedReactContext(reactContext, started)
val manager =
PaseoScrollPackage().createViewManager(reactContext, ReactScrollViewManager.REACT_CLASS)
as ReactScrollViewManager
val scroll = manager.createViewInstance(context)
val content = ReactViewGroup(context)
val text =
if (editable) {
// Selectable, as React Native's TextInput makes itself when attached, so only being a text
// editor tells it apart from chat text. No input connection: building one runs React
// Native's focus search through Fabric, which this context does not run.
object : EditText(context) {
override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection? = null
}.apply {
inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_MULTI_LINE
setTextIsSelectable(true)
}
} else {
// How enriched-markdown and React Native's <Text selectable> set up chat text.
TextView(context).apply { setTextIsSelectable(true) }
}
text.setText(CHAT_TEXT)
content.addView(text)
scroll.addView(content)
started.setContentView(
FrameLayout(started).apply { addView(scroll, FrameLayout.LayoutParams(WIDTH, VIEWPORT)) }
)
// Fabric lays out React content; nothing in a React view group lays out its children itself.
content.layout(0, 0, WIDTH, CONTENT_HEIGHT)
text.measure(exact(WIDTH), unspecified())
assertTrue("The text is taller than the viewport", text.measuredHeight > VIEWPORT)
val top = if (edge == Edge.TOP) OFFSET + EDGE_Y else OFFSET + EDGE_Y - text.measuredHeight
text.layout(0, top, WIDTH, top + text.measuredHeight)
timeline = Timeline(scroll, text)
}
timeline.awaitFrames(2)
timeline.scrollTo(OFFSET)
assertEquals("Starts at the offset", OFFSET, timeline.settledScrollY())
return timeline
}
private fun exact(size: Int) = View.MeasureSpec.makeMeasureSpec(size, View.MeasureSpec.EXACTLY)
private fun unspecified() = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
private companion object {
const val WIDTH = 600
const val TOUCH_X = WIDTH / 2f
const val VIEWPORT = 800
const val CONTENT_HEIGHT = 6000
const val OFFSET = 2400
// Below the plain activity's action bar, which overlays the top of the window.
const val EDGE_Y = 500
val CHAT_TEXT = List(30) { "Line $it of a reply in the chat timeline." }.joinToString("\n")
}
}
@@ -1,10 +1,12 @@
package sh.paseo.scroll;
import android.content.Context;
import android.graphics.Rect;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewTreeObserver;
import android.widget.TextView;
import androidx.annotation.Nullable;
import com.facebook.react.R;
import com.facebook.react.views.scroll.MaintainVisibleScrollPositionHelper;
@@ -12,7 +14,7 @@ import com.facebook.react.views.scroll.ReactScrollView;
import com.facebook.react.views.scroll.ReactScrollViewHelper;
/**
* React Native's vertical scroll view with two corrections. Java, because the content-position
* React Native's vertical scroll view with five corrections. Java, because the content-position
* config type is internal to React Native's Kotlin module.
*/
final class PaseoScrollView extends ReactScrollView {
@@ -27,6 +29,7 @@ final class PaseoScrollView extends ReactScrollView {
private @Nullable MaintainVisibleScrollPositionHelper.Config visibleContentPosition = null;
private boolean isClampPending = false;
private boolean isUserDragging = false;
private boolean isFocusRevealSkipped = false;
PaseoScrollView(Context context) {
super(context);
@@ -51,6 +54,56 @@ final class PaseoScrollView extends ReactScrollView {
super.scrollToPreservingMomentum(x, y);
}
// Chat text is selectable (enriched-markdown's text views and <Text selectable>), and selectable
// text takes focus when touched: a tap on a link, a word, or a long-press to select. React Native
// scrolls a newly focused child fully into view, so touching a reply that is partly off screen
// scrolled the timeline by up to a screen under the user's finger. Text the user touched is
// already where they are looking, so its focus does not scroll. Inputs, and focus moved by a
// keyboard, still scroll into view.
@Override
public void requestChildFocus(View child, View focused) {
if (isTouchedReadOnlyText(focused)) {
requestChildFocusWithoutScroll(child, focused);
return;
}
super.requestChildFocus(child, focused);
}
// Focused text asks to reveal its selection whenever the selection changes. A streaming reply
// replaces its text, which collapses the selection to the start, so a reply that kept focus from
// a touch scrolled the timeline back to its first line on the next update. Read-only text has no
// caret, so a collapsed selection has nothing to reveal. A selection the user drags still does.
@Override
public boolean requestChildRectangleOnScreen(View child, Rect rectangle, boolean immediate) {
View focused = findFocus();
if (isTouchedReadOnlyText(focused) && !((TextView) focused).hasSelection()) return false;
return super.requestChildRectangleOnScreen(child, rectangle, immediate);
}
// When the scroll view changes height, Android scrolls the focused view into view again. Touched
// chat text keeps focus, so the composer growing or shrinking below the timeline realigned it.
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
isFocusRevealSkipped = isTouchedReadOnlyText(findFocus());
try {
super.onSizeChanged(w, h, oldw, oldh);
} finally {
isFocusRevealSkipped = false;
}
}
@Override
protected int computeScrollDeltaToGetChildRectOnScreen(Rect rect) {
if (isFocusRevealSkipped) return 0;
return super.computeScrollDeltaToGetChildRectOnScreen(rect);
}
private static boolean isTouchedReadOnlyText(@Nullable View view) {
if (!(view instanceof TextView) || !view.isInTouchMode()) return false;
TextView text = (TextView) view;
return text.isTextSelectable() && !text.onCheckIsTextEditor();
}
// The drag begins where React Native emits onScrollBeginDrag.
@Override
protected void handleInterceptedTouchEvent(MotionEvent ev) {
+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: {
+4 -2
View File
@@ -81,12 +81,14 @@
/* Suppress the browser's default focus outline — it appears on mouse
clicks and looks out of place against our themed surfaces. Keep a
themed ring for keyboard users via :focus-visible. */
themed ring for keyboard users via :focus-visible. The ring is the active
theme's `ring` color, exposed by Unistyles as a CSS variable; the fallback
only shows before the theme mounts. */
*:focus {
outline: none;
}
*:focus-visible {
outline: 2px solid #20744a;
outline: 2px solid var(--colors-ring, #20744a);
outline-offset: 2px;
}
</style>
+4 -6
View File
@@ -5,7 +5,7 @@ import equal from "fast-deep-equal";
import { normalizeWorkspacePath } from "@/utils/workspace-identity";
export const AGENT_COMMANDS_QUERY_ROOT = "agentCommands";
export const sessionCommandQueryPolicy = {
export const agentCommandQueryPolicy = {
staleTime: 600_000,
gcTime: 1_800_000,
refetchOnMount: true,
@@ -102,14 +102,12 @@ export function draftAgentCommandsQueryKey(input: {
] as const;
}
// Draft commands include project skills read from the checkout. When the checkout moves to
// another branch the cached list describes files that are gone, so it is dropped rather than
// shown while a refetch runs.
export function resetDraftAgentCommandsForCheckout(
// Refresh checkout skills after branch changes while keeping the previous catalog usable.
export function invalidateDraftAgentCommandsForCheckout(
queryClient: QueryClient,
input: { serverId: string; cwd: string },
): Promise<void> {
return queryClient.resetQueries({ queryKey: draftAgentCommandsCheckoutScope(input) });
return queryClient.invalidateQueries({ queryKey: draftAgentCommandsCheckoutScope(input) });
}
export function agentCommandsQueryKey(input: {
+36 -3
View File
@@ -2,12 +2,13 @@ import { describe, expect, it } from "vitest";
import { QueryClient, QueryObserver } from "@tanstack/react-query";
import { fetchQueryOptions } from "@/data/query";
import {
invalidateDraftAgentCommandsForCheckout,
agentCommandsQueryKey,
agentCommandsQueryRoot,
draftAgentCommandsQueryKey,
normalizeAgentCommandsCwd,
sessionAgentCommandsQueryKey,
sessionCommandQueryPolicy,
agentCommandQueryPolicy,
} from "./cache";
it("reuses fresh commands and keeps stale commands during a shared refresh and failure", async () => {
@@ -20,8 +21,8 @@ it("reuses fresh commands and keeps stale commands during a shared refresh and f
resolveRefresh = resolve;
});
const options = fetchQueryOptions({
...sessionCommandQueryPolicy,
staleTimeMs: sessionCommandQueryPolicy.staleTime,
...agentCommandQueryPolicy,
staleTimeMs: agentCommandQueryPolicy.staleTime,
dataShape: "value",
queryKey: key,
retry: false,
@@ -124,3 +125,35 @@ describe("agent command query keys", () => {
expect(agentCommandsQueryRoot("server-1")).toEqual(["agentCommands", "server-1"]);
});
});
it("retains draft commands while checkout invalidation refreshes the catalog", async () => {
const client = new QueryClient();
const key = draftAgentCommandsQueryKey({
serverId: "s",
draftConfig: { provider: "codex", cwd: "/repo" },
});
client.setQueryData(key, ["old"]);
let finish: (commands: string[]) => void = () => {};
const refresh = new Promise<string[]>((resolve) => {
finish = resolve;
});
const observer = new QueryObserver(
client,
fetchQueryOptions({
queryKey: key,
dataShape: "value",
immutableWhen: () => true,
queryFn: () => refresh,
}),
);
const unsubscribe = observer.subscribe(() => {});
const updating = invalidateDraftAgentCommandsForCheckout(client, { serverId: "s", cwd: "/repo" });
expect(observer.getCurrentResult().data).toEqual(["old"]);
expect(observer.getCurrentResult().isPending).toBe(false);
expect(observer.getCurrentResult().isFetching).toBe(true);
finish(["new"]);
await updating;
expect(observer.getCurrentResult().data).toEqual(["new"]);
unsubscribe();
client.clear();
});
+3 -4
View File
@@ -9,7 +9,7 @@ import { useShallow } from "zustand/shallow";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import {
agentCommandsQueryKey,
sessionCommandQueryPolicy,
agentCommandQueryPolicy,
type AgentCommandsDraftConfig,
} from "./cache";
@@ -92,9 +92,8 @@ export function useAgentCommandsQuery({
agentCommandProvider: draftConfig?.provider ?? provider,
agentCommandCwd: draftConfig?.cwd ?? cwd,
},
...(draftConfig
? { immutableWhen: () => true as const }
: { ...sessionCommandQueryPolicy, staleTimeMs: sessionCommandQueryPolicy.staleTime }),
...agentCommandQueryPolicy,
staleTimeMs: agentCommandQueryPolicy.staleTime,
subscribed: queryEnabled,
retry: 3,
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 5000),
@@ -49,8 +49,21 @@ afterEach(() => {
host.remove();
});
function mountTimeline(historyMounted: StreamItem[]) {
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
type OlderHistoryInput = Pick<
StreamRenderInput,
"onNearHistoryStart" | "hasOlderHistory" | "olderHistoryProgressKey"
>;
interface MountTimelineOptions {
olderHistory?: OlderHistoryInput;
isMobileBreakpoint?: boolean;
}
function mountTimeline(
historyMounted: StreamItem[],
{ olderHistory, isMobileBreakpoint = true }: MountTimelineOptions = {},
) {
const strategy = createWebStreamStrategy({ isMobileBreakpoint });
const viewportRef = React.createRef<StreamViewportHandle>();
const input: StreamRenderInput = {
agentId: "agent",
@@ -76,6 +89,7 @@ function mountTimeline(historyMounted: StreamItem[]) {
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
...olderHistory,
};
const render = (segments: Partial<StreamRenderInput["segments"]>) =>
root.render(strategy.render({ ...input, segments: { ...input.segments, ...segments } }));
@@ -100,8 +114,20 @@ function mountTimeline(historyMounted: StreamItem[]) {
scrollTo: (top: number) => {
scrollContainer.scrollTop = top;
},
// A wheel listener runs before the browser applies the scroll the wheel causes.
wheelUpTo: (top: number) => {
scrollContainer.dispatchEvent(new WheelEvent("wheel", { bubbles: true, deltaY: -1000 }));
scrollContainer.scrollTop = top;
},
distanceFromEnd: () =>
scrollContainer.scrollHeight - scrollContainer.clientHeight - scrollContainer.scrollTop,
scrollTop: () => scrollContainer.scrollTop,
scrollUp: (distance: number) =>
act(() => {
scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -distance }));
scrollContainer.scrollTop -= distance;
scrollContainer.dispatchEvent(new Event("scroll"));
}),
// Resize observers and the hold's settle run on animation frames.
settle: () =>
act(async () => {
@@ -113,6 +139,53 @@ function mountTimeline(historyMounted: StreamItem[]) {
}
describe("web disclosure hold", () => {
it.each([{ isMobileBreakpoint: true }, { isMobileBreakpoint: false }])(
"keeps the reading position when mounted history becomes virtualized (compact: $isMobileBreakpoint)",
async (options) => {
const history = Array.from({ length: 80 }, (_, index) => row(index));
const timeline = mountTimeline(history, options);
await timeline.settle();
timeline.scrollUp(600);
await timeline.settle();
const offset = timeline.scrollTop();
const readingTop = timeline.rowTop("message-64");
expect(offset).toBeGreaterThan(0);
timeline.render({
historyVirtualized: history.slice(0, 60),
historyMounted: history.slice(60),
});
await timeline.settle();
expect(timeline.scrollTop()).toBe(offset);
expect(timeline.rowTop("message-64")).toBe(readingTop);
},
);
it.each([{ isMobileBreakpoint: true }, { isMobileBreakpoint: false }])(
"keeps following output when mounted history becomes virtualized at the end (compact: $isMobileBreakpoint)",
async (options) => {
const history = Array.from({ length: 80 }, (_, index) => row(index));
const timeline = mountTimeline(history, options);
await timeline.settle();
timeline.render({
historyVirtualized: history.slice(0, 60),
historyMounted: history.slice(60),
});
await timeline.settle();
expect(timeline.distanceFromEnd()).toBeLessThanOrEqual(1);
timeline.render({
historyVirtualized: history.slice(0, 60),
historyMounted: history.slice(60),
liveHead: [row(80)],
});
await timeline.settle();
expect(timeline.distanceFromEnd()).toBeLessThanOrEqual(1);
},
);
it("keeps the trigger's top when an anchored toggle at the bottom opens rows below it, and stops following", async () => {
const history = [1, 2, 3, 4, 5, 6].map((index) => row(index));
const timeline = mountTimeline(history);
@@ -208,3 +281,32 @@ describe("web disclosure hold", () => {
expect(timeline.distanceFromEnd()).toBeLessThanOrEqual(1);
});
});
describe("web history start", () => {
it("loads older history when an upward wheel lands at the start after the reader stopped following", async () => {
let olderHistoryLoads = 0;
const history = [1, 2, 3, 4, 5, 6].map((index) => row(index));
const timeline = mountTimeline(history, {
olderHistory: {
hasOlderHistory: true,
olderHistoryProgressKey: "epoch:1",
onNearHistoryStart: async () => {
olderHistoryLoads += 1;
return true;
},
},
});
await timeline.settle();
// Opening a row at the bottom stops following output without any scroll.
timeline.hold("message-5", () =>
timeline.render({ historyMounted: [...history.slice(0, 5), row(7, 200), history[5]!] }),
);
await timeline.settle();
expect(olderHistoryLoads).toBe(0);
timeline.wheelUpTo(0);
await timeline.settle();
expect(olderHistoryLoads).toBe(1);
});
});
+19 -6
View File
@@ -457,6 +457,8 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const rowVirtualizer = useVirtualizer({
count: segments.historyVirtualized.length,
enabled: shouldUseVirtualizer,
// History can become virtualized after the reader has already scrolled the mounted rows.
initialOffset: () => scrollContainerRef.current?.scrollTop ?? 0,
initialRect: {
width: scrollContainerRef.current?.clientWidth ?? 0,
height: scrollContainerRef.current?.clientHeight ?? 0,
@@ -605,6 +607,15 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
window.performance.now() + UPWARD_INPUT_EVIDENCE_TIMEOUT_MS;
}, []);
const hasUpwardInputEvidence = useCallback(() => {
const mouseGesture = mouseScrollGestureRef.current;
return (
mouseGesture?.kind === "scrollbar" ||
(mouseGesture?.kind === "autoscroll" && mouseGesture.hasUpwardEvidence) ||
window.performance.now() < upwardInputEvidenceUntilRef.current
);
}, []);
useLayoutEffect(() => {
if (isActive) {
return;
@@ -791,16 +802,16 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - USER_SCROLL_DELTA_EPSILON;
const scrolledDown =
currentScrollTop > lastKnownScrollTopRef.current + USER_SCROLL_DELTA_EPSILON;
const mouseGesture = mouseScrollGestureRef.current;
const hasUpwardInputEvidence =
mouseGesture?.kind === "scrollbar" ||
(mouseGesture?.kind === "autoscroll" && mouseGesture.hasUpwardEvidence) ||
window.performance.now() < upwardInputEvidenceUntilRef.current;
const userScrolledUp = scrolledUp && hasUpwardInputEvidence();
if (!followOutputRef.current && isAtBottom && scrolledDown && !isJumpSettling()) {
setFollowOutput(true);
} else if (followOutputRef.current && scrolledUp && hasUpwardInputEvidence) {
} else if (followOutputRef.current && userScrolledUp) {
stopFollowingOutputFromUserIntent();
} else if (userScrolledUp) {
// Wheel listeners can run before or after the browser applies the scroll, so the
// upward scroll itself re-arms loading when it reaches the start.
rearmHistoryStartFromUserIntent();
}
lastKnownScrollTopRef.current = currentScrollTop;
@@ -811,8 +822,10 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
evaluateHistoryStart();
}, [
evaluateHistoryStart,
hasUpwardInputEvidence,
isJumpSettling,
readingAnchor,
rearmHistoryStartFromUserIntent,
stopFollowingOutputFromUserIntent,
updateScrollMetrics,
]);
+57 -10
View File
@@ -87,7 +87,10 @@ import {
type StartupBlocker,
} from "@/navigation/host-runtime-bootstrap";
import { registerWorkspaceRouteNavigationRef } from "@/navigation/workspace-route-navigation";
import { useSettingsRouteEntry } from "@/navigation/settings-presentation";
import {
useSettingsRouteEntry,
useSettingsSidebarPresentation,
} from "@/navigation/settings-presentation";
import { ThemedStack } from "@/navigation/themed-stack";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { AgentNavigationListener } from "@/desktop/agent-navigation";
@@ -473,12 +476,16 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
viewportWidth,
}),
});
const sidebarPresentation = resolveLeftSidebarPresentation({
chromeEnabled,
isCompactLayout,
isFocusModeEnabled: isWorkspaceFocusModeEnabled,
desktopSidebarVisible,
});
const { presentation: sidebarPresentation, visible: sidebarVisible } =
useSettingsSidebarPresentation({
visible: chromeEnabled,
presentation: resolveLeftSidebarPresentation({
chromeEnabled,
isCompactLayout,
isFocusModeEnabled: isWorkspaceFocusModeEnabled,
desktopSidebarVisible,
}),
});
const applyPanelLayout = usePanelStore((state) => state.applyPanelLayout);
// Publishes the layout to the store, which owns the panel targets each layout allows.
useLayoutEffect(() => {
@@ -546,7 +553,11 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
<DesktopWindowControls />
<FloatingPanelPortalHost />
</AppearanceStyleBoundary>
{isCompactLayout ? themedSidebarChrome : null}
{isCompactLayout ? (
<CompactSidebarHost visible={sidebarVisible} interactive={chromeEnabled}>
{themedSidebarChrome}
</CompactSidebarHost>
) : null}
<AppearanceStyleBoundary>
<DownloadToast />
<RosettaCalloutSource />
@@ -584,6 +595,34 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
);
}
// Keep native scroll ranges and gesture hosts intact while Settings covers the sidebar.
function CompactSidebarHost({
visible,
interactive,
children,
}: {
visible: boolean;
interactive: boolean;
children: ReactNode;
}) {
return (
<View
collapsable={false}
style={[
layoutStyles.compactSidebarHost,
{ opacity: visible ? 1 : 0 },
// DOM descendants can override pointer-events:none.
isWeb && !visible && { display: "none" },
]}
pointerEvents={interactive ? "box-none" : "none"}
accessibilityElementsHidden={!interactive}
importantForAccessibility={interactive ? "auto" : "no-hide-descendants"}
>
{children}
</View>
);
}
function AppContainerLifetime({
children,
sidebarGesturesEnabled,
@@ -614,14 +653,18 @@ function SidebarChrome({
const isCompactLayout = useIsCompactFormFactor();
const isMobileActive = useIsMobilePanelActive("agent-list");
const overlay = presentation === "overlay";
const active = resolveSidebarActivity(presentation !== "hidden", overlay, isMobileActive);
const active = resolveSidebarActivity(
presentation !== "hidden" && presentation !== "suspended",
overlay,
isMobileActive,
);
const hardwareKeyboardPresent = useHardwareKeyboardPresent();
return (
<SidebarModelProvider
active={active}
keyboardNavigationActive={keyboardShortcutsEnabled && hardwareKeyboardPresent}
>
{mounted ? (
{mounted || presentation === "suspended" ? (
<LeftSidebar active={active} desktopOverlay={overlay && !isCompactLayout} />
) : null}
<WorkspaceShortcutTargetsSubscriber enabled={keyboardShortcutsEnabled} />
@@ -864,6 +907,8 @@ const ROOT_STACK_SCREEN_OPTIONS = {
animation: "none" as const,
};
const ROOT_STACK_NESTED_NAVIGATOR_SCREENS = ["h/[serverId]"] as const;
// Settings owns reactive typography leaves; its scroll and form state survive appearance edits.
const ROOT_STACK_SCREEN_OWNED_APPEARANCE = ["settings/[section]"] as const;
function RootStack() {
const storeReady = useStoreReady();
@@ -871,6 +916,7 @@ function RootStack() {
<ThemedStack
screenOptions={ROOT_STACK_SCREEN_OPTIONS}
nestedNavigatorScreens={ROOT_STACK_NESTED_NAVIGATOR_SCREENS}
screenOwnedAppearance={ROOT_STACK_SCREEN_OWNED_APPEARANCE}
>
<Stack.Screen name="index" />
<Stack.Protected guard={storeReady}>
@@ -1004,6 +1050,7 @@ export default function RootLayout() {
}
const layoutStyles = StyleSheet.create((theme) => ({
compactSidebarHost: { position: "absolute", top: 0, bottom: 0, left: 0, right: 0 },
surfaceFill: {
flex: 1,
backgroundColor: theme.colors.surface0,
+3
View File
@@ -0,0 +1,3 @@
import { Text } from "react-native";
export { Text as AppearanceText };
+12
View File
@@ -0,0 +1,12 @@
import { Text as NativeText } from "react-native";
import { withUnistyles } from "react-native-unistyles";
import type { Theme } from "@/styles/theme";
// Web numeric tokens are baked into classes. Subscribe this leaf so withUnistyles
// resolves its style prop again without replacing the surrounding form or scroll view.
function typographyChanges(theme: Theme) {
void theme.fontSize;
return {};
}
export const AppearanceText = withUnistyles(NativeText, typographyChanges);
+2 -1
View File
@@ -1,6 +1,7 @@
import { AppearanceText as Text } from "@/appearance/text";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Text, View, type PressableStateCallbackType } from "react-native";
import { View, type PressableStateCallbackType } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { PressHighlight } from "@/components/ui/press-highlight";
import { DEFAULT_THEME_PREFERENCE, useAppSettings, type AppSettings } from "@/hooks/use-settings";
@@ -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;
}
+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,
);
};
}
+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}
/>
@@ -0,0 +1,17 @@
import { Archive } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { withUnistyles } from "react-native-unistyles";
import { StatusBadge } from "@/components/ui/status-badge";
const ThemedArchive = withUnistyles(Archive, (theme) => ({
size: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
}));
const ARCHIVE_ICON = <ThemedArchive />;
/** Marks a session or workspace the user archived, wherever archived items are listed. */
export function ArchivedBadge() {
const { t } = useTranslation();
return <StatusBadge label={t("agentList.badges.archived")} leading={ARCHIVE_ICON} />;
}
@@ -0,0 +1,149 @@
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { persistAttachmentFromBlob } from "@/attachments/service";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { i18n } from "@/i18n/i18next";
import { WindowLayoutProvider } from "@/window-layout";
import { AttachmentLightbox, type ImageLightboxSource } from "./attachment-lightbox";
interface FrameSample {
image: { x: number; y: number; width: number; height: number } | null;
error: boolean;
}
function nextFrame(): Promise<number> {
return new Promise((resolve) => requestAnimationFrame(resolve));
}
function createPng(width: number, height: number): Promise<Blob> {
const canvas = new OffscreenCanvas(width, height);
const context = canvas.getContext("2d");
if (!context) throw new Error("Canvas 2D context unavailable");
context.fillStyle = "#3a7";
context.fillRect(0, 0, width, height);
return canvas.convertToBlob({ type: "image/png" });
}
function noop() {}
function isPainted(element: Element): boolean {
for (let node: Element | null = element; node; node = node.parentElement) {
if (Number(getComputedStyle(node).opacity) === 0) return false;
}
return true;
}
/** What the next paint will show: the visible lightbox image box, and whether the error is up. */
function sampleFrame(): FrameSample {
const image = document.querySelector('[data-testid="attachment-lightbox-image"]');
const rect = image?.getBoundingClientRect();
const visible = image && rect && rect.width > 0 && rect.height > 0 && isPainted(image);
return {
image: visible
? {
x: Math.round(rect.x),
y: Math.round(rect.y),
width: Math.round(rect.width),
height: Math.round(rect.height),
}
: null,
error: (document.body.textContent ?? "").includes("Couldn't load image"),
};
}
/** Samples every frame from the open until the image has held one box for 20 frames. */
async function recordOpening(open: () => void): Promise<FrameSample[]> {
const samples: FrameSample[] = [];
open();
let stableFrames = 0;
for (let frame = 0; frame < 300 && stableFrames < 20; frame += 1) {
await nextFrame();
const sample = sampleFrame();
const previous = samples.at(-1);
stableFrames =
sample.image && JSON.stringify(sample.image) === JSON.stringify(previous?.image)
? stableFrames + 1
: 0;
samples.push(sample);
}
return samples;
}
let root: Root;
let container: HTMLDivElement;
beforeEach(async () => {
vi.stubGlobal("React", React);
await i18n.changeLanguage("en");
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
document.body.replaceChildren();
vi.unstubAllGlobals();
});
function renderLightbox(source: ImageLightboxSource | null) {
root.render(
<SafeAreaProvider>
<WindowLayoutProvider>
<AttachmentLightbox source={source} onClose={noop} />
</WindowLayoutProvider>
</SafeAreaProvider>,
);
}
async function expectOpeningPaintsOnlyTheFittedImage(source: ImageLightboxSource) {
const samples = await recordOpening(() => renderLightbox(source));
const fitted = samples.at(-1)?.image;
expect(fitted, "the image never settled").toBeTruthy();
expect(samples.filter((sample) => sample.error)).toEqual([]);
expect(
samples.filter(
(sample) => sample.image && JSON.stringify(sample.image) !== JSON.stringify(fitted),
),
).toEqual([]);
}
describe("AttachmentLightbox opening in the browser", () => {
it("paints the fitted attachment image as its first image frame", async () => {
const metadata = await persistAttachmentFromBlob({
blob: await createPng(2400, 1600),
mimeType: "image/png",
fileName: "screenshot.png",
});
await expectOpeningPaintsOnlyTheFittedImage({ type: "attachment", metadata });
});
it("paints only the fitted image when the same attachment opens again", async () => {
const metadata = await persistAttachmentFromBlob({
blob: await createPng(2400, 1600),
mimeType: "image/png",
fileName: "screenshot.png",
});
const source: ImageLightboxSource = { type: "attachment", metadata };
await expectOpeningPaintsOnlyTheFittedImage(source);
await act(async () => renderLightbox(null));
await expectOpeningPaintsOnlyTheFittedImage(source);
});
it("shows an image the browser reports no natural size for", async () => {
const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="0" height="0"></svg>';
const uri = URL.createObjectURL(new Blob([svg], { type: "image/svg+xml" }));
await expectOpeningPaintsOnlyTheFittedImage({ type: "uri", uri });
});
it("paints the fitted timeline image as its first image frame", async () => {
const uri = URL.createObjectURL(await createPng(2400, 1600));
await expectOpeningPaintsOnlyTheFittedImage({ type: "uri", uri });
});
});
@@ -4,6 +4,7 @@ import { createRoot, type Root } from "react-dom/client";
import { JSDOM } from "jsdom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { AttachmentMetadata } from "@/attachments/types";
import type { AttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
import { AttachmentLightbox, type ImageLightboxSource } from "./attachment-lightbox";
const { theme, imageMetadata, useAttachmentPreviewUrlMock } = vi.hoisted(() => {
@@ -38,9 +39,9 @@ const { theme, imageMetadata, useAttachmentPreviewUrlMock } = vi.hoisted(() => {
return {
theme: hoistedTheme,
imageMetadata: hoistedImageMetadata,
useAttachmentPreviewUrlMock: vi.fn<(metadata: AttachmentMetadata | null) => string | null>(
() => "blob:preview",
),
useAttachmentPreviewUrlMock: vi.fn<
(metadata: AttachmentMetadata | null) => AttachmentPreviewUrl
>(() => ({ status: "ready", url: "blob:preview" })),
};
});
const attachmentSource: ImageLightboxSource = { type: "attachment", metadata: imageMetadata };
@@ -73,10 +74,13 @@ vi.mock("react-i18next", () => ({
}));
vi.mock("react-native-safe-area-context", () => ({
SafeAreaProvider: ({ children }: { children?: React.ReactNode }) => children,
useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
}));
vi.mock("@/window-layout", () => ({
WindowSafeArea: ({ children }: { children?: React.ReactNode }) => children,
}));
vi.mock("react-native-gesture-handler", () => ({
GestureHandlerRootView: ({ children }: { children?: React.ReactNode }) =>
React.createElement("div", { "data-testid": "gesture-handler-root" }, children),
@@ -151,7 +155,7 @@ beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
useAttachmentPreviewUrlMock.mockReturnValue("blob:preview");
useAttachmentPreviewUrlMock.mockReturnValue({ status: "ready", url: "blob:preview" });
});
afterEach(() => {
@@ -229,14 +233,22 @@ describe("AttachmentLightbox", () => {
expect(onClose).toHaveBeenCalledTimes(1);
});
it("shows error text when the preview URL resolves to null", () => {
useAttachmentPreviewUrlMock.mockReturnValue(null);
it("shows error text when the preview URL fails to resolve", () => {
useAttachmentPreviewUrlMock.mockReturnValue({ status: "failed" });
render(<AttachmentLightbox source={attachmentSource} onClose={vi.fn()} />);
expect(queryByTestId("attachment-lightbox-image")).toBeNull();
expect(document.body.textContent ?? "").toContain("Couldn't load image");
});
it("shows neither the image nor an error while the preview URL resolves", () => {
useAttachmentPreviewUrlMock.mockReturnValue({ status: "loading" });
render(<AttachmentLightbox source={attachmentSource} onClose={vi.fn()} />);
expect(queryByTestId("attachment-lightbox-image")).toBeNull();
expect(document.body.textContent ?? "").not.toContain("Couldn't load image");
});
it("closes on Escape key on web", () => {
const onClose = vi.fn();
render(<AttachmentLightbox source={attachmentSource} onClose={onClose} />);
@@ -4,17 +4,21 @@ import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react
import { Pressable, Text, View } from "react-native";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { StyleSheet } from "react-native-unistyles";
import { SafeAreaProvider, useSafeAreaInsets } from "react-native-safe-area-context";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { X } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import type { AttachmentMetadata } from "@/attachments/types";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
import {
type AttachmentPreviewUrl,
useAttachmentPreviewUrl,
} from "@/attachments/use-attachment-preview-url";
import { isNative } from "@/constants/platform";
import { SPACING } from "@/styles/theme";
import { WindowChromeRootRegion } from "@/utils/desktop-window";
import { ZoomableImage } from "@/components/zoomable-viewport/image";
import type { ViewportSize } from "@/components/zoomable-viewport/geometry";
import { useGlobalOverlayLayer } from "@/lib/overlay-root";
import { WindowSafeArea } from "@/window-layout";
export type ImageLightboxSource =
| { type: "attachment"; metadata: AttachmentMetadata }
@@ -31,10 +35,13 @@ const LIGHTBOX_FIT = { padding: SPACING[4], maxWidth: 960, maxHeight: 640 };
export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps) {
const { t } = useTranslation();
const metadata = source?.type === "attachment" ? source.metadata : null;
const attachmentUrl = useAttachmentPreviewUrl(metadata);
const url = source?.type === "uri" ? source.uri : attachmentUrl;
const attachmentPreview = useAttachmentPreviewUrl(metadata);
const image: AttachmentPreviewUrl =
source?.type === "uri" ? { status: "ready", url: source.uri } : attachmentPreview;
const url = image.status === "ready" ? image.url : null;
const contentSize = source?.type === "uri" ? source.contentSize : undefined;
const [errored, setErrored] = useState(false);
const failed = errored || image.status === "failed";
const modalLayer = useGlobalOverlayLayer("modal", source !== null);
useEffect(() => {
@@ -76,14 +83,12 @@ export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps)
return null;
}
const hasError = errored || !url;
return (
<Modal transparent animationType="fade" statusBarTranslucent visible onRequestClose={onClose}>
<ModalRoot style={styles.root}>
{/* The modal is its own window: its insets are the window's, not the ones left where the
image was opened (inside a sheet tab, the bottom one is already spent). */}
<SafeAreaProvider>
{/* The modal draws over the whole window: its insets are the window's, not the ones left
where the image was opened (inside a sheet tab, the bottom one is already spent). */}
<WindowSafeArea>
<WindowChromeRootRegion corners="both">
<View ref={setOverlayScope} style={styles.root}>
<Pressable
@@ -94,9 +99,10 @@ export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps)
style={styles.backdrop}
/>
<LightboxImageArea>
{hasError ? (
{failed ? (
<Text style={styles.errorText}>{t("message.attachments.imageLoadFailed")}</Text>
) : (
) : null}
{!failed && url ? (
<ZoomableImage
accessibilityLabel={t("composer.attachments.openImage")}
actions={actions}
@@ -108,11 +114,11 @@ export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps)
testID="attachment-lightbox"
uri={url}
/>
)}
) : null}
</LightboxImageArea>
</View>
</WindowChromeRootRegion>
</SafeAreaProvider>
</WindowSafeArea>
</ModalRoot>
</Modal>
);
@@ -133,9 +133,10 @@ export function AttachmentLabel({ icon, title, subtitle }: AttachmentLabelProps)
/** Square image preview pill body. */
export function AttachmentThumbnail({ metadata }: { metadata: AttachmentMetadata }) {
const uri = useAttachmentPreviewUrl(metadata);
const source = useMemo(() => ({ uri: uri ?? "" }), [uri]);
if (!uri) {
const preview = useAttachmentPreviewUrl(metadata);
const uri = preview.status === "ready" ? preview.url : null;
const source = useMemo(() => (uri ? { uri } : null), [uri]);
if (!source) {
return <View style={styles.thumbnailPlaceholder} />;
}
return <Image source={source} style={styles.thumbnail} />;
@@ -11,6 +11,10 @@ export type { DraggableListProps, DraggableRenderItemInfo };
const SCROLL_ENABLED_FLEX_STYLE = { flex: 1 };
// A drop remounts every row between the drag's start and end slots (see the draggable-flatlist
// patch), so state held inside a rendered row resets when a drop moves it. Keep state that must
// survive a reorder in the list's owner, keyed by row.
export function DraggableList<T>({
data,
keyExtractor,
@@ -117,6 +121,10 @@ export function DraggableList<T>({
ListEmptyComponent={ListEmptyComponent}
showsVerticalScrollIndicator={showsVerticalScrollIndicator}
scrollEnabled={scrollEnabled}
// A list that doesn't scroll shows every row, so it renders them all in its first commit.
// FlatList's default first render is 10 rows; a nested list remounted by a drop would paint
// 10 rows, then grow on a later commit.
initialNumToRender={scrollEnabled ? undefined : data.length}
extraData={extraData}
simultaneousHandlers={simultaneousHandlers}
dragGestureHostPresented={gestureHostPresented}
+13 -11
View File
@@ -1,6 +1,5 @@
import { SidebarViewSlot } from "@/plugins/sidebar-view";
import { useAddHost } from "@/hosts/add-host";
import { router } from "expo-router";
import {
CircleGauge,
FolderPlus,
@@ -85,8 +84,9 @@ import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop
import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
import { useLeftPanelWidth } from "@/mobile-panels/provider";
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
import { buildSettingsRoute } from "@/utils/host-routes";
import { openHostOverview } from "@/navigation/settings-navigation";
import { buildSettingsHostSectionRoute } from "@/utils/host-routes";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { openSettingsFromSidebar } from "@/navigation/settings-navigation";
import { UsageSidebarItem, UsageSidebarRoot, useUsageSummary, useOpenSidebarUsage } from "@/usage";
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
@@ -157,6 +157,7 @@ export const LeftSidebar = memo(function LeftSidebar({
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const isCompactLayout = useIsCompactFormFactor();
const activeWorkspace = useActiveWorkspaceSelection();
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
const {
@@ -193,25 +194,26 @@ export const LeftSidebar = memo(function LeftSidebar({
const openProjectPicker = useOpenAddProject();
const { open: openImportSession, sheet: importSessionSheet } = useImportSession();
// Each destination closes the navigation overlay first. Closing is idempotent, so a docked
// sidebar runs the same handler.
// Workspace destinations close the overlay; Settings retains it for the return journey.
const handleOpenProject = useCallback(() => {
void openProjectPicker();
}, [openProjectPicker]);
const handleSettings = useCallback(() => {
showMobileAgent();
router.push(buildSettingsRoute());
}, [showMobileAgent]);
openSettingsFromSidebar({ workspace: activeWorkspace, isCompact: isCompactLayout });
}, [activeWorkspace, isCompactLayout]);
const handleAddHost = useAddHost();
const handleOpenHostSettings = useCallback(
(serverId: string) => {
showMobileAgent();
openHostOverview(serverId);
openSettingsFromSidebar({
workspace: activeWorkspace,
isCompact: isCompactLayout,
route: buildSettingsHostSectionRoute(serverId, "host"),
});
},
[showMobileAgent],
[activeWorkspace, isCompactLayout],
);
const labels = useMemo(
@@ -1,5 +1,6 @@
import { AppearanceText as Text } from "@/appearance/text";
import { useMemo, type ReactNode } from "react";
import { Platform, ScrollView, Text, View } from "react-native";
import { Platform, ScrollView, View } from "react-native";
import { KeyboardAwareScrollView } from "react-native-keyboard-controller";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet } from "react-native-unistyles";
@@ -1,5 +1,6 @@
import { AppearanceText as Text } from "@/appearance/text";
import { useMemo, type ReactNode } from "react";
import { Text, View, type StyleProp, type ViewStyle } from "react-native";
import { View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { ExternalLink, type ExternalLinkProps } from "@/components/ui/external-link";
import { SettingsInfoTip } from "./settings-info-tip";
@@ -54,11 +54,7 @@ export function SettingsRowHeader({
}: SettingsRowHeaderProps) {
const layout = useSettingsRowLayout();
const rowStyle = useCallback(
({ hovered, pressed }: HoverState) => [
layout.row,
!disabled && hovered && styles.hovered,
!disabled && pressed && styles.pressed,
],
({ hovered }: HoverState) => [layout.row, !disabled && hovered && styles.hovered],
[disabled, layout],
);
const chevronStyle = useMemo(
@@ -462,7 +462,9 @@ describe("sidebar group visibility", () => {
it.each([0, 7, 8])("limits %i Done rows to seven", (count) => {
const items = entries(Array(count).fill("done"));
const { result } = renderHook(() => useLimitedSidebarGroup({ items, selection: null, policy }));
const { result } = renderHook(() =>
useLimitedSidebarGroup({ items, selection: null, expanded: false, policy }),
);
expect(result.current.visibleItems).toEqual(items.slice(0, 7));
expect(result.current.canToggle).toBe(count > 7);
});
@@ -472,7 +474,7 @@ describe("sidebar group visibility", () => {
(status) => {
const items = entries([...Array(11).fill("done"), status]);
const { result } = renderHook(() =>
useLimitedSidebarGroup({ items, selection: null, policy }),
useLimitedSidebarGroup({ items, selection: null, expanded: false, policy }),
);
expect(names(result.current.visibleItems)).toEqual([
"row-1",
@@ -497,6 +499,7 @@ describe("sidebar group visibility", () => {
useLimitedSidebarGroup({
items,
selection: null,
expanded: false,
policy: createWorkspaceVisibilityPolicy(entriesByKey),
}),
);
@@ -518,7 +521,9 @@ describe("sidebar group visibility", () => {
"needs_input",
"failed",
]);
const { result } = renderHook(() => useLimitedSidebarGroup({ items, selection: null, policy }));
const { result } = renderHook(() =>
useLimitedSidebarGroup({ items, selection: null, expanded: false, policy }),
);
expect(result.current.visibleItems).toEqual(items);
expect(result.current.canToggle).toBe(false);
});
@@ -532,9 +537,10 @@ describe("sidebar group visibility", () => {
"done",
"attention",
]);
const { result, rerender, unmount } = renderHook(
({ rows }) => useLimitedSidebarGroup({ items: rows, selection: null, policy }),
{ initialProps: { rows: items } },
const { result, rerender } = renderHook(
({ rows, expanded }) =>
useLimitedSidebarGroup({ items: rows, selection: null, expanded, policy }),
{ initialProps: { rows: items, expanded: false } },
);
expect(names(result.current.visibleItems)).toEqual([
"row-1",
@@ -548,16 +554,15 @@ describe("sidebar group visibility", () => {
"row-10",
"row-12",
]);
act(() => result.current.toggleExpanded());
rerender({ rows: items, expanded: true });
expect(result.current.visibleItems).toEqual(items);
rerender({ rows: items.map((item) => ({ ...item, statusBucket: "running" })) });
rerender({ rows: items.map((item) => ({ ...item, statusBucket: "running" })), expanded: true });
expect(result.current.canToggle).toBe(false);
expect(result.current.expanded).toBe(true);
rerender({ rows: items.slice(0, 3) });
rerender({ rows: items.slice(0, 3), expanded: true });
expect(result.current.canToggle).toBe(false);
rerender({ rows: items.toReversed() });
rerender({ rows: items.toReversed(), expanded: true });
expect(result.current.visibleItems).toEqual(items.toReversed());
act(() => result.current.toggleExpanded());
rerender({ rows: items.toReversed(), expanded: false });
expect(names(result.current.visibleItems)).toEqual([
"row-12",
"row-11",
@@ -568,15 +573,25 @@ describe("sidebar group visibility", () => {
"row-6",
]);
const newest = { ...items[0], workspaceId: "new", workspaceKey: "srv:new" };
rerender({ rows: [newest, ...items] });
rerender({ rows: [newest, ...items], expanded: false });
expect(result.current.visibleItems[0]).toBe(newest);
expect(result.current.visibleItems).toHaveLength(10);
expect(result.current.expanded).toBe(false);
act(() => result.current.toggleExpanded());
unmount();
const remounted = renderHook(() => useLimitedSidebarGroup({ items, selection: null, policy }));
expect(remounted.result.current.expanded).toBe(false);
expect(remounted.result.current.visibleItems).toHaveLength(10);
});
it("shows the reveal its owner passes in on every mount", () => {
// A native drop remounts every project block it moves, so the group holds no reveal of its
// own. Only the native list remounts rows; the browser list never does, so no test here can
// drive that remount.
const items = entries([...Array(7).fill("done"), "running", "done", "failed", "done"]);
const first = renderHook(() =>
useLimitedSidebarGroup({ items, selection: null, expanded: true, policy }),
);
expect(first.result.current.visibleItems).toEqual(items);
first.unmount();
const remounted = renderHook(() =>
useLimitedSidebarGroup({ items, selection: null, expanded: true, policy }),
);
expect(remounted.result.current.visibleItems).toEqual(items);
});
it("follows effective pending creation, recovery and agent activity without changing Created order", () => {
@@ -602,6 +617,7 @@ describe("sidebar group visibility", () => {
useLimitedSidebarGroup({
items: [...items.slice(0, 11), last],
selection: null,
expanded: false,
policy,
}),
{ initialProps: { last: items[11] } },
@@ -641,12 +657,15 @@ describe("sidebar group visibility", () => {
it("preserves the twenty-row pinned policy regardless of status", () => {
const items = entries(Array(21).fill("running"));
const { result } = renderHook(() => useLimitedSidebarGroup({ items, selection: null }));
const { result, rerender } = renderHook(
({ expanded }) => useLimitedSidebarGroup({ items, selection: null, expanded }),
{ initialProps: { expanded: false } },
);
expect(result.current.visibleItems).toEqual(items.slice(0, 20));
expect(result.current.canToggle).toBe(true);
act(() => result.current.toggleExpanded());
rerender({ expanded: true });
expect(result.current.visibleItems).toEqual(items);
act(() => result.current.toggleExpanded());
rerender({ expanded: false });
expect(result.current.visibleItems).toHaveLength(20);
});
});
@@ -1,8 +1,8 @@
import { PressHighlight as Pressable } from "@/components/ui/press-highlight";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import {
View,
Text,
Pressable,
ScrollView,
type GestureResponderEvent,
type PressableStateCallbackType,
@@ -87,7 +87,6 @@ import {
import { ProjectLeadingVisual } from "@/components/sidebar/project-leading-visual";
import { useToast } from "@/contexts/toast-context";
import { getForgePresentation, normalizeForge } from "@/git/forge";
import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning";
import { hasVisibleOrderChanged, mergeWithRemainder } from "@/utils/sidebar-reorder";
import { confirmDialog } from "@/utils/confirm-dialog";
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
@@ -368,12 +367,10 @@ function projectKebabStyle({
function getProjectWorkspaceRowStyle({
isDragging,
isPressed,
selected,
isHovered,
}: {
isDragging: boolean;
isPressed: boolean;
selected: boolean;
isHovered: boolean;
}) {
@@ -382,7 +379,6 @@ function getProjectWorkspaceRowStyle({
isHovered && styles.workspaceRowHovered,
selected && styles.sidebarRowSelected,
isDragging && styles.workspaceRowDragging,
isPressed && styles.workspaceRowPressed,
];
}
@@ -674,7 +670,11 @@ function WorkspaceRowRightGroup({
{renderSlot ? (
<SidebarWorkspaceTrailingActionSlot reserveWidth={reserveSlotWidth}>
<SidebarWorkspaceTrailingActionBase presentation={trailingPresentation}>
<SidebarWorkspaceTrailingContent workspace={workspace} trailing={trailing} />
<SidebarWorkspaceTrailingContent
workspace={workspace}
trailing={trailing}
backdrop={backdrop}
/>
</SidebarWorkspaceTrailingActionBase>
<SidebarWorkspaceTrailingActionOverlay
visible={kebab.showKebab}
@@ -816,7 +816,6 @@ function NewWorkspaceGhostRow({
({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.newWorkspaceGhostRow,
hovered && !pressed && styles.newWorkspaceGhostRowHovered,
pressed && styles.newWorkspaceGhostRowPressed,
],
[],
);
@@ -829,6 +828,7 @@ function NewWorkspaceGhostRow({
})}
onPress={handlePress}
style={rowStyle}
highlightStyle={styles.newWorkspaceGhostRowPressed}
testID={`sidebar-project-new-workspace-row-${project.viewKey}`}
>
{({ hovered, pressed }) => (
@@ -879,7 +879,7 @@ function ProjectHeaderRow({
dragHandleProps,
}: ProjectHeaderRowProps) {
const [isHovered, setIsHovered] = useState(false);
const [isPressed, setIsPressed] = useState(false);
const [isHighlighted, setIsHighlighted] = useState(false);
const [contextMenuOpen, setContextMenuOpen] = useState(false);
const isMobileBreakpoint = useIsCompactFormFactor();
const localDaemonServerId = useLocalDaemonServerId();
@@ -926,25 +926,13 @@ function ProjectHeaderRow({
setContextMenuOpen(open);
if (open) setIsHovered(false);
}, []);
const handleProjectPressIn = useCallback(
(event: GestureResponderEvent) => {
setIsPressed(true);
interaction.handlePressIn(event);
},
[interaction],
);
const handleProjectPressOut = useCallback(() => {
setIsPressed(false);
interaction.handlePressOut();
}, [interaction]);
const projectRowStyle = useCallback(
({ pressed }: PressableStateCallbackType) => [
const projectRowStyle = useMemo(
() => [
styles.projectRow,
isDragging && styles.projectRowDragging,
selected && styles.sidebarRowSelected,
isHovered && styles.projectRowHovered,
pressed && styles.projectRowPressed,
],
[isDragging, selected, isHovered],
);
@@ -957,7 +945,7 @@ function ProjectHeaderRow({
iconDataUri={iconDataUri}
statusBucket={statusBucket}
projectViewKey={project.viewKey}
backdrop={getSidebarRowBackdrop({ isDragging, isPressed, selected, isHovered })}
backdrop={getSidebarRowBackdrop({ isDragging, isHighlighted, selected, isHovered })}
chevron={chevron}
showChevron={isHovered && chevron !== null}
isArchiving={isArchiving}
@@ -1003,9 +991,10 @@ function ProjectHeaderRow({
accessibilityRole="button"
style={projectRowStyle}
highlightStyle={styles.projectRowPressed}
onPressIn={handleProjectPressIn}
onHighlightChange={setIsHighlighted}
onPressIn={interaction.handlePressIn}
onTouchMove={interaction.handleTouchMove}
onPressOut={handleProjectPressOut}
onPressOut={interaction.handlePressOut}
onPress={handlePress}
testID={`sidebar-project-row-${project.viewKey}`}
>
@@ -1029,9 +1018,10 @@ function ProjectHeaderRow({
accessibilityRole="button"
style={projectRowStyle}
highlightStyle={styles.projectRowPressed}
onPressIn={handleProjectPressIn}
onHighlightChange={setIsHighlighted}
onPressIn={interaction.handlePressIn}
onTouchMove={interaction.handleTouchMove}
onPressOut={handleProjectPressOut}
onPressOut={interaction.handlePressOut}
onPress={handlePress}
testID={`sidebar-project-row-${project.viewKey}`}
>
@@ -1087,7 +1077,7 @@ function WorkspaceRowInner({
reserveIdleStatusIndicatorSpace = true,
}: WorkspaceRowInnerProps) {
const isCompact = useIsCompactFormFactor();
const [isPressed, setIsPressed] = useState(false);
const [isHighlighted, setIsHighlighted] = useState(false);
const isTouchPlatform = platformIsNative || isCompact;
const interaction = useLongPressDragInteraction({
drag,
@@ -1107,17 +1097,6 @@ function WorkspaceRowInner({
}
onPress();
}, [interaction.didLongPressRef, onPress]);
const handleWorkspacePressIn = useCallback(
(event: GestureResponderEvent) => {
setIsPressed(true);
interaction.handlePressIn(event);
},
[interaction],
);
const handleWorkspacePressOut = useCallback(() => {
setIsPressed(false);
interaction.handlePressOut();
}, [interaction]);
const accessibilityState = useMemo(() => ({ selected }), [selected]);
const isEditingTitle = titleEditor !== null;
@@ -1131,13 +1110,8 @@ function WorkspaceRowInner({
{({ isHovered, contextMenuOpen, onContextMenuOpenChange, hoverHandlers }) => {
const isDesktop = !isTouchPlatform;
const serviceSummary = isDesktop ? selectWorkspaceServiceSummary(workspace.scripts) : null;
const workspaceRowStyle = getProjectWorkspaceRowStyle({
isDragging,
isPressed,
selected,
isHovered,
});
const backdrop = getSidebarRowBackdrop({ isDragging, isPressed, selected, isHovered });
const workspaceRowStyle = getProjectWorkspaceRowStyle({ isDragging, selected, isHovered });
const backdrop = getSidebarRowBackdrop({ isDragging, isHighlighted, selected, isHovered });
return (
<View
{...dragAttributes}
@@ -1172,10 +1146,11 @@ function WorkspaceRowInner({
accessibilityRole="button"
accessibilityState={accessibilityState}
style={workspaceRowStyle}
highlightStyle={styles.workspaceRowPressed}
onPressIn={isEditingTitle ? undefined : handleWorkspacePressIn}
highlightStyle={isEditingTitle ? undefined : styles.workspaceRowPressed}
onHighlightChange={setIsHighlighted}
onPressIn={isEditingTitle ? undefined : interaction.handlePressIn}
onTouchMove={isEditingTitle ? undefined : interaction.handleTouchMove}
onPressOut={isEditingTitle ? undefined : handleWorkspacePressOut}
onPressOut={isEditingTitle ? undefined : interaction.handlePressOut}
onPress={isEditingTitle ? undefined : handlePress}
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
>
@@ -1276,8 +1251,9 @@ function WorkspaceRowWithMenu({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
workspaceKind: workspace.workspaceKind,
workspaceDirectory: workspace.workspaceDirectory,
name: workspace.name,
...toWorktreeArchiveRisk(workspace),
diffStat: workspace.diffStat,
onArchiveStarted: redirectAfterArchive,
onSetHiding: setIsHidingWorkspace,
});
@@ -1553,6 +1529,8 @@ function ProjectBlock({
showShortcutBadges,
shortcutIndexByWorkspaceKey,
onToggleCollapsed,
workspacesExpanded,
onToggleWorkspacesExpanded,
onWorkspacePress,
parentGestureRef,
onWorkspaceReorder,
@@ -1579,6 +1557,9 @@ function ProjectBlock({
showShortcutBadges: boolean;
shortcutIndexByWorkspaceKey: Map<string, number>;
onToggleCollapsed: (projectViewKey: string) => void;
/** "Show more" on this project's workspaces, owned by the list so a drop's remount keeps it. */
workspacesExpanded: boolean;
onToggleWorkspacesExpanded: (projectViewKey: string) => void;
onWorkspacePress?: () => void;
parentGestureRef?: MutableRefObject<GestureType | undefined>;
onWorkspaceReorder: (projectViewKey: string, workspaces: SidebarWorkspacePlacement[]) => void;
@@ -1603,12 +1584,11 @@ function ProjectBlock({
const {
leadingItem: leadingWorkspace,
visibleItems: visibleWorkspaces,
expanded: workspacesExpanded,
canToggle: canToggleWorkspaces,
toggleExpanded: toggleWorkspacesExpanded,
} = useLimitedSidebarGroup({
items: project.workspaces,
selection: selectionEnabled ? activeWorkspaceSelection : null,
expanded: workspacesExpanded,
policy: visibilityPolicy,
});
const activeServerId = activeWorkspaceSelection?.serverId ?? null;
@@ -1766,6 +1746,10 @@ function ProjectBlock({
onToggleCollapsed(project.viewKey);
}, [onToggleCollapsed, project.viewKey]);
const handleToggleWorkspacesExpanded = useCallback(() => {
onToggleWorkspacesExpanded(project.viewKey);
}, [onToggleWorkspacesExpanded, project.viewKey]);
let projectChildren = null;
if (!collapsed) {
if (project.workspaces.length > 0) {
@@ -1802,7 +1786,7 @@ function ProjectBlock({
{canToggleWorkspaces ? (
<SidebarGroupToggleRow
expanded={workspacesExpanded}
onPress={toggleWorkspacesExpanded}
onPress={handleToggleWorkspacesExpanded}
testID={`sidebar-project-show-more-${project.viewKey}`}
/>
) : null}
@@ -1872,6 +1856,8 @@ function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlo
previous.supportsPinningByServerId === next.supportsPinningByServerId &&
previous.onToggleWorkspacePin === next.onToggleWorkspacePin &&
previous.onToggleCollapsed === next.onToggleCollapsed &&
previous.workspacesExpanded === next.workspacesExpanded &&
previous.onToggleWorkspacesExpanded === next.onToggleWorkspacesExpanded &&
previous.onWorkspacePress === next.onWorkspacePress &&
previous.parentGestureRef === next.parentGestureRef &&
previous.onWorkspaceReorder === next.onWorkspaceReorder &&
@@ -2200,15 +2186,29 @@ function ProjectModeList({
const selectionEnabled = isWorkspaceRoute;
const activeWorkspaceSelection = useActiveWorkspaceSelection();
const { pinnedChats, unpinnedProjects } = pinnedGroups;
const [pinnedChatsExpanded, setPinnedChatsExpanded] = useState(false);
const togglePinnedChatsExpanded = useCallback(
() => setPinnedChatsExpanded((current) => !current),
[],
);
const [expandedProjectKeys, setExpandedProjectKeys] = useState<ReadonlySet<string>>(
() => new Set(),
);
const toggleProjectWorkspacesExpanded = useCallback((projectViewKey: string) => {
setExpandedProjectKeys((current) => {
const next = new Set(current);
if (!next.delete(projectViewKey)) next.add(projectViewKey);
return next;
});
}, []);
const {
leadingItem: leadingPinnedChat,
visibleItems: visiblePinnedChats,
expanded: pinnedChatsExpanded,
canToggle: canTogglePinnedChats,
toggleExpanded: togglePinnedChatsExpanded,
} = useLimitedSidebarGroup({
items: pinnedChats,
selection: selectionEnabled ? activeWorkspaceSelection : null,
expanded: pinnedChatsExpanded,
});
const nativeScrollGestureProps = useMemo(
() =>
@@ -2364,6 +2364,8 @@ function ProjectModeList({
showShortcutBadges={showShortcutBadges}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
onToggleCollapsed={onToggleProjectCollapsed}
workspacesExpanded={expandedProjectKeys.has(item.viewKey)}
onToggleWorkspacesExpanded={toggleProjectWorkspacesExpanded}
onWorkspacePress={onWorkspacePress}
manualSort={manualSort}
parentGestureRef={parentGestureRef}
@@ -2385,6 +2387,8 @@ function ProjectModeList({
},
[
collapsedProjectKeys,
expandedProjectKeys,
toggleProjectWorkspacesExpanded,
activeWorkspaceSelection,
handleWorktreeCreated,
manualSort,
@@ -2588,6 +2592,8 @@ const styles = StyleSheet.create((theme) => ({
// Optical inset: aligns the visible Pinned/Workspaces glyph edge with the
// Schedules icon across the divider; their layout boxes have different insets.
paddingTop: 2,
// The sidebar toast floats over the list's end. Keep this padding the same whether or not
// a toast shows: changing it moves the rows when the toast leaves.
paddingBottom: theme.spacing[4],
},
projectListContainer: {
@@ -1,6 +1,7 @@
import { PressHighlight as Pressable } from "@/components/ui/press-highlight";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import { Text, View, type PressableStateCallbackType } from "react-native";
import { ChevronDown, ChevronUp } from "lucide-react-native";
import { StyleSheet } from "react-native-unistyles";
import { isWeb } from "@/constants/platform";
@@ -38,7 +39,6 @@ export function SidebarGroupToggleRow({
styles.row,
indented && sidebarWorkspaceRowStyles.rowIndented,
hovered && !pressed && styles.rowHovered,
pressed && styles.rowPressed,
],
[indented],
);
@@ -49,6 +49,7 @@ export function SidebarGroupToggleRow({
accessibilityLabel={label}
onPress={onPress}
style={rowStyle}
highlightStyle={styles.rowPressed}
testID={testID}
>
{({ hovered, pressed }) => (
@@ -1,5 +1,6 @@
import { PressHighlight as Pressable } from "@/components/ui/press-highlight";
import { useCallback, useState, type ComponentType, type ReactNode, type Ref } from "react";
import { Pressable, Text, View } from "react-native";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from "@/constants/layout";
import { DensityIcon } from "@/components/ui/density-icon";
@@ -12,16 +12,17 @@ import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
*/
export function getSidebarRowBackdrop({
isDragging = false,
isPressed = false,
isHighlighted = false,
selected = false,
isHovered = false,
}: {
isDragging?: boolean;
isPressed?: boolean;
/** The press highlight is painting the row's pressed fill. */
isHighlighted?: boolean;
selected?: boolean;
isHovered?: boolean;
}): SidebarSurfaceBackdrop {
if (isDragging || isPressed) return "surface2";
if (isDragging || isHighlighted) return "surface2";
if (selected) return "surfaceSidebarSelected";
if (isHovered) return "surfaceSidebarHover";
return "surfaceSidebar";
@@ -1,6 +1,7 @@
import { PressHighlight as Pressable } from "@/components/ui/press-highlight";
import { useMemo, type ReactNode } from "react";
import { ChevronDown, ChevronRight } from "lucide-react-native";
import { Pressable, Text, View } from "react-native";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { DensityIcon } from "@/components/ui/density-icon";
import { useIsCompactFormFactor } from "@/constants/layout";
@@ -1,3 +1,4 @@
import { PressHighlight as Pressable } from "@/components/ui/press-highlight";
import {
memo,
useCallback,
@@ -8,14 +9,7 @@ import {
type Ref,
} from "react";
import { useTranslation } from "react-i18next";
import {
View,
Text,
Pressable,
ScrollView,
type GestureResponderEvent,
type PressableStateCallbackType,
} from "react-native";
import { View, Text, ScrollView } from "react-native";
import { NestableScrollContainer } from "react-native-draggable-flatlist";
import type { GestureType } from "react-native-gesture-handler";
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
@@ -44,7 +38,6 @@ import { useSidebarWorkspaceRename } from "@/workspace-rename";
import { useWorkspaceClipboardActions } from "@/hooks/use-workspace-clipboard-actions";
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
@@ -155,13 +148,20 @@ export function SidebarStatusWorkspaceList({
const togglePinnedCollapsed = useSidebarCollapsedSectionsStore(
(state) => state.togglePinnedCollapsed,
);
const [pinnedWorkspacesExpanded, setPinnedWorkspacesExpanded] = useState(false);
const togglePinnedWorkspacesExpanded = useCallback(
() => setPinnedWorkspacesExpanded((current) => !current),
[],
);
const {
leadingItem: leadingPinnedWorkspace,
visibleItems: visiblePinnedWorkspaces,
expanded: pinnedWorkspacesExpanded,
canToggle: canTogglePinnedWorkspaces,
toggleExpanded: togglePinnedWorkspacesExpanded,
} = useLimitedSidebarGroup({ items: pinnedWorkspaces, selection: activeWorkspaceSelection });
} = useLimitedSidebarGroup({
items: pinnedWorkspaces,
selection: activeWorkspaceSelection,
expanded: pinnedWorkspacesExpanded,
});
const statusShortcutIndex = showShortcutBadges
? shortcutIndexByWorkspaceKey
@@ -366,15 +366,19 @@ function StatusGroupRows({
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
}) {
const activeWorkspaceSelection = useActiveWorkspaceSelection();
const [workspacesExpanded, setWorkspacesExpanded] = useState(false);
const toggleWorkspacesExpanded = useCallback(
() => setWorkspacesExpanded((current) => !current),
[],
);
const {
leadingItem: leadingWorkspace,
visibleItems: visibleWorkspaces,
expanded: workspacesExpanded,
canToggle: canToggleWorkspaces,
toggleExpanded: toggleWorkspacesExpanded,
} = useLimitedSidebarGroup({
items: group.rows,
selection: activeWorkspaceSelection,
expanded: workspacesExpanded,
policy: STATUS_GROUP_VISIBILITY,
});
@@ -460,12 +464,8 @@ function StatusGroupHeader({
}, [group.key, toggleWorkspaceGroupCollapsed]);
const handleHoverIn = useCallback(() => setIsHovered(true), []);
const handleHoverOut = useCallback(() => setIsHovered(false), []);
const rowStyle = useCallback(
({ pressed }: PressableStateCallbackType) => [
styles.statusGroupRow,
isHovered && styles.statusGroupRowHovered,
pressed && styles.statusGroupRowPressed,
],
const rowStyle = useMemo(
() => [styles.statusGroupRow, isHovered && styles.statusGroupRowHovered],
[isHovered],
);
const accessibilityState = useMemo(() => ({ expanded: !collapsed }), [collapsed]);
@@ -477,6 +477,7 @@ function StatusGroupHeader({
accessibilityLabel={t("sidebar.statusGroupAccessibility", { label: group.label })}
accessibilityState={accessibilityState}
style={rowStyle}
highlightStyle={styles.statusGroupRowPressed}
onPress={handlePress}
testID={`sidebar-status-group-${group.key}`}
>
@@ -654,8 +655,9 @@ function StatusWorkspaceRowWithMenu({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
workspaceKind: workspace.workspaceKind,
workspaceDirectory: workspace.workspaceDirectory,
name: workspace.name,
...toWorktreeArchiveRisk(workspace),
diffStat: workspace.diffStat,
onArchiveStarted: redirectAfterArchive,
onSetHiding: setIsHidingWorkspace,
});
@@ -826,7 +828,7 @@ function StatusWorkspaceRowInnerContent({
}) {
const isCompact = useIsCompactFormFactor();
const isTouchPlatform = platformIsNative || isCompact;
const [isPressed, setIsPressed] = useState(false);
const [isHighlighted, setIsHighlighted] = useState(false);
const trailing = useSidebarWorkspaceTrailing();
const {
role: _dragRole,
@@ -851,17 +853,6 @@ function StatusWorkspaceRowInnerContent({
}
onPress();
}, [didLongPressRef, onPress]);
const handlePressIn = useCallback(
(event: GestureResponderEvent) => {
setIsPressed(true);
startDragPress?.(event);
},
[startDragPress],
);
const handlePressOut = useCallback(() => {
setIsPressed(false);
endDragPress?.();
}, [endDragPress]);
return (
<SidebarWorkspaceRowFrame
@@ -887,13 +878,12 @@ function StatusWorkspaceRowInnerContent({
showShortcut,
});
const workspaceRowStyle = getStatusWorkspaceRowStyle({
isPressed,
selected,
isHovered,
inStatusGroup,
isDragging,
});
const backdrop = getSidebarRowBackdrop({ isDragging, isPressed, selected, isHovered });
const backdrop = getSidebarRowBackdrop({ isDragging, isHighlighted, selected, isHovered });
return (
<View
{...dragAttributes}
@@ -928,10 +918,11 @@ function StatusWorkspaceRowInnerContent({
accessibilityRole="button"
accessibilityState={accessibilityState}
style={workspaceRowStyle}
highlightStyle={styles.workspaceRowPressed}
onPressIn={isEditingTitle ? undefined : handlePressIn}
highlightStyle={isEditingTitle ? undefined : styles.workspaceRowPressed}
onHighlightChange={setIsHighlighted}
onPressIn={isEditingTitle ? undefined : startDragPress}
onTouchMove={isEditingTitle ? undefined : moveDragPress}
onPressOut={isEditingTitle ? undefined : handlePressOut}
onPressOut={isEditingTitle ? undefined : endDragPress}
onPress={isEditingTitle ? undefined : handlePress}
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
>
@@ -1029,7 +1020,11 @@ function StatusWorkspaceActionSlot({
return (
<SidebarWorkspaceTrailingActionSlot reserveWidth={reserveSlotWidth}>
<SidebarWorkspaceTrailingActionBase presentation={trailingPresentation}>
<SidebarWorkspaceTrailingContent workspace={workspace} trailing={trailing} />
<SidebarWorkspaceTrailingContent
workspace={workspace}
trailing={trailing}
backdrop={backdrop}
/>
</SidebarWorkspaceTrailingActionBase>
<SidebarWorkspaceTrailingActionOverlay
visible={kebab.showKebab}
@@ -1064,13 +1059,11 @@ function StatusWorkspaceActionSlot({
}
function getStatusWorkspaceRowStyle({
isPressed,
selected,
isHovered,
inStatusGroup,
isDragging,
}: {
isPressed: boolean;
selected: boolean;
isHovered: boolean;
inStatusGroup: boolean;
@@ -1082,7 +1075,6 @@ function getStatusWorkspaceRowStyle({
isHovered && styles.workspaceRowHovered,
selected && styles.sidebarRowSelected,
isDragging && styles.workspaceRowDragging,
isPressed && styles.workspaceRowPressed,
];
}
@@ -1097,6 +1089,8 @@ const styles = StyleSheet.create((theme) => ({
paddingHorizontal: theme.spacing[2],
// Keep status mode's Pinned/Workspaces boundary identical to project mode.
paddingTop: 2,
// The sidebar toast floats over the list's end. Keep this padding the same whether or not
// a toast shows: changing it moves the rows when the toast leaves.
paddingBottom: theme.spacing[4],
},
pinnedSection: {
@@ -180,6 +180,7 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
<View style={sidebarWorkspaceRowStyles.rowRight}>{children}</View>
</View>
<WorkspaceMetaRow
backdrop={backdrop}
currentBranch={workspace.currentBranch}
projectName={leadingProjectName}
hostBadge={hostBadge ?? null}
@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from "react";
import { useMemo } from "react";
import {
selectSidebarGroupVisibility,
type SidebarGroupItem,
@@ -12,23 +12,22 @@ const PINNED_POLICY = { kind: "pinned" } as const;
interface LimitedSidebarGroupInput<T> {
items: readonly T[];
selection: ActiveWorkspaceSelection | null;
/**
* Whether the user revealed the whole group. The caller owns it: a native drop remounts the
* rows it moved, and a reveal held inside a row would reset with them.
*/
expanded: boolean;
policy?: SidebarGroupVisibilityPolicy<T>;
}
export function useLimitedSidebarGroup<T extends SidebarGroupItem>({
items,
selection,
expanded,
policy = PINNED_POLICY,
}: LimitedSidebarGroupInput<T>): SidebarGroupVisibility<T> & {
expanded: boolean;
toggleExpanded: () => void;
} {
const [expanded, setExpanded] = useState(false);
const visibility = useMemo(
}: LimitedSidebarGroupInput<T>): SidebarGroupVisibility<T> {
return useMemo(
() => selectSidebarGroupVisibility({ items, expanded, policy, selection }),
[expanded, items, policy, selection],
);
const toggleExpanded = useCallback(() => setExpanded((current) => !current), []);
return { ...visibility, expanded, toggleExpanded };
}
@@ -1,6 +1,7 @@
import { PressHighlight as Pressable } from "@/components/ui/press-highlight";
import { Fragment, useCallback, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Pressable, Text, View, type GestureResponderEvent } from "react-native";
import { Text, View, type GestureResponderEvent } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { ExternalLink, Folder, GitBranch, Globe } from "lucide-react-native";
import {
@@ -15,6 +16,7 @@ import { getForgePresentation, normalizeForge } from "@/git/forge";
import { openExternalUrl } from "@/utils/open-external-url";
import { useSidebarMetaPreferences } from "@/components/sidebar/display-preferences/model";
import type { Theme } from "@/styles/theme";
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
import { PullRequestStateIcon } from "@/git/pull-request-state-icon";
import {
PluginWorkspaceRowBadgeItems,
@@ -75,6 +77,7 @@ export function WorkspaceMetaRow({
serviceSummary,
labels = EMPTY_LABELS,
workspace = null,
backdrop,
}: {
currentBranch: string | null;
projectName: string | null;
@@ -84,6 +87,7 @@ export function WorkspaceMetaRow({
labels?: readonly WorkspaceLabelDefinition[];
/** The real workspace behind the row; previews leave it out and get no plugin items. */
workspace?: { serverId: string; workspaceId: string } | null;
backdrop: SidebarSurfaceBackdrop;
}) {
const { rowItems, checksDisplay } = useSidebarMetaPreferences();
const items = selectMetaRowItems({
@@ -104,7 +108,17 @@ export function WorkspaceMetaRow({
<View style={styles.row}>
{items.map((item, index) => (
<Fragment key={item.kind}>
{index > 0 ? <Text style={styles.separator}>·</Text> : null}
{index > 0 ? (
<Text
style={[
styles.separator,
backdrop === "surfaceSidebarHover" && styles.separatorHovered,
backdrop === "surfaceSidebarSelected" && styles.separatorSelected,
]}
>
·
</Text>
) : null}
<MetaItemNode item={item} hostBadge={hostBadge} leading={index === 0} />
</Fragment>
))}
@@ -362,6 +376,8 @@ const styles = StyleSheet.create((theme) => ({
lineHeight: 16,
flexShrink: 0,
},
separatorHovered: { color: theme.colors.foregroundSidebarHover },
separatorSelected: { color: theme.colors.foregroundSidebarSelected },
// Tighter than the line's own gap so a run of chips reads as one item — see `LabelsItem`.
// Shrinks at the same weight as the service name: both are arbitrary text somebody chose, so
// a crowded line takes from the two of them in proportion rather than emptying one of them.
@@ -5,6 +5,7 @@ import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list"
import { useAppSettings } from "@/hooks/use-settings";
import type { SidebarWorkspaceTrailing } from "@/hooks/use-settings";
import { useCompactTimeAgo } from "@/hooks/use-time-ago";
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
export type { SidebarWorkspaceTrailing };
@@ -39,9 +40,11 @@ export function hasSidebarWorkspaceTrailing({
export function SidebarWorkspaceTrailingContent({
workspace,
trailing,
backdrop,
}: {
workspace: SidebarWorkspaceEntry;
trailing: SidebarWorkspaceTrailing;
backdrop: SidebarSurfaceBackdrop;
}) {
if (trailing === "diff" && workspace.diffStat) {
return (
@@ -49,7 +52,7 @@ export function SidebarWorkspaceTrailingContent({
);
}
if (trailing === "timestamp" && workspace.statusEnteredAt) {
return <WorkspaceTimestamp enteredAt={workspace.statusEnteredAt} />;
return <WorkspaceTimestamp enteredAt={workspace.statusEnteredAt} backdrop={backdrop} />;
}
return null;
}
@@ -59,10 +62,24 @@ export function SidebarWorkspaceTrailingContent({
* re-renders the component that owns it — keeping that component down to a single `<Text>` is
* what stops a minute tick from reaching the row, the list, or the diff stat next door.
*/
function WorkspaceTimestamp({ enteredAt }: { enteredAt: Date }) {
function WorkspaceTimestamp({
enteredAt,
backdrop,
}: {
enteredAt: Date;
backdrop: SidebarSurfaceBackdrop;
}) {
const label = useCompactTimeAgo(enteredAt);
return (
<Text style={styles.timestamp} numberOfLines={1} testID="sidebar-workspace-timestamp">
<Text
style={[
styles.timestamp,
backdrop === "surfaceSidebarHover" && styles.hovered,
backdrop === "surfaceSidebarSelected" && styles.selected,
]}
numberOfLines={1}
testID="sidebar-workspace-timestamp"
>
{label}
</Text>
);
@@ -80,4 +97,6 @@ const styles = StyleSheet.create((theme) => ({
fontWeight: theme.fontWeight.normal,
flexShrink: 0,
},
hovered: { color: theme.colors.foregroundSidebarHover },
selected: { color: theme.colors.foregroundSidebarSelected },
}));

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