Compare commits

...
Author SHA1 Message Date
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
76 changed files with 3344 additions and 682 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
+1 -1
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 uses React Native’s press lifecycle on native with a short press delay so scrolling cancels pending feedback. A bare `<Text>` styled to look like a section header is wrong; the section header is `<SettingsSection>` (`packages/app/src/components/settings/headings/settings-section.tsx`). A docs link in its header is its `link` prop, which renders `Label ↗` on the title's rail; a ghost button with an arrow is wrong. A custom `Modal` for a confirmation is wrong; the confirmation is `confirmDialog` (`packages/app/src/utils/confirm-dialog.ts`). A hand-rolled overflow menu is wrong; the menu is `<DropdownMenu>` (`packages/app/src/components/ui/dropdown-menu.tsx`). A hand-rolled status pill is wrong; the pill is `<StatusBadge>` (`packages/app/src/components/ui/status-badge.tsx`). A circle pinned over a scrolling surface is wrong; the floating action is `<FloatingActionButton>` (`packages/app/src/components/ui/floating-action-button.tsx`), one per surface, and the surface reserves `FLOATING_ACTION_BUTTON_CLEARANCE` below its content so the button never traps the last row.
A pressable styled to look like a button is wrong; the button is `<Button>` (`packages/app/src/components/ui/button.tsx`). A pressable wrapping an icon is wrong; the icon action is `<ToolbarButton>` (`packages/app/src/components/ui/pane-content-toolbar.tsx`), which owns the hitbox, the rounded `interactionHighlight` fill on hover and press, the compact size, and the tooltip. Rows on a menu surface are `<MenuItem>`, or `<ComposerTrackRow>` in a track panel; other rows are `<PressHighlight>` (`packages/app/src/components/ui/press-highlight.tsx`), which owns the pressed feedback: pass the pressed fill as `highlightStyle` and never paint it in your own style. iOS and web paint that fill; Android draws the platform ripple over the content instead, clipped to the row's corners. On native, feedback waits for a short press delay so a scroll that claims the touch shows none. Content that knocks out of the row's surface, such as a badge ring, follows the fill through `onHighlightChange`. A bare `<Text>` styled to look like a section header is wrong; the section header is `<SettingsSection>` (`packages/app/src/components/settings/headings/settings-section.tsx`). A docs link in its header is its `link` prop, which renders `Label ↗` on the title's rail; a ghost button with an arrow is wrong. A custom `Modal` for a confirmation is wrong; the confirmation is `confirmDialog` (`packages/app/src/utils/confirm-dialog.ts`). A hand-rolled overflow menu is wrong; the menu is `<DropdownMenu>` (`packages/app/src/components/ui/dropdown-menu.tsx`). A hand-rolled status pill is wrong; the pill is `<StatusBadge>` (`packages/app/src/components/ui/status-badge.tsx`). A circle pinned over a scrolling surface is wrong; the floating action is `<FloatingActionButton>` (`packages/app/src/components/ui/floating-action-button.tsx`), one per surface, and the surface reserves `FLOATING_ACTION_BUTTON_CLEARANCE` below its content so the button never traps the last row.
Before adding a new component, read `components/ui/`. The primitive usually exists.
+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:
+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
+5 -4
View File
@@ -95,10 +95,11 @@ Provider message IDs are not guaranteed for every displayed item. Paseo-generate
Actions that address a point in chat history, such as Fork, use the daemon timeline `epoch` plus the projected item's `seqEnd`. The app carries that position on the rendered assistant item for both live and fetched history. When adjacent projected chunks merge, the merged item retains the newer chunk's position.
The daemon validates the epoch and locates the projected item at the selected position. A fork
includes projected items through that checkpoint. If an item the fork copies spans the checkpoint
and changed afterward, the daemon refuses that fork with an actionable error: discarded historical
payloads cannot be reconstructed from sequence metadata. Plugin cards are not copied, so a card
updated after the checkpoint does not block it. Forking the current context remains available.
includes projected items through that checkpoint. A tool call that started before the checkpoint
and changed after it, such as background work that outlives a reply, is copied as still running:
the projection keeps only its latest payload, so its state at the checkpoint can't be read back.
Plugin cards are not copied. A position from another epoch, or one that is gone, is refused;
forking the current context remains available.
## Resume behavior
@@ -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 ");
@@ -14,6 +14,7 @@ import { connectSeedClient } from "../support/helpers/seed-client";
import { createTempGitRepo } from "../support/helpers/workspace";
import { getWorkspaceTabTestIds } from "../support/helpers/workspace-tabs";
import {
archivedToast,
archiveFromRowButton,
createGatedTeardown,
expectDirectoryPresence,
@@ -25,6 +26,7 @@ import {
resizeSidebarToMinimum,
rowArchiveButton,
rowKebab,
scrollSidebarListToEnd,
undoButton,
workspaceRow,
workspaceTitleWidth,
@@ -119,6 +121,41 @@ test.describe("Workspace archive undo", () => {
expect(shortcutBox.x - (labelBox.x + labelBox.width)).toBeLessThanOrEqual(8.5);
});
test("a long list ends just below its last row, and the toast over it moves no row", async ({
page,
}) => {
await page.clock.install({ time: Date.now() });
const fillers: Array<{ path: string; cleanup: () => Promise<void> }> = [];
const fillerProjectIds: string[] = [];
try {
for (let index = 0; index < 10; index += 1) {
const repo = await createTempGitRepo(`archive-toast-filler-${index}-`);
fillers.push(repo);
fillerProjectIds.push((await openProjectViaDaemon(client, repo.path)).projectId);
}
const worktree = await seedWorktree(page, "archive-toast-long-list");
const resting = await scrollSidebarListToEnd(page);
expect(resting.listBottom - resting.rowBottoms.at(-1)!).toBeLessThan(resting.rowHeight);
await archiveFromRowButton(page, worktree.workspaceId);
await page.mouse.move(0, 0);
const withToast = await scrollSidebarListToEnd(page);
const toastBox = await archivedToast(page).boundingBox();
if (!toastBox) throw new Error("Toast is not laid out");
expect(toastBox.y + toastBox.height).toBeLessThanOrEqual(withToast.listBottom);
await outlastUndoWindow(page);
await expect(archivedToast(page)).toHaveCount(0);
expect(await scrollSidebarListToEnd(page)).toEqual(withToast);
} finally {
for (const projectId of fillerProjectIds) {
await client.removeProject(projectId).catch(() => undefined);
}
for (const repo of fillers) await repo.cleanup().catch(() => undefined);
}
});
test("an archive that fails after the sidebar was replaced still shows its error", async ({
page,
}) => {
@@ -5,27 +5,34 @@ import path from "node:path";
import type { Dialog, Page } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import {
archiveWorkspaceFromDaemon,
connectNewWorkspaceDaemonClient,
createWorktreeViaDaemon,
openProjectViaDaemon,
} from "../support/helpers/new-workspace";
import { expectAppRoute } from "../support/helpers/route-assertions";
import { getServerId } from "../support/helpers/server-id";
import {
clickArchiveWorkspaceMenuItem,
expectWorkspaceAbsentFromSidebar,
} from "../support/helpers/sidebar";
import { createTempGitRepo } from "../support/helpers/workspace";
import {
rowArchiveButton,
undoButton,
workspaceRow,
} from "../support/helpers/workspace-archive-undo";
import {
waitForSidebarHydration,
waitForWorkspaceInSidebar,
} from "../support/helpers/workspace-ui";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
async function seedRiskyWorktree(
client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>,
worktreeDirectory: string,
): Promise<void> {
type DaemonClient = Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
async function seedRiskyWorktree(client: DaemonClient, worktreeDirectory: string): Promise<void> {
// The daemon only reports unpushed commits when the branch has a configured
// upstream (aheadOfOrigin is computed against `branch.<name>.merge`). Push the
// worktree branch at its current head first so it tracks origin with 0 ahead,
@@ -55,35 +62,189 @@ async function seedRiskyWorktree(
const dirtyFile = path.join(worktreeDirectory, "DIRTY.md");
await writeFile(dirtyFile, "# dirty\n");
const refreshed = await client.checkoutRefresh(worktreeDirectory);
await refreshCheckout(client, worktreeDirectory);
}
async function refreshCheckout(client: DaemonClient, directory: string): Promise<void> {
const refreshed = await client.checkoutRefresh(directory);
if (!refreshed.success) {
throw new Error(`Failed to refresh checkout for ${worktreeDirectory}`);
throw new Error(`Failed to refresh checkout for ${directory}`);
}
}
// The archive confirmation is a synchronous web `window.confirm()`. The click that
// opens it does not resolve until the dialog is answered, so the handler must
// accept/dismiss inline — awaiting the dialog only *after* the click deadlocks, as
// the click waits for an answer that is gated behind that same click.
async function clickArchiveAndAnswerWarning(
// The archive confirmation is a synchronous web `window.confirm()` that opens once the app has
// read the worktree's git state from the host. Wait for the dialog itself: on web the click does
// not resolve until it is answered, so answer it from the event.
async function answerArchiveWarning(
page: Page,
workspaceId: string,
answer: "accept" | "dismiss",
openArchive: () => Promise<void>,
): Promise<Dialog> {
let warning: Dialog | undefined;
page.once("dialog", (dialog) => {
warning = dialog;
void (answer === "accept" ? dialog.accept() : dialog.dismiss());
const warning = page.waitForEvent("dialog", { timeout: 10_000 }).then(async (dialog) => {
await (answer === "accept" ? dialog.accept() : dialog.dismiss());
return dialog;
});
await clickArchiveWorkspaceMenuItem(page, workspaceId);
if (!warning) {
throw new Error("Expected an archive confirmation dialog, but none was shown.");
await openArchive();
return await warning;
}
// A clean Paseo worktree's primary git action in the workspace header is Archive.
function gitActionsArchiveButton(page: Page) {
return page.getByTestId("changes-primary-cta").and(page.getByLabel("Archive workspace"));
}
async function clickRowArchiveButton(page: Page, workspaceId: string): Promise<void> {
await workspaceRow(page, workspaceId).hover();
await rowArchiveButton(page, workspaceId).click();
}
interface SessionEnvelope {
type?: unknown;
message?: {
type?: unknown;
workspaceId?: unknown;
cwd?: unknown;
payload?: Record<string, unknown>;
};
}
function parseSessionEnvelope(message: string): SessionEnvelope | null {
try {
return JSON.parse(message) as SessionEnvelope;
} catch {
return null;
}
return warning;
}
function removeGitStatusFromDescriptors(value: unknown, workspaceId: string): number {
if (Array.isArray(value)) {
return value.reduce<number>(
(count, item) => count + removeGitStatusFromDescriptors(item, workspaceId),
0,
);
}
if (!value || typeof value !== "object") return 0;
const record = value as Record<string, unknown>;
let count = 0;
if (record.id === workspaceId && "workspaceDirectory" in record) {
delete record.gitRuntime;
record.diffStat = null;
count += 1;
}
for (const child of Object.values(record)) {
count += removeGitStatusFromDescriptors(child, workspaceId);
}
return count;
}
/**
* Relays the app's connection to the daemon. Every workspace descriptor for `workspaceId` reaches
* the app without git status, as the host sends it before its first checkout snapshot of the
* worktree, so the sidebar row has nothing cached to warn from. `failCheckoutStatusFor` turns the
* host's checkout status answer for that directory into a git error.
*/
async function relayDaemon(
page: Page,
input: { workspaceId: string; failCheckoutStatusFor?: string },
) {
let descriptorRewrites = 0;
let archiveRequests = 0;
await page.routeWebSocket(daemonWsRoutePattern(), (browser) => {
const server = browser.connectToServer();
browser.onMessage((message) => {
if (typeof message === "string") {
const request = parseSessionEnvelope(message)?.message;
if (
request?.type === "archive_workspace_request" &&
request.workspaceId === input.workspaceId
) {
archiveRequests += 1;
}
}
server.send(message);
});
server.onMessage((message) => {
if (typeof message !== "string") {
browser.send(message);
return;
}
const envelope = parseSessionEnvelope(message);
const response = envelope?.message;
if (
input.failCheckoutStatusFor &&
response?.type === "checkout_status_response" &&
response.payload?.cwd === input.failCheckoutStatusFor
) {
response.payload = {
...response.payload,
isGit: false,
isPaseoOwnedWorktree: false,
repoRoot: null,
currentBranch: null,
isDirty: null,
baseRef: null,
aheadBehind: null,
aheadOfOrigin: null,
behindOfOrigin: null,
hasRemote: false,
remoteUrl: null,
error: { code: "UNKNOWN", message: "git status failed" },
};
browser.send(JSON.stringify(envelope));
return;
}
if (!envelope || !message.includes(input.workspaceId)) {
browser.send(message);
return;
}
descriptorRewrites += removeGitStatusFromDescriptors(envelope, input.workspaceId);
browser.send(JSON.stringify(envelope));
});
});
return {
descriptorRewrites: () => descriptorRewrites,
archiveRequests: () => archiveRequests,
};
}
/**
* Holds the app's next checkout status request for `directory` once armed, so a test can act
* while the archive waits for the host's answer.
*/
async function holdCheckoutStatusRequest(page: Page, directory: string) {
let armed = false;
let held: (() => void) | null = null;
await page.routeWebSocket(daemonWsRoutePattern(), (browser) => {
const server = browser.connectToServer();
browser.onMessage((message) => {
const request = typeof message === "string" ? parseSessionEnvelope(message)?.message : null;
if (
armed &&
!held &&
request?.type === "checkout_status_request" &&
request.cwd === directory
) {
held = () => server.send(message);
return;
}
server.send(message);
});
server.onMessage((message) => browser.send(message));
});
return {
arm: () => {
armed = true;
},
waitForHeld: () => expect.poll(() => held !== null).toBe(true),
release: () => {
if (!held) throw new Error("No checkout status request is held");
held();
},
};
}
test.describe("Workspace archive risk warning for worktree backing", () => {
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
let client: DaemonClient;
let tempRepo: { path: string; cleanup: () => Promise<void> };
const createdWorktreeDirectories = new Set<string>();
@@ -103,36 +264,53 @@ test.describe("Workspace archive risk warning for worktree backing", () => {
await tempRepo?.cleanup().catch(() => undefined);
});
test("a risky workspace archive is gated by confirmation and removes its worktree after acceptance", async ({
page,
}) => {
const serverId = getServerId();
await openProjectViaDaemon(client, tempRepo.path);
async function createWorktree(slug: string) {
const project = await openProjectViaDaemon(client, tempRepo.path);
const worktree = await createWorktreeViaDaemon(client, {
cwd: tempRepo.path,
slug: `archive-risk-${Date.now()}`,
slug: `${slug}-${Date.now()}`,
});
createdWorktreeDirectories.add(worktree.workspaceDirectory);
expect(existsSync(worktree.workspaceDirectory)).toBe(true);
return { ...worktree, project };
}
await seedRiskyWorktree(client, worktree.workspaceDirectory);
async function openSidebarRow(page: Page, workspaceId: string): Promise<void> {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await waitForWorkspaceInSidebar(page, { serverId, workspaceId: worktree.workspaceId });
await waitForWorkspaceInSidebar(page, { serverId: getServerId(), workspaceId });
}
const firstWarning = await clickArchiveAndAnswerWarning(page, worktree.workspaceId, "dismiss");
async function expectWorktreeKept(page: Page, worktree: { workspaceId: string }) {
await expect(workspaceRow(page, worktree.workspaceId)).toBeVisible({ timeout: 10_000 });
}
async function hostListsWorkspace(workspaceId: string): Promise<boolean> {
const { entries } = await client.fetchWorkspaces();
return entries.some((entry) => entry.id === workspaceId);
}
test("a risky workspace archive is gated by confirmation and removes its worktree after acceptance", async ({
page,
}) => {
const worktree = await createWorktree("archive-risk");
await seedRiskyWorktree(client, worktree.workspaceDirectory);
await openSidebarRow(page, worktree.workspaceId);
const firstWarning = await answerArchiveWarning(page, "dismiss", () =>
clickArchiveWorkspaceMenuItem(page, worktree.workspaceId),
);
expect(firstWarning.type()).toBe("confirm");
expect(firstWarning.message()).toContain(`Archive "${worktree.workspaceName}"?`);
expect(firstWarning.message()).toContain("Uncommitted changes");
expect(firstWarning.message()).toContain("1 unpushed commit");
await expect(
page.getByTestId(`sidebar-workspace-row-${serverId}:${worktree.workspaceId}`),
).toBeVisible({ timeout: 10_000 });
await expectWorktreeKept(page, worktree);
expect(existsSync(worktree.workspaceDirectory)).toBe(true);
const secondWarning = await clickArchiveAndAnswerWarning(page, worktree.workspaceId, "accept");
const secondWarning = await answerArchiveWarning(page, "accept", () =>
clickArchiveWorkspaceMenuItem(page, worktree.workspaceId),
);
expect(secondWarning.message()).toContain("Uncommitted changes");
expect(secondWarning.message()).toContain("1 unpushed commit");
@@ -143,4 +321,128 @@ test.describe("Workspace archive risk warning for worktree backing", () => {
createdWorktreeDirectories.delete(worktree.workspaceDirectory);
});
test("the row's archive button warns from the host's git state while the row has none, and Cancel keeps the worktree", async ({
page,
}) => {
const worktree = await createWorktree("archive-risk-uncached");
await seedRiskyWorktree(client, worktree.workspaceDirectory);
const relay = await relayDaemon(page, { workspaceId: worktree.workspaceId });
await openSidebarRow(page, worktree.workspaceId);
expect(relay.descriptorRewrites()).toBeGreaterThan(0);
const warning = await answerArchiveWarning(page, "dismiss", () =>
clickRowArchiveButton(page, worktree.workspaceId),
);
expect(warning.message()).toContain(`Archive "${worktree.workspaceName}"?`);
expect(warning.message()).toContain("Uncommitted changes");
expect(warning.message()).toContain("1 unpushed commit");
await expectWorktreeKept(page, worktree);
expect(relay.archiveRequests()).toBe(0);
expect(existsSync(path.join(worktree.workspaceDirectory, "DIRTY.md"))).toBe(true);
});
test("a worktree holding only new files warns when the repository hides untracked files", async ({
page,
}) => {
execSync("git config status.showUntrackedFiles no", { cwd: tempRepo.path, stdio: "ignore" });
const worktree = await createWorktree("archive-risk-untracked");
await writeFile(path.join(worktree.workspaceDirectory, "NEW.md"), "# not yet added\n");
await refreshCheckout(client, worktree.workspaceDirectory);
await openSidebarRow(page, worktree.workspaceId);
const warning = await answerArchiveWarning(page, "dismiss", () =>
clickRowArchiveButton(page, worktree.workspaceId),
);
expect(warning.message()).toContain(`Archive "${worktree.workspaceName}"?`);
expect(warning.message()).toContain("Uncommitted changes");
await expectWorktreeKept(page, worktree);
expect(existsSync(path.join(worktree.workspaceDirectory, "NEW.md"))).toBe(true);
});
test("a failed git read shows its error and keeps the worktree", async ({ page }) => {
const worktree = await createWorktree("archive-risk-read-failure");
await seedRiskyWorktree(client, worktree.workspaceDirectory);
const relay = await relayDaemon(page, {
workspaceId: worktree.workspaceId,
failCheckoutStatusFor: worktree.workspaceDirectory,
});
await openSidebarRow(page, worktree.workspaceId);
await clickRowArchiveButton(page, worktree.workspaceId);
await expect(page.getByTestId("app-toast-message")).toHaveText("git status failed");
await expectWorktreeKept(page, worktree);
expect(relay.archiveRequests()).toBe(0);
expect(existsSync(worktree.workspaceDirectory)).toBe(true);
});
// Archives the open worktree with `startArchive`, opens the project's main workspace while the
// host's checkout status answer is held, then lets the answer through.
async function archiveWhileOpeningAnotherWorkspace(
page: Page,
slug: string,
startArchive: (worktree: { workspaceId: string }) => Promise<void>,
): Promise<string> {
const serverId = getServerId();
const worktree = await createWorktree(slug);
const statusRequest = await holdCheckoutStatusRequest(page, worktree.workspaceDirectory);
await openSidebarRow(page, worktree.workspaceId);
await workspaceRow(page, worktree.workspaceId).click();
await expectAppRoute(page, buildHostWorkspaceRoute(serverId, worktree.workspaceId));
statusRequest.arm();
await startArchive(worktree);
await statusRequest.waitForHeld();
await workspaceRow(page, worktree.project.workspaceId).click();
const openedRoute = buildHostWorkspaceRoute(serverId, worktree.project.workspaceId);
await expectAppRoute(page, openedRoute);
statusRequest.release();
await expect(undoButton(page)).toBeVisible();
// The redirect runs before the archive request, so once the host has archived the worktree
// any redirect has already happened.
await expect
.poll(() => hostListsWorkspace(worktree.workspaceId), { timeout: 30_000 })
.toBe(false);
return openedRoute;
}
test("opening another workspace while the host answers keeps it open after a row archive", async ({
page,
}) => {
const openedRoute = await archiveWhileOpeningAnotherWorkspace(
page,
"archive-risk-switch-row",
(worktree) => clickRowArchiveButton(page, worktree.workspaceId),
);
await expectAppRoute(page, openedRoute);
});
test("opening another workspace while the host answers keeps it open after a git actions archive", async ({
page,
}) => {
const openedRoute = await archiveWhileOpeningAnotherWorkspace(
page,
"archive-risk-switch-git",
() => gitActionsArchiveButton(page).click(),
);
await expectAppRoute(page, openedRoute);
});
test("archiving the open worktree from its git actions leaves it for a new workspace", async ({
page,
}) => {
const worktree = await createWorktree("archive-risk-git-redirect");
await openSidebarRow(page, worktree.workspaceId);
await workspaceRow(page, worktree.workspaceId).click();
await expectAppRoute(page, buildHostWorkspaceRoute(getServerId(), worktree.workspaceId));
await gitActionsArchiveButton(page).click();
await expect.poll(() => new URL(page.url()).pathname).toBe("/new");
await expectWorkspaceAbsentFromSidebar(page, worktree.workspaceId);
});
});
@@ -21,7 +21,6 @@ export interface OlderTimelinePagesGate {
getRepeatedEntryCount(): number;
getOwnedEntryCountContaining(text: string): number;
releasePage(pageNumber: number): void;
waitForRequestCount(count: number): Promise<void>;
}
export interface DaemonHydrationGate {
@@ -371,15 +370,6 @@ export async function holdAgentOlderTimelinePages(
const ownedEntries = new Map<string, string>();
const releasedPages = new Set<number>();
const delayedForwards = new Map<number, Array<() => void>>();
const requestWaiters = new Map<number, Array<() => void>>();
const resolveRequestWaiters = () => {
for (const [count, resolvers] of requestWaiters) {
if (requestCount < count) continue;
requestWaiters.delete(count);
for (const resolve of resolvers) resolve();
}
};
const routePattern =
daemonPort === undefined ? daemonWsRoutePattern() : wsRoutePatternForPort(String(daemonPort));
@@ -393,7 +383,6 @@ export async function holdAgentOlderTimelinePages(
sessionMessage.direction === "before"
) {
requestCount += 1;
resolveRequestWaiters();
}
server.send(message);
});
@@ -439,14 +428,6 @@ export async function holdAgentOlderTimelinePages(
for (const forward of delayedForwards.get(pageNumber) ?? []) forward();
delayedForwards.delete(pageNumber);
},
waitForRequestCount(count) {
if (requestCount >= count) return Promise.resolve();
return new Promise<void>((resolve) => {
const resolvers = requestWaiters.get(count) ?? [];
resolvers.push(resolve);
requestWaiters.set(count, resolvers);
});
},
};
}
@@ -427,7 +427,11 @@ export async function holdOlderHistoryPages(
const loader = page.getByTestId("load-older-history-spinner");
return {
async expectRequestedPages(count) {
await gate.waitForRequestCount(count);
await expect
.poll(() => gate.getRequestCount(), {
message: `older history requests ${count} page(s)`,
})
.toBeGreaterThanOrEqual(count);
expect(gate.getRequestCount()).toBe(count);
},
async expectSettledWithRequestedPages(count) {
@@ -162,3 +162,38 @@ export function createGatedTeardown(): GatedTeardown {
release: () => writeFileSync(releasePath, "release\n"),
};
}
export function archivedToast(page: Page) {
return page.getByTestId("workspace-archived-toast");
}
export interface SidebarListLayout {
/** Bottom edge of the list's visible area. */
listBottom: number;
/** Bottom edges of the visible project and workspace rows, top to bottom. */
rowBottoms: number[];
rowHeight: number;
}
/** Scrolls the sidebar's workspace list to its end and reads where its rows sit. */
export async function scrollSidebarListToEnd(page: Page): Promise<SidebarListLayout> {
const list = page.getByTestId("sidebar-project-workspace-list-scroll");
await list.evaluate((element) => {
element.scrollTop = element.scrollHeight;
});
return list.evaluate((element) => {
const visible = element.getBoundingClientRect();
const rows = Array.from(
element.querySelectorAll<HTMLElement>(
'[data-testid^="sidebar-workspace-row-"],[data-testid^="sidebar-project-row-"]',
),
)
.map((row) => row.getBoundingClientRect())
.filter((row) => row.height > 0 && row.bottom > visible.top && row.top < visible.bottom);
return {
listBottom: visible.bottom,
rowBottoms: rows.map((row) => row.bottom).sort((a, b) => a - b),
rowHeight: Math.min(...rows.map((row) => row.height)),
};
});
}
@@ -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) {
@@ -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,
]);
@@ -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}
@@ -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);
});
});
@@ -87,7 +87,6 @@ import {
import { ProjectLeadingVisual } from "@/components/sidebar/project-leading-visual";
import { useToast } from "@/contexts/toast-context";
import { getForgePresentation, normalizeForge } from "@/git/forge";
import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning";
import { hasVisibleOrderChanged, mergeWithRemainder } from "@/utils/sidebar-reorder";
import { confirmDialog } from "@/utils/confirm-dialog";
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
@@ -368,12 +367,10 @@ function projectKebabStyle({
function getProjectWorkspaceRowStyle({
isDragging,
isPressed,
selected,
isHovered,
}: {
isDragging: boolean;
isPressed: boolean;
selected: boolean;
isHovered: boolean;
}) {
@@ -382,7 +379,6 @@ function getProjectWorkspaceRowStyle({
isHovered && styles.workspaceRowHovered,
selected && styles.sidebarRowSelected,
isDragging && styles.workspaceRowDragging,
isPressed && styles.workspaceRowPressed,
];
}
@@ -820,7 +816,6 @@ function NewWorkspaceGhostRow({
({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.newWorkspaceGhostRow,
hovered && !pressed && styles.newWorkspaceGhostRowHovered,
pressed && styles.newWorkspaceGhostRowPressed,
],
[],
);
@@ -833,6 +828,7 @@ function NewWorkspaceGhostRow({
})}
onPress={handlePress}
style={rowStyle}
highlightStyle={styles.newWorkspaceGhostRowPressed}
testID={`sidebar-project-new-workspace-row-${project.viewKey}`}
>
{({ hovered, pressed }) => (
@@ -883,7 +879,7 @@ function ProjectHeaderRow({
dragHandleProps,
}: ProjectHeaderRowProps) {
const [isHovered, setIsHovered] = useState(false);
const [isPressed, setIsPressed] = useState(false);
const [isHighlighted, setIsHighlighted] = useState(false);
const [contextMenuOpen, setContextMenuOpen] = useState(false);
const isMobileBreakpoint = useIsCompactFormFactor();
const localDaemonServerId = useLocalDaemonServerId();
@@ -930,25 +926,13 @@ function ProjectHeaderRow({
setContextMenuOpen(open);
if (open) setIsHovered(false);
}, []);
const handleProjectPressIn = useCallback(
(event: GestureResponderEvent) => {
setIsPressed(true);
interaction.handlePressIn(event);
},
[interaction],
);
const handleProjectPressOut = useCallback(() => {
setIsPressed(false);
interaction.handlePressOut();
}, [interaction]);
const projectRowStyle = useCallback(
({ pressed }: PressableStateCallbackType) => [
const projectRowStyle = useMemo(
() => [
styles.projectRow,
isDragging && styles.projectRowDragging,
selected && styles.sidebarRowSelected,
isHovered && styles.projectRowHovered,
pressed && styles.projectRowPressed,
],
[isDragging, selected, isHovered],
);
@@ -961,7 +945,7 @@ function ProjectHeaderRow({
iconDataUri={iconDataUri}
statusBucket={statusBucket}
projectViewKey={project.viewKey}
backdrop={getSidebarRowBackdrop({ isDragging, isPressed, selected, isHovered })}
backdrop={getSidebarRowBackdrop({ isDragging, isHighlighted, selected, isHovered })}
chevron={chevron}
showChevron={isHovered && chevron !== null}
isArchiving={isArchiving}
@@ -1007,9 +991,10 @@ function ProjectHeaderRow({
accessibilityRole="button"
style={projectRowStyle}
highlightStyle={styles.projectRowPressed}
onPressIn={handleProjectPressIn}
onHighlightChange={setIsHighlighted}
onPressIn={interaction.handlePressIn}
onTouchMove={interaction.handleTouchMove}
onPressOut={handleProjectPressOut}
onPressOut={interaction.handlePressOut}
onPress={handlePress}
testID={`sidebar-project-row-${project.viewKey}`}
>
@@ -1033,9 +1018,10 @@ function ProjectHeaderRow({
accessibilityRole="button"
style={projectRowStyle}
highlightStyle={styles.projectRowPressed}
onPressIn={handleProjectPressIn}
onHighlightChange={setIsHighlighted}
onPressIn={interaction.handlePressIn}
onTouchMove={interaction.handleTouchMove}
onPressOut={handleProjectPressOut}
onPressOut={interaction.handlePressOut}
onPress={handlePress}
testID={`sidebar-project-row-${project.viewKey}`}
>
@@ -1091,7 +1077,7 @@ function WorkspaceRowInner({
reserveIdleStatusIndicatorSpace = true,
}: WorkspaceRowInnerProps) {
const isCompact = useIsCompactFormFactor();
const [isPressed, setIsPressed] = useState(false);
const [isHighlighted, setIsHighlighted] = useState(false);
const isTouchPlatform = platformIsNative || isCompact;
const interaction = useLongPressDragInteraction({
drag,
@@ -1111,17 +1097,6 @@ function WorkspaceRowInner({
}
onPress();
}, [interaction.didLongPressRef, onPress]);
const handleWorkspacePressIn = useCallback(
(event: GestureResponderEvent) => {
setIsPressed(true);
interaction.handlePressIn(event);
},
[interaction],
);
const handleWorkspacePressOut = useCallback(() => {
setIsPressed(false);
interaction.handlePressOut();
}, [interaction]);
const accessibilityState = useMemo(() => ({ selected }), [selected]);
const isEditingTitle = titleEditor !== null;
@@ -1135,13 +1110,8 @@ function WorkspaceRowInner({
{({ isHovered, contextMenuOpen, onContextMenuOpenChange, hoverHandlers }) => {
const isDesktop = !isTouchPlatform;
const serviceSummary = isDesktop ? selectWorkspaceServiceSummary(workspace.scripts) : null;
const workspaceRowStyle = getProjectWorkspaceRowStyle({
isDragging,
isPressed,
selected,
isHovered,
});
const backdrop = getSidebarRowBackdrop({ isDragging, isPressed, selected, isHovered });
const workspaceRowStyle = getProjectWorkspaceRowStyle({ isDragging, selected, isHovered });
const backdrop = getSidebarRowBackdrop({ isDragging, isHighlighted, selected, isHovered });
return (
<View
{...dragAttributes}
@@ -1176,10 +1146,11 @@ function WorkspaceRowInner({
accessibilityRole="button"
accessibilityState={accessibilityState}
style={workspaceRowStyle}
highlightStyle={styles.workspaceRowPressed}
onPressIn={isEditingTitle ? undefined : handleWorkspacePressIn}
highlightStyle={isEditingTitle ? undefined : styles.workspaceRowPressed}
onHighlightChange={setIsHighlighted}
onPressIn={isEditingTitle ? undefined : interaction.handlePressIn}
onTouchMove={isEditingTitle ? undefined : interaction.handleTouchMove}
onPressOut={isEditingTitle ? undefined : handleWorkspacePressOut}
onPressOut={isEditingTitle ? undefined : interaction.handlePressOut}
onPress={isEditingTitle ? undefined : handlePress}
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
>
@@ -1280,8 +1251,9 @@ function WorkspaceRowWithMenu({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
workspaceKind: workspace.workspaceKind,
workspaceDirectory: workspace.workspaceDirectory,
name: workspace.name,
...toWorktreeArchiveRisk(workspace),
diffStat: workspace.diffStat,
onArchiveStarted: redirectAfterArchive,
onSetHiding: setIsHidingWorkspace,
});
@@ -1557,6 +1529,8 @@ function ProjectBlock({
showShortcutBadges,
shortcutIndexByWorkspaceKey,
onToggleCollapsed,
workspacesExpanded,
onToggleWorkspacesExpanded,
onWorkspacePress,
parentGestureRef,
onWorkspaceReorder,
@@ -1583,6 +1557,9 @@ function ProjectBlock({
showShortcutBadges: boolean;
shortcutIndexByWorkspaceKey: Map<string, number>;
onToggleCollapsed: (projectViewKey: string) => void;
/** "Show more" on this project's workspaces, owned by the list so a drop's remount keeps it. */
workspacesExpanded: boolean;
onToggleWorkspacesExpanded: (projectViewKey: string) => void;
onWorkspacePress?: () => void;
parentGestureRef?: MutableRefObject<GestureType | undefined>;
onWorkspaceReorder: (projectViewKey: string, workspaces: SidebarWorkspacePlacement[]) => void;
@@ -1607,12 +1584,11 @@ function ProjectBlock({
const {
leadingItem: leadingWorkspace,
visibleItems: visibleWorkspaces,
expanded: workspacesExpanded,
canToggle: canToggleWorkspaces,
toggleExpanded: toggleWorkspacesExpanded,
} = useLimitedSidebarGroup({
items: project.workspaces,
selection: selectionEnabled ? activeWorkspaceSelection : null,
expanded: workspacesExpanded,
policy: visibilityPolicy,
});
const activeServerId = activeWorkspaceSelection?.serverId ?? null;
@@ -1770,6 +1746,10 @@ function ProjectBlock({
onToggleCollapsed(project.viewKey);
}, [onToggleCollapsed, project.viewKey]);
const handleToggleWorkspacesExpanded = useCallback(() => {
onToggleWorkspacesExpanded(project.viewKey);
}, [onToggleWorkspacesExpanded, project.viewKey]);
let projectChildren = null;
if (!collapsed) {
if (project.workspaces.length > 0) {
@@ -1806,7 +1786,7 @@ function ProjectBlock({
{canToggleWorkspaces ? (
<SidebarGroupToggleRow
expanded={workspacesExpanded}
onPress={toggleWorkspacesExpanded}
onPress={handleToggleWorkspacesExpanded}
testID={`sidebar-project-show-more-${project.viewKey}`}
/>
) : null}
@@ -1876,6 +1856,8 @@ function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlo
previous.supportsPinningByServerId === next.supportsPinningByServerId &&
previous.onToggleWorkspacePin === next.onToggleWorkspacePin &&
previous.onToggleCollapsed === next.onToggleCollapsed &&
previous.workspacesExpanded === next.workspacesExpanded &&
previous.onToggleWorkspacesExpanded === next.onToggleWorkspacesExpanded &&
previous.onWorkspacePress === next.onWorkspacePress &&
previous.parentGestureRef === next.parentGestureRef &&
previous.onWorkspaceReorder === next.onWorkspaceReorder &&
@@ -2204,15 +2186,29 @@ function ProjectModeList({
const selectionEnabled = isWorkspaceRoute;
const activeWorkspaceSelection = useActiveWorkspaceSelection();
const { pinnedChats, unpinnedProjects } = pinnedGroups;
const [pinnedChatsExpanded, setPinnedChatsExpanded] = useState(false);
const togglePinnedChatsExpanded = useCallback(
() => setPinnedChatsExpanded((current) => !current),
[],
);
const [expandedProjectKeys, setExpandedProjectKeys] = useState<ReadonlySet<string>>(
() => new Set(),
);
const toggleProjectWorkspacesExpanded = useCallback((projectViewKey: string) => {
setExpandedProjectKeys((current) => {
const next = new Set(current);
if (!next.delete(projectViewKey)) next.add(projectViewKey);
return next;
});
}, []);
const {
leadingItem: leadingPinnedChat,
visibleItems: visiblePinnedChats,
expanded: pinnedChatsExpanded,
canToggle: canTogglePinnedChats,
toggleExpanded: togglePinnedChatsExpanded,
} = useLimitedSidebarGroup({
items: pinnedChats,
selection: selectionEnabled ? activeWorkspaceSelection : null,
expanded: pinnedChatsExpanded,
});
const nativeScrollGestureProps = useMemo(
() =>
@@ -2368,6 +2364,8 @@ function ProjectModeList({
showShortcutBadges={showShortcutBadges}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
onToggleCollapsed={onToggleProjectCollapsed}
workspacesExpanded={expandedProjectKeys.has(item.viewKey)}
onToggleWorkspacesExpanded={toggleProjectWorkspacesExpanded}
onWorkspacePress={onWorkspacePress}
manualSort={manualSort}
parentGestureRef={parentGestureRef}
@@ -2389,6 +2387,8 @@ function ProjectModeList({
},
[
collapsedProjectKeys,
expandedProjectKeys,
toggleProjectWorkspacesExpanded,
activeWorkspaceSelection,
handleWorktreeCreated,
manualSort,
@@ -2592,9 +2592,9 @@ const styles = StyleSheet.create((theme) => ({
// Optical inset: aligns the visible Pinned/Workspaces glyph edge with the
// Schedules icon across the divider; their layout boxes have different insets.
paddingTop: 2,
// Keep the last row scrollable above the sidebar toast, including a wrapped Undo row.
// This space stays present across toast changes so the scroll range never jumps.
paddingBottom: theme.spacing[24],
// The sidebar toast floats over the list's end. Keep this padding the same whether or not
// a toast shows: changing it moves the rows when the toast leaves.
paddingBottom: theme.spacing[4],
},
projectListContainer: {
width: "100%",
@@ -39,7 +39,6 @@ export function SidebarGroupToggleRow({
styles.row,
indented && sidebarWorkspaceRowStyles.rowIndented,
hovered && !pressed && styles.rowHovered,
pressed && styles.rowPressed,
],
[indented],
);
@@ -50,6 +49,7 @@ export function SidebarGroupToggleRow({
accessibilityLabel={label}
onPress={onPress}
style={rowStyle}
highlightStyle={styles.rowPressed}
testID={testID}
>
{({ hovered, pressed }) => (
@@ -12,16 +12,17 @@ import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
*/
export function getSidebarRowBackdrop({
isDragging = false,
isPressed = false,
isHighlighted = false,
selected = false,
isHovered = false,
}: {
isDragging?: boolean;
isPressed?: boolean;
/** The press highlight is painting the row's pressed fill. */
isHighlighted?: boolean;
selected?: boolean;
isHovered?: boolean;
}): SidebarSurfaceBackdrop {
if (isDragging || isPressed) return "surface2";
if (isDragging || isHighlighted) return "surface2";
if (selected) return "surfaceSidebarSelected";
if (isHovered) return "surfaceSidebarHover";
return "surfaceSidebar";
@@ -9,13 +9,7 @@ import {
type Ref,
} from "react";
import { useTranslation } from "react-i18next";
import {
View,
Text,
ScrollView,
type GestureResponderEvent,
type PressableStateCallbackType,
} from "react-native";
import { View, Text, ScrollView } from "react-native";
import { NestableScrollContainer } from "react-native-draggable-flatlist";
import type { GestureType } from "react-native-gesture-handler";
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
@@ -44,7 +38,6 @@ import { useSidebarWorkspaceRename } from "@/workspace-rename";
import { useWorkspaceClipboardActions } from "@/hooks/use-workspace-clipboard-actions";
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
@@ -155,13 +148,20 @@ export function SidebarStatusWorkspaceList({
const togglePinnedCollapsed = useSidebarCollapsedSectionsStore(
(state) => state.togglePinnedCollapsed,
);
const [pinnedWorkspacesExpanded, setPinnedWorkspacesExpanded] = useState(false);
const togglePinnedWorkspacesExpanded = useCallback(
() => setPinnedWorkspacesExpanded((current) => !current),
[],
);
const {
leadingItem: leadingPinnedWorkspace,
visibleItems: visiblePinnedWorkspaces,
expanded: pinnedWorkspacesExpanded,
canToggle: canTogglePinnedWorkspaces,
toggleExpanded: togglePinnedWorkspacesExpanded,
} = useLimitedSidebarGroup({ items: pinnedWorkspaces, selection: activeWorkspaceSelection });
} = useLimitedSidebarGroup({
items: pinnedWorkspaces,
selection: activeWorkspaceSelection,
expanded: pinnedWorkspacesExpanded,
});
const statusShortcutIndex = showShortcutBadges
? shortcutIndexByWorkspaceKey
@@ -366,15 +366,19 @@ function StatusGroupRows({
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
}) {
const activeWorkspaceSelection = useActiveWorkspaceSelection();
const [workspacesExpanded, setWorkspacesExpanded] = useState(false);
const toggleWorkspacesExpanded = useCallback(
() => setWorkspacesExpanded((current) => !current),
[],
);
const {
leadingItem: leadingWorkspace,
visibleItems: visibleWorkspaces,
expanded: workspacesExpanded,
canToggle: canToggleWorkspaces,
toggleExpanded: toggleWorkspacesExpanded,
} = useLimitedSidebarGroup({
items: group.rows,
selection: activeWorkspaceSelection,
expanded: workspacesExpanded,
policy: STATUS_GROUP_VISIBILITY,
});
@@ -460,12 +464,8 @@ function StatusGroupHeader({
}, [group.key, toggleWorkspaceGroupCollapsed]);
const handleHoverIn = useCallback(() => setIsHovered(true), []);
const handleHoverOut = useCallback(() => setIsHovered(false), []);
const rowStyle = useCallback(
({ pressed }: PressableStateCallbackType) => [
styles.statusGroupRow,
isHovered && styles.statusGroupRowHovered,
pressed && styles.statusGroupRowPressed,
],
const rowStyle = useMemo(
() => [styles.statusGroupRow, isHovered && styles.statusGroupRowHovered],
[isHovered],
);
const accessibilityState = useMemo(() => ({ expanded: !collapsed }), [collapsed]);
@@ -477,6 +477,7 @@ function StatusGroupHeader({
accessibilityLabel={t("sidebar.statusGroupAccessibility", { label: group.label })}
accessibilityState={accessibilityState}
style={rowStyle}
highlightStyle={styles.statusGroupRowPressed}
onPress={handlePress}
testID={`sidebar-status-group-${group.key}`}
>
@@ -654,8 +655,9 @@ function StatusWorkspaceRowWithMenu({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
workspaceKind: workspace.workspaceKind,
workspaceDirectory: workspace.workspaceDirectory,
name: workspace.name,
...toWorktreeArchiveRisk(workspace),
diffStat: workspace.diffStat,
onArchiveStarted: redirectAfterArchive,
onSetHiding: setIsHidingWorkspace,
});
@@ -826,7 +828,7 @@ function StatusWorkspaceRowInnerContent({
}) {
const isCompact = useIsCompactFormFactor();
const isTouchPlatform = platformIsNative || isCompact;
const [isPressed, setIsPressed] = useState(false);
const [isHighlighted, setIsHighlighted] = useState(false);
const trailing = useSidebarWorkspaceTrailing();
const {
role: _dragRole,
@@ -851,17 +853,6 @@ function StatusWorkspaceRowInnerContent({
}
onPress();
}, [didLongPressRef, onPress]);
const handlePressIn = useCallback(
(event: GestureResponderEvent) => {
setIsPressed(true);
startDragPress?.(event);
},
[startDragPress],
);
const handlePressOut = useCallback(() => {
setIsPressed(false);
endDragPress?.();
}, [endDragPress]);
return (
<SidebarWorkspaceRowFrame
@@ -887,13 +878,12 @@ function StatusWorkspaceRowInnerContent({
showShortcut,
});
const workspaceRowStyle = getStatusWorkspaceRowStyle({
isPressed,
selected,
isHovered,
inStatusGroup,
isDragging,
});
const backdrop = getSidebarRowBackdrop({ isDragging, isPressed, selected, isHovered });
const backdrop = getSidebarRowBackdrop({ isDragging, isHighlighted, selected, isHovered });
return (
<View
{...dragAttributes}
@@ -928,10 +918,11 @@ function StatusWorkspaceRowInnerContent({
accessibilityRole="button"
accessibilityState={accessibilityState}
style={workspaceRowStyle}
highlightStyle={styles.workspaceRowPressed}
onPressIn={isEditingTitle ? undefined : handlePressIn}
highlightStyle={isEditingTitle ? undefined : styles.workspaceRowPressed}
onHighlightChange={setIsHighlighted}
onPressIn={isEditingTitle ? undefined : startDragPress}
onTouchMove={isEditingTitle ? undefined : moveDragPress}
onPressOut={isEditingTitle ? undefined : handlePressOut}
onPressOut={isEditingTitle ? undefined : endDragPress}
onPress={isEditingTitle ? undefined : handlePress}
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
>
@@ -1068,13 +1059,11 @@ function StatusWorkspaceActionSlot({
}
function getStatusWorkspaceRowStyle({
isPressed,
selected,
isHovered,
inStatusGroup,
isDragging,
}: {
isPressed: boolean;
selected: boolean;
isHovered: boolean;
inStatusGroup: boolean;
@@ -1086,7 +1075,6 @@ function getStatusWorkspaceRowStyle({
isHovered && styles.workspaceRowHovered,
selected && styles.sidebarRowSelected,
isDragging && styles.workspaceRowDragging,
isPressed && styles.workspaceRowPressed,
];
}
@@ -1101,8 +1089,9 @@ const styles = StyleSheet.create((theme) => ({
paddingHorizontal: theme.spacing[2],
// Keep status mode's Pinned/Workspaces boundary identical to project mode.
paddingTop: 2,
// Match project grouping: permanent room to scroll the final row above sidebar toasts.
paddingBottom: theme.spacing[24],
// The sidebar toast floats over the list's end. Keep this padding the same whether or not
// a toast shows: changing it moves the rows when the toast leaves.
paddingBottom: theme.spacing[4],
},
pinnedSection: {
marginBottom: theme.spacing[1],
@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from "react";
import { useMemo } from "react";
import {
selectSidebarGroupVisibility,
type SidebarGroupItem,
@@ -12,23 +12,22 @@ const PINNED_POLICY = { kind: "pinned" } as const;
interface LimitedSidebarGroupInput<T> {
items: readonly T[];
selection: ActiveWorkspaceSelection | null;
/**
* Whether the user revealed the whole group. The caller owns it: a native drop remounts the
* rows it moved, and a reveal held inside a row would reset with them.
*/
expanded: boolean;
policy?: SidebarGroupVisibilityPolicy<T>;
}
export function useLimitedSidebarGroup<T extends SidebarGroupItem>({
items,
selection,
expanded,
policy = PINNED_POLICY,
}: LimitedSidebarGroupInput<T>): SidebarGroupVisibility<T> & {
expanded: boolean;
toggleExpanded: () => void;
} {
const [expanded, setExpanded] = useState(false);
const visibility = useMemo(
}: LimitedSidebarGroupInput<T>): SidebarGroupVisibility<T> {
return useMemo(
() => selectSidebarGroupVisibility({ items, expanded, policy, selection }),
[expanded, items, policy, selection],
);
const toggleExpanded = useCallback(() => setExpanded((current) => !current), []);
return { ...visibility, expanded, toggleExpanded };
}
@@ -8,7 +8,6 @@ import {
import {
View,
type GestureResponderEvent,
type PressableProps,
type PressableStateCallbackType,
type StyleProp,
type ViewProps,
@@ -27,6 +26,7 @@ import {
type MenuTriggerState,
} from "@/components/ui/menu";
import { PressHighlight } from "@/components/ui/press-highlight";
import type { PressHighlightProps } from "@/components/ui/press-highlight.types";
/**
* A menu opened by a long press or a right click, anchored to the point of the gesture rather
@@ -109,8 +109,7 @@ export function ContextMenuTrigger({
triggerRef,
...props
}: PropsWithChildren<
Omit<PressableProps, "style"> & {
highlightStyle?: StyleProp<ViewStyle>;
Omit<PressHighlightProps, "style"> & {
style?: TriggerStyleProp;
enabled?: boolean;
enabledOnMobile?: boolean;
@@ -0,0 +1,87 @@
import { forwardRef, useCallback, useMemo } from "react";
import {
Pressable,
type PressableProps,
type PressableStateCallbackType,
type View,
} from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import type { Theme } from "@/styles/theme";
import type { PressHighlightProps } from "./press-highlight.types";
interface RipplePressableProps extends Omit<PressableProps, "style"> {
rippleColor?: string;
// `withUnistyles` flattens a `style` prop, which would drop the caller's state callback.
pressableStyle: PressableProps["style"];
}
// A foreground ripple tints the content with the surface, so nothing inside needs to know the
// row is pressed.
const RipplePressable = withUnistyles(
forwardRef<View, RipplePressableProps>(function RipplePressable(
{ rippleColor, pressableStyle, ...props },
ref,
) {
const ripple = useMemo(
() => (rippleColor ? { color: rippleColor, foreground: true } : undefined),
[rippleColor],
);
return <Pressable {...props} ref={ref} style={pressableStyle} android_ripple={ripple} />;
}),
);
// Android's own `ViewConfiguration.getTapTimeout()`: a native list in a scroll view waits this long
// before showing pressed, so a scroll that claims the touch first cancels it.
const TAP_TIMEOUT_MS = 100;
const rippleColorMapping = (theme: Theme) => ({ rippleColor: theme.colors.interactionHighlight });
/**
* The platform ripple replaces `highlightStyle`. Pressable starts it from JS after the press
* delay, then the animation runs on the UI thread without a React commit.
*/
export const PressHighlight = forwardRef<View, PressHighlightProps>(function PressHighlight(
{
highlightStyle,
onHighlightChange: _onHighlightChange,
style,
unstable_pressDelay = TAP_TIMEOUT_MS,
...props
},
ref,
) {
const hasRipple = highlightStyle != null;
const rippleStyle = useCallback(
(state: PressableStateCallbackType) => [
hasRipple && styles.rippleBorderReset,
typeof style === "function" ? style(state) : style,
hasRipple && styles.rippleClip,
],
[style, hasRipple],
);
return (
<RipplePressable
{...props}
ref={ref}
pressableStyle={rippleStyle}
uniProps={hasRipple ? rippleColorMapping : undefined}
unstable_pressDelay={unstable_pressDelay}
/>
);
});
const styles = StyleSheet.create({
// React Native 0.86 sends NaN when a style drops `borderWidth`, and an overflow-hidden view then
// clips to a NaN padding box and stops painting. A sidebar row loses its drag border on drop.
// An explicit 0 under the caller's style sends 0 instead. Fixed upstream in
// facebook/react-native#57789; remove once the app ships a React Native release with it.
rippleBorderReset: {
borderWidth: 0,
},
// React Native draws ripples in the view's rectangle. With overflow hidden, the view clips its
// children to its rounded padding box, and that clip is still on the canvas when Android draws
// the foreground, so the ripple follows the corners.
rippleClip: {
overflow: "hidden",
},
});
@@ -4,6 +4,8 @@ import { ArrowUp } from "lucide-react-native";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { afterEach, expect, it } from "vitest";
import { PressHighlight } from "./press-highlight.native";
import { PressHighlight as AndroidPressHighlight } from "./press-highlight.android";
import { PressHighlight as WebPressHighlight } from "./press-highlight";
import { FloatingActionButton } from "./floating-action-button";
const rowStyle = { backgroundColor: "rgb(255, 255, 255)", borderRadius: 8 };
@@ -13,6 +15,22 @@ afterEach(cleanup);
function onPress() {}
function recordPresses() {
const calls: string[] = [];
return {
calls,
onPressIn: () => calls.push("in"),
onPressOut: () => calls.push("out"),
};
}
const borderedRowStyle = { ...rowStyle, borderWidth: 1, borderColor: "rgb(0, 0, 0)" };
function recordHighlights() {
const calls: boolean[] = [];
return { calls, record: (highlighted: boolean) => calls.push(highlighted) };
}
it("keeps a floating action opaque while showing its held-press feedback", async () => {
const { getByRole } = render(
<FloatingActionButton icon={ArrowUp} accessibilityLabel="Jump to file" onPress={onPress} />,
@@ -31,30 +49,147 @@ it("keeps a floating action opaque while showing its held-press feedback", async
await waitFor(() => expect(getComputedStyle(button).backgroundColor).toBe(restingFill));
});
it("uses the pressable's pressed state for the native row fill and its existing corners", () => {
const { getByTestId, rerender } = render(
it("paints the native row fill while pressed and keeps its existing corners", async () => {
const { getByTestId } = render(
<GestureHandlerRootView>
<PressHighlight
testID="row"
testOnly_pressed
style={rowStyle}
highlightStyle={highlightStyle}
/>
<PressHighlight testID="row" style={rowStyle} highlightStyle={highlightStyle} />
</GestureHandlerRootView>,
);
const row = getByTestId("row");
expect(getComputedStyle(getByTestId("row")).backgroundColor).toBe("rgb(20, 30, 40)");
expect(getComputedStyle(getByTestId("row")).borderRadius).toBe("8px");
fireEvent.mouseDown(row, { button: 0 });
await waitFor(() => expect(getComputedStyle(row).backgroundColor).toBe("rgb(20, 30, 40)"));
expect(getComputedStyle(row).borderRadius).toBe("8px");
rerender(
<GestureHandlerRootView>
<PressHighlight
testID="row"
testOnly_pressed={false}
style={rowStyle}
highlightStyle={highlightStyle}
/>
</GestureHandlerRootView>,
);
expect(getComputedStyle(getByTestId("row")).backgroundColor).toBe("rgb(255, 255, 255)");
fireEvent.mouseUp(row, { button: 0 });
await waitFor(() => expect(getComputedStyle(row).backgroundColor).toBe("rgb(255, 255, 255)"));
});
it("paints the pressed fill on web and reports it, so a caller does not paint its own", async () => {
const highlights = recordHighlights();
const { getByTestId } = render(
<WebPressHighlight
testID="row"
style={rowStyle}
highlightStyle={highlightStyle}
onHighlightChange={highlights.record}
onPress={onPress}
/>,
);
const row = getByTestId("row");
fireEvent.mouseDown(row, { button: 0 });
await waitFor(() => expect(getComputedStyle(row).backgroundColor).toBe("rgb(20, 30, 40)"));
expect(highlights.calls).toEqual([true]);
fireEvent.mouseUp(row, { button: 0 });
await waitFor(() => expect(getComputedStyle(row).backgroundColor).toBe("rgb(255, 255, 255)"));
expect(highlights.calls).toEqual([true, false]);
});
it("leaves the Android row surface alone while pressed and clips the ripple to its corners", () => {
const highlights = recordHighlights();
const { getByTestId } = render(
<AndroidPressHighlight
testID="row"
testOnly_pressed
style={rowStyle}
highlightStyle={highlightStyle}
onHighlightChange={highlights.record}
/>,
);
const row = getComputedStyle(getByTestId("row"));
expect(row.backgroundColor).toBe("rgb(255, 255, 255)");
expect(row.borderRadius).toBe("8px");
expect(row.overflow).toBe("hidden");
expect(highlights.calls).toEqual([]);
});
it("keeps the caller's border and press handlers on the Android ripple row", async () => {
const presses = recordPresses();
const { getByTestId } = render(
<AndroidPressHighlight
testID="row"
style={borderedRowStyle}
highlightStyle={highlightStyle}
onPressIn={presses.onPressIn}
onPressOut={presses.onPressOut}
onPress={onPress}
/>,
);
const row = getByTestId("row");
expect(getComputedStyle(row).borderTopWidth).toBe("1px");
fireEvent.mouseDown(row, { button: 0 });
fireEvent.mouseUp(row, { button: 0 });
await waitFor(() => expect(presses.calls).toEqual(["in", "out"]));
});
it("paints and reports nothing on web without a pressed fill, such as a row being renamed", () => {
const highlights = recordHighlights();
const { getByTestId } = render(
<WebPressHighlight
testID="row"
style={rowStyle}
onHighlightChange={highlights.record}
onPress={onPress}
/>,
);
const row = getByTestId("row");
fireEvent.mouseDown(row, { button: 0 });
expect(getComputedStyle(row).backgroundColor).toBe("rgb(255, 255, 255)");
fireEvent.mouseUp(row, { button: 0 });
expect(highlights.calls).toEqual([]);
});
const midPress = { ...recordPresses(), highlights: recordHighlights() };
function PressedRow({ filled }: { filled: boolean }) {
return (
<WebPressHighlight
testID="row"
style={rowStyle}
highlightStyle={filled ? highlightStyle : undefined}
onHighlightChange={midPress.highlights.record}
onPressIn={midPress.onPressIn}
onPressOut={midPress.onPressOut}
onPress={onPress}
/>
);
}
function resetMidPress() {
midPress.calls.length = 0;
midPress.highlights.calls.length = 0;
}
it("keeps a press's fill and report together when the fill appears mid-press", async () => {
resetMidPress();
const { getByTestId, rerender } = render(<PressedRow filled={false} />);
const row = getByTestId("row");
fireEvent.mouseDown(row, { button: 0 });
await waitFor(() => expect(midPress.calls).toEqual(["in"]));
rerender(<PressedRow filled />);
expect(getComputedStyle(row).backgroundColor).toBe("rgb(255, 255, 255)");
fireEvent.mouseUp(row, { button: 0 });
await waitFor(() => expect(midPress.calls).toEqual(["in", "out"]));
expect(midPress.highlights.calls).toEqual([]);
});
it("keeps a press's fill and report together when the fill goes away mid-press", async () => {
resetMidPress();
const { getByTestId, rerender } = render(<PressedRow filled />);
const row = getByTestId("row");
fireEvent.mouseDown(row, { button: 0 });
await waitFor(() => expect(midPress.calls).toEqual(["in"]));
rerender(<PressedRow filled={false} />);
expect(getComputedStyle(row).backgroundColor).toBe("rgb(20, 30, 40)");
fireEvent.mouseUp(row, { button: 0 });
await waitFor(() => expect(midPress.calls).toEqual(["in", "out"]));
expect(getComputedStyle(row).backgroundColor).toBe("rgb(255, 255, 255)");
expect(midPress.highlights.calls).toEqual([true, false]);
});
@@ -0,0 +1,60 @@
import { useCallback, useRef, useState } from "react";
import type {
GestureResponderEvent,
PressableStateCallbackType,
StyleProp,
ViewStyle,
} from "react-native";
import type { PressHighlightProps } from "./press-highlight.types";
type PressFillProps = Pick<
PressHighlightProps,
"style" | "highlightStyle" | "onPressIn" | "onPressOut" | "onHighlightChange"
>;
/**
* Paints `highlightStyle` while pressed and reports it, for the platforms that show a fill. A press
* keeps the fill it started with until it ends, so the paint and the report always agree, even when
* the caller adds or removes `highlightStyle` mid-press.
*/
export function usePressFill({
style,
highlightStyle,
onPressIn,
onPressOut,
onHighlightChange,
}: PressFillProps) {
const [pressFill, setPressFill] = useState<StyleProp<ViewStyle> | null>(null);
// Pressable can call the press-out handler from the render that started the press.
const reportedRef = useRef(false);
const fillStyle = useCallback(
(state: PressableStateCallbackType) => [
typeof style === "function" ? style(state) : style,
pressFill,
],
[style, pressFill],
);
const handlePressIn = useCallback(
(event: GestureResponderEvent) => {
if (highlightStyle != null) {
setPressFill(highlightStyle);
reportedRef.current = true;
onHighlightChange?.(true);
}
onPressIn?.(event);
},
[highlightStyle, onHighlightChange, onPressIn],
);
const handlePressOut = useCallback(
(event: GestureResponderEvent) => {
setPressFill(null);
if (reportedRef.current) {
reportedRef.current = false;
onHighlightChange?.(false);
}
onPressOut?.(event);
},
[onHighlightChange, onPressOut],
);
return { style: fillStyle, onPressIn: handlePressIn, onPressOut: handlePressOut };
}
@@ -1,21 +1,25 @@
import { forwardRef, useCallback } from "react";
import { Pressable, type PressableStateCallbackType, type View } from "react-native";
import { forwardRef } from "react";
import { Pressable, type View } from "react-native";
import { usePressFill } from "./press-highlight.fill";
import type { PressHighlightProps } from "./press-highlight.types";
// iOS. Android resolves `press-highlight.android.tsx`.
// Feedback waits this long so a scroll that claims the touch first cancels it.
const PRESS_DELAY_MS = 130;
export const PressHighlight = forwardRef<View, PressHighlightProps>(function PressHighlight(
{ highlightStyle, style, unstable_pressDelay = 130, ...props },
{
highlightStyle,
onHighlightChange,
style,
onPressIn,
onPressOut,
unstable_pressDelay = PRESS_DELAY_MS,
...props
},
ref,
) {
// Give the scroll view time to claim a pan before Pressable activates its feedback.
const pressStyle = useCallback(
(state: PressableStateCallbackType) => [
typeof style === "function" ? style(state) : style,
state.pressed && highlightStyle,
],
[style, highlightStyle],
);
return (
<Pressable {...props} ref={ref} unstable_pressDelay={unstable_pressDelay} style={pressStyle} />
);
const fill = usePressFill({ style, highlightStyle, onPressIn, onPressOut, onHighlightChange });
return <Pressable {...props} {...fill} ref={ref} unstable_pressDelay={unstable_pressDelay} />;
});
@@ -1,11 +1,13 @@
import { forwardRef } from "react";
import { Pressable } from "react-native";
import type { View } from "react-native";
import { usePressFill } from "./press-highlight.fill";
import type { PressHighlightProps } from "./press-highlight.types";
export const PressHighlight = forwardRef<View, PressHighlightProps>(function PressHighlight(
{ highlightStyle: _highlightStyle, ...props },
{ highlightStyle, onHighlightChange, style, onPressIn, onPressOut, ...props },
ref,
) {
return <Pressable ref={ref} {...props} />;
const fill = usePressFill({ style, highlightStyle, onPressIn, onPressOut, onHighlightChange });
return <Pressable ref={ref} {...props} {...fill} />;
});
@@ -1,6 +1,15 @@
import type { PressableProps, StyleProp, ViewStyle } from "react-native";
export interface PressHighlightProps extends PressableProps {
/** Native pressed style. Feedback and cancellation follow Pressable's press lifecycle. */
/**
* The pressed fill. iOS and web paint it while the pressable is pressed; Android draws the
* platform ripple over the content instead. Callers do not paint their own pressed fill.
*/
highlightStyle?: StyleProp<ViewStyle>;
/**
* Reports when `highlightStyle` starts and stops painting, so content that knocks out of the
* surface (a badge ring, a text scrim) can match it. The Android ripple tints over the content,
* so it never reports a highlight.
*/
onHighlightChange?: (highlighted: boolean) => void;
}
+8 -18
View File
@@ -1,10 +1,5 @@
import { memo, type ReactElement, useCallback, useMemo, useState } from "react";
import {
Text,
View,
type GestureResponderEvent,
type PressableStateCallbackType,
} from "react-native";
import { Text, View, type GestureResponderEvent } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useWorkspaceFileDragSource } from "@/attachments/use-workspace-file-drag-source";
import { DiffStat } from "@/components/diff-stat";
@@ -92,18 +87,17 @@ function useFileHeaderHover(enabled: boolean, onActiveChange?: (active: boolean)
return { isHovered, handlePointerEnter, handlePointerLeave };
}
// Press feedback is the pressable's `highlightStyle`, so it is not part of the active fill.
function fileHeaderIsActive(input: {
isHovered: boolean;
pressed: boolean;
showsBodyState: boolean;
isSelected: boolean;
}): boolean {
return input.isHovered || input.pressed || (!input.showsBodyState && input.isSelected);
return input.isHovered || (!input.showsBodyState && input.isSelected);
}
function fileHeaderInteractionStyle(input: {
isHovered: boolean;
pressed: boolean;
showsBodyState: boolean;
isSelected: boolean;
}) {
@@ -217,8 +211,8 @@ export const FileHeader = memo(function FileHeader({
[file.path, onHeaderHeightChange],
),
});
const pressableStyle = useCallback(
({ pressed }: PressableStateCallbackType) => [
const pressableStyle = useMemo(
() => [
styles.header,
!showsBodyState && workspaceTreeRowStyles.row,
showsBodyState && styles.documentHeader,
@@ -229,18 +223,14 @@ export const FileHeader = memo(function FileHeader({
: null,
fileHeaderInteractionStyle({
isHovered: hover.isHovered,
pressed,
showsBodyState,
isSelected,
}),
],
[depth, hover.isHovered, isSelected, showsBodyState],
);
const canvasPressableStyle = useCallback(
(state: PressableStateCallbackType) => [
pressableStyle(state),
canvasRendered && styles.canvasInteraction,
],
const canvasPressableStyle = useMemo(
() => [pressableStyle, canvasRendered && styles.canvasInteraction],
[canvasRendered, pressableStyle],
);
const accessibilityState = useMemo(
@@ -324,7 +314,7 @@ export const FileHeader = memo(function FileHeader({
} else {
trigger = (
<View
style={[pressableStyle({ pressed: false }), canvasRendered && styles.canvasInteraction]}
style={[pressableStyle, canvasRendered && styles.canvasInteraction]}
accessibilityLabel={accessibilityLabel}
>
{renderedContent}
@@ -1,5 +1,5 @@
import { useCallback, useMemo } from "react";
import { Pressable, ScrollView, Text, View, type GestureResponderEvent } from "react-native";
import { Pressable, Text, View, type GestureResponderEvent } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { ChevronDown, ChevronRight, MessageSquarePlus } from "lucide-react-native";
@@ -18,12 +18,6 @@ import { useCheckGroupState } from "./check-group-state";
const ThemedChevronDown = withUnistyles(ChevronDown);
const ThemedChevronRight = withUnistyles(ChevronRight);
/**
* Roughly eight rows. A repo with thirty checks would otherwise push the activity
* timeline off the pane entirely, so past this height the list scrolls in place.
*/
const LIST_MAX_HEIGHT = 268;
/**
* The three statuses the pane has always labelled for tests. Skipped has no id because
* nothing asserts on it.
@@ -112,11 +106,7 @@ export function ChecksSection({
</Pressable>
{open && summary.groups.length > 0 ? (
<ScrollView
style={styles.list}
contentContainerStyle={styles.listContent}
nestedScrollEnabled
>
<View style={styles.list}>
{summary.groups.map((group) => (
<CheckGroup
key={group.status}
@@ -128,7 +118,7 @@ export function ChecksSection({
onAddLogsToChat={onAddLogsToChat}
/>
))}
</ScrollView>
</View>
) : null}
</View>
);
@@ -263,9 +253,6 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
},
list: {
maxHeight: LIST_MAX_HEIGHT,
},
listContent: {
paddingBottom: theme.spacing[2],
},
groupHeader: {
@@ -0,0 +1,231 @@
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { within } from "@testing-library/dom";
import { SafeAreaInsetsContext } from "react-native-safe-area-context";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { i18n } from "@/i18n/i18next";
import { ToastApiProvider } from "@/contexts/toast-context";
import type { ToastApi } from "@/components/toast-host";
import type { PrPaneCheck, PrPaneData } from "./data";
import { PullRequestPane } from "./pane";
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
// App sources compile against the classic JSX runtime, which expects React on the global.
beforeEach(() => vi.stubGlobal("React", React));
const PANE_HEIGHT = 600;
const silentToasts: ToastApi = {
show: () => () => {},
copied: () => {},
error: () => {},
};
let root: Root | null = null;
let container: HTMLDivElement | null = null;
afterEach(() => {
act(() => root?.unmount());
container?.remove();
root = null;
container = null;
});
function successfulChecks(count: number): PrPaneCheck[] {
return Array.from({ length: count }, (_, index) => ({
provider: "github",
name: `check-${index + 1}`,
status: "success",
url: `https://example.test/checks/${index + 1}`,
}));
}
function pullRequest(checks: PrPaneCheck[]): PrPaneData {
return {
provider: { id: "github", label: "GitHub" },
forge: "github",
number: 42,
repoOwner: "acme",
repoName: "widgets",
title: "Ship the widget",
state: "open",
url: "https://example.test/pull/42",
reviewDecision: "pending",
awaitingReviewers: [],
checks,
activity: [],
};
}
/** A phone's home indicator: the pane clears it with bottom padding. */
const HOME_INDICATOR_INSETS = { top: 0, right: 0, bottom: 34, left: 0 };
const NO_INSETS = { top: 0, right: 0, bottom: 0, left: 0 };
function renderPane(data: PrPaneData, insets = NO_INSETS) {
act(() =>
root?.render(
<SafeAreaInsetsContext.Provider value={insets}>
<ToastApiProvider api={silentToasts}>
<PullRequestPane serverId="server-1" cwd="/repo" data={data} activityLoading={false} />
</ToastApiProvider>
</SafeAreaInsetsContext.Provider>,
),
);
}
function mountPane(data: PrPaneData, insets = NO_INSETS) {
container = document.createElement("div");
container.style.height = `${PANE_HEIGHT}px`;
container.style.width = "600px";
container.style.display = "flex";
container.style.flexDirection = "column";
document.body.appendChild(container);
root = createRoot(container);
renderPane(data, insets);
const pane = container.querySelector<HTMLElement>('[data-testid="pr-pane"]');
if (!pane) throw new Error("The pull request pane did not render");
return pane;
}
/** The pressable row holding the Activity title. */
function activityHeader(pane: HTMLElement): HTMLElement {
const title = within(pane).getByText(i18n.t("workspace.git.pr.sections.activity"));
const header = title.closest<HTMLElement>("[tabindex]");
if (!header) throw new Error("The Activity title is not inside a pressable header");
return header;
}
function checksRegionBottom(pane: HTMLElement): number {
return within(pane).getByRole("separator").getBoundingClientRect().top;
}
/** Whether a user sees the element: no scroll container clips it out of view. */
function isOnScreen(element: HTMLElement): boolean {
const rect = element.getBoundingClientRect();
const hit = document.elementFromPoint(rect.left + 8, rect.top + rect.height / 2);
return hit !== null && element.contains(hit);
}
/** React Native Web reports layout from a ResizeObserver, a frame or two after mounting. */
async function settleLayout() {
for (let frame = 0; frame < 2; frame++) {
await act(() => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())));
}
}
/** Drags the separator through each offset from where it starts, releasing at the last one. */
function dragSeparator(pane: HTMLElement, ...deltasY: number[]) {
const separator = within(pane).getByRole("separator");
const rect = separator.getBoundingClientRect();
const x = rect.left + rect.width / 2;
const startY = rect.top + rect.height / 2;
const pointer = { pointerId: 1, bubbles: true, cancelable: true, clientX: x, button: 0 };
act(() => {
separator.dispatchEvent(new PointerEvent("pointerdown", { ...pointer, clientY: startY }));
});
for (const deltaY of deltasY) {
act(() => {
window.dispatchEvent(
new PointerEvent("pointermove", { ...pointer, clientY: startY + deltaY }),
);
});
}
const endY = startY + (deltasY.at(-1) ?? 0);
act(() => {
window.dispatchEvent(new PointerEvent("pointerup", { ...pointer, clientY: endY }));
});
}
describe("pull request pane layout", () => {
it("docks a collapsed Activity section to the bottom and gives the checks the pane", async () => {
const pane = mountPane(pullRequest(successfulChecks(10)));
act(() => activityHeader(pane).click());
await expect
.poll(() => Math.round(activityHeader(pane).getBoundingClientRect().bottom))
.toBe(Math.round(pane.getBoundingClientRect().bottom));
const rows = within(pane).getAllByTestId("pr-pane-check-row");
expect(rows).toHaveLength(10);
expect(rows.every(isOnScreen)).toBe(true);
});
it("keeps keyboard focus on the Activity header while it collapses and expands", () => {
const pane = mountPane(pullRequest(successfulChecks(3)));
const header = activityHeader(pane);
act(() => header.focus());
act(() => header.click());
expect(document.activeElement).toBe(header);
act(() => header.click());
expect(document.activeElement).toBe(header);
});
it("exposes whether Activity is expanded to assistive technology", () => {
const pane = mountPane(pullRequest(successfulChecks(3)));
expect(activityHeader(pane).getAttribute("aria-expanded")).toBe("true");
act(() => activityHeader(pane).click());
expect(activityHeader(pane).getAttribute("aria-expanded")).toBe("false");
});
it("keeps the Activity header inside the pane when a dragged pane gets short", async () => {
const pane = mountPane(pullRequest(successfulChecks(30)));
await settleLayout();
dragSeparator(pane, PANE_HEIGHT);
await settleLayout();
act(() => {
if (container) container.style.height = "160px";
});
await settleLayout();
await expect
.poll(() => activityHeader(pane).getBoundingClientRect().bottom)
.toBeLessThanOrEqual(pane.getBoundingClientRect().bottom);
expect(isOnScreen(activityHeader(pane))).toBe(true);
});
it.each([
["5px down", [5]],
["400px down", [400]],
["up past their end and back down", [-20, 100]],
])("gives the checks their earlier share when they grow after a drag %s", async (_name, path) => {
const pane = mountPane(pullRequest(successfulChecks(2)));
await settleLayout();
const drawn = checksRegionBottom(pane);
dragSeparator(pane, ...path);
await settleLayout();
expect(checksRegionBottom(pane)).toBeCloseTo(drawn, 0);
renderPane(pullRequest(successfulChecks(30)));
await settleLayout();
const overview = within(pane).getByTestId("pr-pane-overview");
expect(overview.getBoundingClientRect().height).toBeCloseTo(PANE_HEIGHT / 2, 0);
});
it("resizes the checks against Activity by dragging the separator between them", async () => {
const pane = mountPane(pullRequest(successfulChecks(30)));
await settleLayout();
const before = checksRegionBottom(pane);
dragSeparator(pane, 120);
await expect.poll(() => Math.round(checksRegionBottom(pane) - before)).toBe(120);
expect(activityHeader(pane).getBoundingClientRect().top).toBeGreaterThan(before + 100);
});
it("keeps the separator under the pointer when the pane clears a bottom inset", async () => {
const pane = mountPane(pullRequest(successfulChecks(30)), HOME_INDICATOR_INSETS);
await settleLayout();
const before = checksRegionBottom(pane);
dragSeparator(pane, 120);
await expect.poll(() => Math.round(checksRegionBottom(pane) - before)).toBe(120);
});
});
@@ -1,5 +1,5 @@
import { useCallback, useMemo, useState } from "react";
import { Image, Pressable, ScrollView, Text, View, type ViewStyle } from "react-native";
import { Image, Pressable, Text, View, type ViewStyle } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import {
CircleCheck,
@@ -74,13 +74,14 @@ import {
type PrTimelineEntry,
} from "./timeline";
import {
Section,
SectionHeader,
SummaryPill,
dangerColorMapping,
foregroundMutedColorMapping,
sectionKitStyles,
successColorMapping,
} from "./section-kit";
import { PullRequestPaneSplit } from "./split";
const ThemedCircleCheck = withUnistyles(CircleCheck);
const ThemedCircleX = withUnistyles(CircleX);
@@ -478,8 +479,8 @@ export function PullRequestPane({
: null;
return (
<View style={styles.root} testID="pr-pane">
<ScrollView style={styles.scroll} showsVerticalScrollIndicator={false}>
<PullRequestPaneSplit activityOpen={activityOpen}>
<>
<PaneContentToolbar style={styles.toolbar} testID="pr-pane-toolbar">
<View style={styles.toolbarActions}>
<Button
@@ -499,7 +500,9 @@ export function PullRequestPane({
accessibilityLabel={
isRefreshing
? t("workspace.git.diff.refreshing")
: t("workspace.git.diff.refreshState", { brand: forgePresentation.brandLabel })
: t("workspace.git.diff.refreshState", {
brand: forgePresentation.brandLabel,
})
}
testID="pr-pane-refresh"
style={toolbarRefreshButtonStyle}
@@ -564,60 +567,58 @@ export function PullRequestPane({
onAddLogsToChat={handleAddCheckLogsToChat}
/>
)}
<View style={styles.divider} />
<Section
title={t("workspace.git.pr.sections.activity")}
open={activityOpen}
onToggle={handleToggleActivity}
summary={
<>
<SummaryPill count={approvals} icon={SUMMARY_APPROVAL_ICON} variant="success" />
<SummaryPill
count={changesRequested}
icon={SUMMARY_CHANGES_REQUESTED_ICON}
variant="danger"
</>
<SectionHeader
title={t("workspace.git.pr.sections.activity")}
open={activityOpen}
onToggle={handleToggleActivity}
summary={
<>
<SummaryPill count={approvals} icon={SUMMARY_APPROVAL_ICON} variant="success" />
<SummaryPill
count={changesRequested}
icon={SUMMARY_CHANGES_REQUESTED_ICON}
variant="danger"
/>
<SummaryPill count={commentCount} icon={SUMMARY_COMMENT_ICON} variant="muted" />
</>
}
/>
<View style={sectionKitStyles.sectionBody}>
{timelineEntries.length > 0 && attachEnabled && visibleEntries.length > 0 ? (
<View style={styles.activityToolbar}>
<Button
variant="ghost"
size="sm"
leftIcon={MessageSquarePlus}
onPress={handleAddAllToChat}
disabled={activityLoading}
>
{t("workspace.git.pr.actions.addAllToChat")}
</Button>
</View>
) : null}
{activityLoading ? <PrActivitySkeleton /> : null}
{!activityLoading && visibleEntries.length === 0 ? (
<Text style={sectionKitStyles.emptyText}>{t("workspace.git.pr.empty.noActivity")}</Text>
) : null}
{!activityLoading
? visibleEntries.map(({ entry, collapsed }) => (
<TimelineEntryCard
key={entry.id}
entry={entry}
collapsed={collapsed}
collapsedEntryIds={collapsedEntryIds}
attachEnabled={attachEnabled}
brandLabel={forgePresentation.brandLabel}
onAddToChat={handleAddActivityToChat}
onAddThreadToChat={handleAddThreadToChat}
onToggleCollapsed={handleToggleEntryCollapsed}
/>
<SummaryPill count={commentCount} icon={SUMMARY_COMMENT_ICON} variant="muted" />
</>
}
>
{timelineEntries.length > 0 && attachEnabled && visibleEntries.length > 0 ? (
<View style={styles.activityToolbar}>
<Button
variant="ghost"
size="sm"
leftIcon={MessageSquarePlus}
onPress={handleAddAllToChat}
disabled={activityLoading}
>
{t("workspace.git.pr.actions.addAllToChat")}
</Button>
</View>
) : null}
{activityLoading ? <PrActivitySkeleton /> : null}
{!activityLoading && visibleEntries.length === 0 ? (
<Text style={sectionKitStyles.emptyText}>{t("workspace.git.pr.empty.noActivity")}</Text>
) : null}
{!activityLoading
? visibleEntries.map(({ entry, collapsed }) => (
<TimelineEntryCard
key={entry.id}
entry={entry}
collapsed={collapsed}
collapsedEntryIds={collapsedEntryIds}
attachEnabled={attachEnabled}
brandLabel={forgePresentation.brandLabel}
onAddToChat={handleAddActivityToChat}
onAddThreadToChat={handleAddThreadToChat}
onToggleCollapsed={handleToggleEntryCollapsed}
/>
))
: null}
</Section>
</ScrollView>
</View>
))
: null}
</View>
</PullRequestPaneSplit>
);
}
@@ -1192,15 +1193,6 @@ function ThreadComment({
}
const styles = StyleSheet.create((theme) => ({
root: {
flex: 1,
minHeight: 0,
backgroundColor: theme.colors.surfaceSidebar,
},
scroll: {
flex: 1,
minHeight: 0,
},
invisible: {
opacity: 0,
},
@@ -1261,10 +1253,6 @@ const styles = StyleSheet.create((theme) => ({
marginLeft: theme.spacing[1],
opacity: 0,
},
divider: {
height: 1,
backgroundColor: theme.colors.border,
},
toolbar: {
flexDirection: "row",
alignItems: "center",
@@ -1,8 +1,9 @@
import React, { type ReactNode } from "react";
import React, { useMemo, type ReactNode } from "react";
import { Pressable, Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import type { CountedCheckPresentation } from "@/git/check-presentation";
import { CONTROL_HEIGHTS, mapLayout } from "@/components/ui/control-geometry";
import { isWeb } from "@/constants/platform";
import {
CheckPresentationIcon,
getCheckPresentationTone,
@@ -21,39 +22,57 @@ export const successColorMapping = (theme: Theme) => ({ color: theme.colors.stat
export const dangerColorMapping = (theme: Theme) => ({ color: theme.colors.statusDanger });
export const warningColorMapping = (theme: Theme) => ({ color: theme.colors.statusWarning });
interface SectionProps {
interface SectionHeaderProps {
title: string;
open: boolean;
onToggle: () => void;
summary: ReactNode;
children: ReactNode;
accessibilityLabel?: string;
}
export function Section({
interface SectionProps extends SectionHeaderProps {
children: ReactNode;
}
/** The disclosure row of a section, for layouts that place the section's body themselves. */
export function SectionHeader({
title,
open,
onToggle,
summary,
children,
accessibilityLabel,
}: SectionProps) {
}: SectionHeaderProps) {
const accessibilityState = useMemo(() => ({ expanded: open }), [open]);
// React Native Web does not map `accessibilityState.expanded` to `aria-expanded`.
const webExpandedProps = useMemo(
() => (isWeb ? ({ "aria-expanded": open } as const) : null),
[open],
);
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={accessibilityLabel ?? title}
accessibilityState={accessibilityState}
{...webExpandedProps}
style={sectionKitStyles.sectionHeader}
onPress={onToggle}
>
{open ? (
<ThemedChevronDown size={14} uniProps={foregroundMutedColorMapping} />
) : (
<ThemedChevronRight size={14} uniProps={foregroundMutedColorMapping} />
)}
<Text style={sectionKitStyles.sectionTitle}>{title}</Text>
<View style={sectionKitStyles.summaryWrap}>{summary}</View>
</Pressable>
);
}
export function Section({ children, ...header }: SectionProps) {
return (
<View>
<Pressable
accessibilityLabel={accessibilityLabel}
style={sectionKitStyles.sectionHeader}
onPress={onToggle}
>
{open ? (
<ThemedChevronDown size={14} uniProps={foregroundMutedColorMapping} />
) : (
<ThemedChevronRight size={14} uniProps={foregroundMutedColorMapping} />
)}
<Text style={sectionKitStyles.sectionTitle}>{title}</Text>
<View style={sectionKitStyles.summaryWrap}>{summary}</View>
</Pressable>
{open ? <View style={sectionKitStyles.sectionBody}>{children}</View> : null}
<SectionHeader {...header} />
{header.open ? <View style={sectionKitStyles.sectionBody}>{children}</View> : null}
</View>
);
}
@@ -0,0 +1,162 @@
import { Children, useCallback, useMemo, useState, type ReactNode } from "react";
import { ScrollView, View, type LayoutChangeEvent } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ResizeHandle } from "@/components/resize-handle";
/** Most of the pane the overview may take before it scrolls, until the user drags the separator. */
const DEFAULT_OVERVIEW_SHARE = 0.5;
const SPLIT_GROUP_ID = "pr-pane";
interface PullRequestPaneSplitProps {
/**
* Exactly three children, in order:
* 1. the overview (the PR's header and checks), which scrolls in its own region;
* 2. the Activity disclosure row, docked to the bottom of the pane while collapsed;
* 3. the Activity timeline, filling the space below the separator while open.
*
* Slots are children because `react-perf/jsx-no-jsx-as-prop` rejects JSX in a prop. Pass all
* three unconditionally, the way `TreeRail` takes its slots.
*/
children: ReactNode;
activityOpen: boolean;
}
/**
* Lays the pane out the way stacked sidebar sections behave: the overview and Activity share the
* height, a separator between them resizes the share, and a collapsed Activity gives its space
* back by docking its header to the bottom.
*/
export function PullRequestPaneSplit({ children, activityOpen }: PullRequestPaneSplitProps) {
const [overview, activityHeader, activityTimeline] = Children.toArray(children);
const insets = useSafeAreaInsets();
const [overviewShare, setOverviewShare] = useState(DEFAULT_OVERVIEW_SHARE);
// The pointer's share while a drag is in progress. The saved share is decided once, when the
// drag ends, so moves along the way cannot overwrite the share the overview had before it.
const [dragShare, setDragShare] = useState<number | null>(null);
const [splitHeight, setSplitHeight] = useState(0);
const [overviewHeight, setOverviewHeight] = useState(0);
const [overviewContentHeight, setOverviewContentHeight] = useState(0);
const handleSplitLayout = useCallback((event: LayoutChangeEvent) => {
setSplitHeight(event.nativeEvent.layout.height);
}, []);
const handleOverviewLayout = useCallback((event: LayoutChangeEvent) => {
setOverviewHeight(event.nativeEvent.layout.height);
}, []);
const handleOverviewContentSizeChange = useCallback((_width: number, height: number) => {
setOverviewContentHeight(height);
}, []);
const handlePreviewResize = useCallback((_groupId: string, sizes: number[]) => {
const [share] = sizes;
if (share !== undefined) setDragShare(share);
}, []);
const handleResize = useCallback(
(_groupId: string, sizes: number[]) => {
setDragShare(null);
const [share] = sizes;
if (share === undefined || splitHeight <= 0) return;
const contentShare = overviewContentHeight / splitHeight;
setOverviewShare((current) => resolveDraggedShare(share, current, contentShare));
},
[overviewContentHeight, splitHeight],
);
// The overview stops at its content height, so a drag starts from where the separator is
// drawn rather than from the share the overview may grow to.
const separatorSizes = useMemo(() => {
const share = splitHeight > 0 ? overviewHeight / splitHeight : DEFAULT_OVERVIEW_SHARE;
return [share, 1 - share];
}, [overviewHeight, splitHeight]);
// The Activity header docks to the bottom edge, so it clears the home indicator the way the
// Changes pane's docked Commits section does. The split is measured inside that padding: the
// overview's percentage height resolves against this box, so the drag must use the same one.
const rootStyle = useMemo(() => [styles.root, { paddingBottom: insets.bottom }], [insets.bottom]);
const drawnShare = dragShare ?? overviewShare;
const overviewStyle = useMemo(
() =>
activityOpen
? [styles.overviewShared, { maxHeight: `${drawnShare * 100}%` as const }]
: styles.overviewFill,
[activityOpen, drawnShare],
);
return (
<View style={rootStyle} testID="pr-pane">
<View style={styles.split} onLayout={handleSplitLayout}>
<ScrollView
style={overviewStyle}
onLayout={handleOverviewLayout}
onContentSizeChange={handleOverviewContentSizeChange}
showsVerticalScrollIndicator={false}
testID="pr-pane-overview"
>
{overview}
</ScrollView>
{activityOpen ? (
<ResizeHandle
testID="pr-pane-split-handle"
direction="vertical"
groupId={SPLIT_GROUP_ID}
index={0}
sizes={separatorSizes}
containerSize={splitHeight}
onPreviewResizeSplit={handlePreviewResize}
onResizeSplit={handleResize}
/>
) : (
<View style={styles.divider} />
)}
{/* The header keeps one slot in both states, so toggling it does not remount the row
and drop keyboard focus. Only the timeline mounts and unmounts. */}
{activityHeader}
{activityOpen ? (
<ScrollView style={styles.activityScroll} showsVerticalScrollIndicator={false}>
{activityTimeline}
</ScrollView>
) : null}
</View>
</View>
);
}
/**
* The share a drag leaves the overview. Short content stops the separator at the content's
* bottom, so a drag that ends there or below saves what was drawn and keeps any larger share
* the overview already had. The separator then neither jumps to the pointer nor stays pinned
* at the old content height when the content grows.
*/
function resolveDraggedShare(pointerShare: number, currentShare: number, contentShare: number) {
if (pointerShare < contentShare) return pointerShare;
return Math.max(currentShare, contentShare);
}
const styles = StyleSheet.create((theme) => ({
root: {
flex: 1,
minHeight: 0,
backgroundColor: theme.colors.surfaceSidebar,
},
split: {
flex: 1,
minHeight: 0,
},
overviewShared: {
flexGrow: 0,
flexShrink: 1,
minHeight: 0,
},
overviewFill: {
flex: 1,
minHeight: 0,
},
// Same 1px line the separator draws, so collapsing Activity does not move the boundary's look.
divider: {
height: 1,
backgroundColor: theme.colors.border,
},
activityScroll: {
flex: 1,
minHeight: 0,
},
}));
+3 -11
View File
@@ -291,21 +291,13 @@ function useWorkspaceScreenArchiveController({
serverId,
workspaceId: workspaceDescriptor?.id ?? "",
workspaceKind: workspaceDescriptor?.workspaceKind ?? "directory",
workspaceDirectory: workspaceDescriptor?.workspaceDirectory ?? "",
name: workspaceDescriptor?.name ?? branchLabel,
isDirty: archiveRisk.isDirty,
aheadOfOrigin: archiveRisk.aheadOfOrigin,
diffStat: workspaceDescriptor?.diffStat ?? null,
warningLabels: getWorktreeArchiveWarningLabels(t),
onSetHiding: setIsHidingWorkspace,
onArchiveStarted: () => {
if (!activeWorkspaceSelection) {
return;
}
redirectIfArchivingActiveWorkspace({
serverId,
workspaceId: activeWorkspaceSelection.workspaceId,
activeWorkspaceSelection,
});
onArchiveStarted: (archived) => {
redirectIfArchivingActiveWorkspace({ ...archived, activeWorkspaceSelection });
},
});
@@ -1,11 +1,87 @@
import { describe, expect, it } from "vitest";
import type { CheckoutStatusClient, CheckoutStatusPayload } from "@/git/checkout-status-cache";
import {
buildWorktreeArchiveConfirmationMessage,
buildWorktreeArchiveRiskReasons,
readWorktreeArchiveRisk,
toWorktreeArchiveRisk,
} from "@/git/worktree-archive-warning";
const WORKTREE = "/home/me/.paseo/worktrees/repo/feature";
function hostAnswering(status: CheckoutStatusPayload): CheckoutStatusClient & { asked: string[] } {
const asked: string[] = [];
return {
asked,
getCheckoutStatus: async (cwd) => {
asked.push(cwd);
return status;
},
};
}
const NOT_GIT = {
cwd: WORKTREE,
requestId: "status",
isGit: false,
isPaseoOwnedWorktree: false,
repoRoot: null,
currentBranch: null,
isDirty: null,
baseRef: null,
aheadBehind: null,
aheadOfOrigin: null,
behindOfOrigin: null,
hasRemote: false,
remoteUrl: null,
error: null,
} satisfies CheckoutStatusPayload;
describe("reading a worktree's archive risk from its host", () => {
it("reads uncommitted changes and unpushed commits from the worktree's checkout status", async () => {
const host = hostAnswering({
cwd: WORKTREE,
requestId: "status",
isGit: true,
isPaseoOwnedWorktree: true,
repoRoot: WORKTREE,
mainRepoRoot: "/home/me/repo",
currentBranch: "feature",
isDirty: true,
baseRef: "main",
aheadBehind: { ahead: 2, behind: 0 },
aheadOfOrigin: 2,
behindOfOrigin: 0,
hasRemote: true,
remoteUrl: "git@example.com:me/repo.git",
error: null,
});
expect(await readWorktreeArchiveRisk(host, WORKTREE)).toEqual({
isDirty: true,
aheadOfOrigin: 2,
});
expect(host.asked).toEqual([WORKTREE]);
});
it("finds nothing to lose in a directory that is no longer a git checkout", async () => {
expect(await readWorktreeArchiveRisk(hostAnswering(NOT_GIT), WORKTREE)).toEqual({
isDirty: false,
aheadOfOrigin: null,
});
});
it("rejects with the host's error when it can't read the worktree", async () => {
const host = hostAnswering({
...NOT_GIT,
error: { code: "UNKNOWN", message: "git status failed" },
});
await expect(readWorktreeArchiveRisk(host, WORKTREE)).rejects.toThrow("git status failed");
});
});
describe("workspace archive warning for worktree backing", () => {
it("does not require a confirmation for clean and pushed worktrees", () => {
expect(
@@ -1,3 +1,4 @@
import type { CheckoutStatusClient } from "@/git/checkout-status-cache";
import { confirmDialog } from "@/utils/confirm-dialog";
import { i18n } from "@/i18n/i18next";
@@ -49,6 +50,21 @@ export const DEFAULT_WORKTREE_ARCHIVE_WARNING_LABELS: WorktreeArchiveWarningLabe
: i18n.t("workspace.git.actions.archiveWarning.unpushedCommits", { count }),
};
/**
* Reads the worktree's uncommitted changes and unpushed commits from its host. A directory the
* host no longer reads as a git checkout has neither. Rejects when the host can't read it.
*/
export async function readWorktreeArchiveRisk(
client: CheckoutStatusClient,
workspaceDirectory: string,
): Promise<Pick<WorktreeArchiveRisk, "isDirty" | "aheadOfOrigin">> {
const status = await client.getCheckoutStatus(workspaceDirectory);
if (status.error) {
throw new Error(status.error.message);
}
return { isDirty: status.isDirty ?? false, aheadOfOrigin: status.aheadOfOrigin };
}
export function toWorktreeArchiveRisk(input: WorktreeArchiveRiskInput): WorktreeArchiveRisk {
return {
isDirty: input.archiveHasUncommittedChanges,
@@ -487,10 +487,7 @@ function LocalModelRow({
}
const rowStyle = useCallback(
({ hovered, pressed }: HoverState) => [
!selectionLocked && hovered && styles.rowHovered,
!selectionLocked && pressed && styles.rowPressed,
],
({ hovered }: HoverState) => [!selectionLocked && hovered && styles.rowHovered],
[selectionLocked],
);
const accessibilityState = useMemo(
+3 -2
View File
@@ -113,8 +113,8 @@ function pinnedUsageLabel(
return `${label}: ${summary}${error}`;
}
function triggerStyle({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) {
return [styles.trigger, hovered && styles.triggerHovered, pressed && styles.triggerPressed];
function triggerStyle({ hovered }: PressableStateCallbackType & { hovered?: boolean }) {
return [styles.trigger, hovered && styles.triggerHovered];
}
function layoutWidth(event: LayoutChangeEvent): number {
@@ -161,6 +161,7 @@ function PinnedUsageTrigger({
accessibilityRole="button"
accessibilityLabel={pinnedUsageLabel(label, sources, errorLabel)}
style={triggerStyle}
highlightStyle={styles.triggerPressed}
testID="sidebar-usage"
>
<View style={styles.line} onLayout={handleAvailableLayout}>
@@ -1,17 +1,21 @@
import { useCallback } from "react";
import { useCallback, useRef } from "react";
import { useTranslation } from "react-i18next";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { useToast } from "@/contexts/toast-context";
import {
confirmRiskyWorktreeArchive,
DEFAULT_WORKTREE_ARCHIVE_WARNING_LABELS,
readWorktreeArchiveRisk,
type WorktreeArchiveWarningLabels,
} from "@/git/worktree-archive-warning";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import { archiveWorkspaceWithUndo, supportsWorkspaceArchiveUndo } from "@/workspace/archive-undo";
import { archiveWorkspaceOptimistically } from "@/workspace/workspace-archive";
import {
archiveWorkspaceOptimistically,
type WorkspaceArchiveTarget,
} from "@/workspace/workspace-archive";
function purgeArchivedWorkspaceState(input: { serverId: string; workspaceId: string }): void {
const workspaceKey = buildWorkspaceTabPersistenceKey(input);
@@ -24,12 +28,13 @@ export interface ArchiveWorkspaceInput {
serverId: string;
workspaceId: string;
workspaceKind: WorkspaceDescriptor["workspaceKind"];
workspaceDirectory: string;
name: string;
isDirty?: boolean | null;
aheadOfOrigin?: number | null;
/** Labels the warning's line counts. Whether to warn comes from the host's git state. */
diffStat?: { additions: number; deletions: number } | null;
warningLabels?: WorktreeArchiveWarningLabels;
onArchiveStarted: () => void;
/** Runs once the archive starts, with the workspace being archived. */
onArchiveStarted: (workspace: WorkspaceArchiveTarget) => void;
onSetHiding?: (hiding: boolean) => void;
}
@@ -42,9 +47,8 @@ export function useWorkspaceArchive(input: ArchiveWorkspaceInput): WorkspaceArch
serverId,
workspaceId,
workspaceKind,
workspaceDirectory,
name,
isDirty,
aheadOfOrigin,
diffStat,
warningLabels = DEFAULT_WORKTREE_ARCHIVE_WARNING_LABELS,
onArchiveStarted,
@@ -52,6 +56,11 @@ export function useWorkspaceArchive(input: ArchiveWorkspaceInput): WorkspaceArch
} = input;
const { t } = useTranslation();
const toast = useToast();
const confirmingRef = useRef(false);
// The archive starts after the host answers and you confirm. By then you may have opened another
// workspace, so the redirect reads the selection from the latest render.
const onArchiveStartedRef = useRef(onArchiveStarted);
onArchiveStartedRef.current = onArchiveStarted;
const archiveWorkspaceRecord = useCallback(async () => {
const client = getHostRuntimeStore().getClient(serverId);
@@ -62,7 +71,7 @@ export function useWorkspaceArchive(input: ArchiveWorkspaceInput): WorkspaceArch
const workspace = { serverId, workspaceId };
onSetHiding?.(true);
try {
onArchiveStarted();
onArchiveStartedRef.current(workspace);
if (supportsWorkspaceArchiveUndo(serverId)) {
await archiveWorkspaceWithUndo({
client,
@@ -82,35 +91,54 @@ export function useWorkspaceArchive(input: ArchiveWorkspaceInput): WorkspaceArch
} finally {
onSetHiding?.(false);
}
}, [onArchiveStarted, onSetHiding, serverId, t, toast, workspaceId]);
}, [onSetHiding, serverId, t, toast, workspaceId]);
// The host removes a worktree it archives, uncommitted work included. Ask it what that would
// lose: the sidebar row's git status is empty until the row receives it.
const confirmWorktreeArchive = useCallback(async (): Promise<boolean> => {
const client = getHostRuntimeStore().getClient(serverId);
if (!client) {
toast.error(t("sidebar.workspace.toasts.hostDisconnected"));
return false;
}
const risk = await readWorktreeArchiveRisk(client, workspaceDirectory).catch(
(error: unknown) => {
toast.error(
error instanceof Error ? error.message : t("sidebar.workspace.toasts.archiveFailed"),
);
return null;
},
);
if (!risk) {
return false;
}
return await confirmRiskyWorktreeArchive(
{ workspaceName: name, diffStat, ...risk },
warningLabels,
);
}, [diffStat, name, serverId, t, toast, warningLabels, workspaceDirectory]);
const archive = useCallback(() => {
// A second press while the host answers or the warning is open would ask twice.
if (confirmingRef.current) {
return;
}
void (async () => {
if (workspaceKind === "worktree") {
const confirmed = await confirmRiskyWorktreeArchive(
{
workspaceName: name,
isDirty,
aheadOfOrigin,
diffStat,
},
warningLabels,
);
confirmingRef.current = true;
let confirmed: boolean;
try {
confirmed = await confirmWorktreeArchive();
} finally {
confirmingRef.current = false;
}
if (!confirmed) {
return;
}
}
await archiveWorkspaceRecord();
})();
}, [
aheadOfOrigin,
archiveWorkspaceRecord,
diffStat,
isDirty,
name,
warningLabels,
workspaceKind,
]);
}, [archiveWorkspaceRecord, confirmWorktreeArchive, workspaceKind]);
return {
archive,
@@ -1,7 +1,8 @@
import { createElement, type ElementType } from "react";
import { lightHighlightColors } from "@getpaseo/highlight";
import { lightTheme } from "../src/styles/theme";
const testTheme = {
const pinnedTheme = {
colorScheme: "light",
colors: {
syntax: lightHighlightColors,
@@ -86,6 +87,14 @@ const testTheme = {
},
};
// Tokens a test does not pin come from the real light theme, so a style or `uniProps` mapping
// that reads one (the markdown renderer reads `colors.terminal`) gets a value instead of crashing.
const testTheme = {
...lightTheme,
...pinnedTheme,
colors: { ...lightTheme.colors, ...pinnedTheme.colors },
};
type StyleFactory<T> = (theme: typeof testTheme) => T;
function isStyleFactory<T>(styles: T | StyleFactory<T>): styles is StyleFactory<T> {
@@ -142,7 +151,19 @@ export const StyleSheet = {
resolveStyles(isStyleFactory(styles) ? styles(testTheme) : styles),
};
export const withUnistyles = <T>(Component: T): T => Component;
type UniPropsMapping = (theme: typeof testTheme, runtime: object) => Record<string, unknown>;
/** Applies `uniProps` with the test theme, the way Unistyles maps theme values onto props. */
export const withUnistyles = <T>(Component: T): T => {
function ThemedComponent({ uniProps, ...props }: { uniProps?: UniPropsMapping }) {
// Explicit props win over mapped ones, as in Unistyles' `deepMergeObjects(mappings, uniProps, props)`.
return createElement(Component as ElementType, {
...(uniProps ? uniProps(testTheme, {}) : {}),
...props,
});
}
return ThemedComponent as T;
};
export const useUnistyles = () => ({
theme: testTheme,
+7
View File
@@ -236,6 +236,8 @@ interface AgentLike {
title?: string | null;
}
const FULL_AGENT_UUID_PATTERN = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i;
/**
* Resolve an agent ID from a partial ID or name.
* Supports:
@@ -252,6 +254,11 @@ export function resolveAgentId(idOrName: string, agents: AgentLike[]): string |
const query = idOrName.toLowerCase();
// A complete agent UUID is an exact identity, never a name search fallback.
if (FULL_AGENT_UUID_PATTERN.test(idOrName)) {
return agents.find((a) => a.id.toLowerCase() === query)?.id ?? null;
}
// Try exact ID match first
const exactMatch = agents.find((a) => a.id === idOrName);
if (exactMatch) {
@@ -18,25 +18,52 @@ const agents = [
}
{
console.log("Test 2: ID prefix resolves");
console.log("Test 2: full UUID resolves exactly, case-insensitively");
const exactId = "9eac296c-4d60-4189-a0b5-ae71dd9c6ad9";
const agentsWithNameCollision = [
{ id: exactId, title: "target agent" },
{
id: "c9a97e5d-4a92-4646-a016-53032146d0a1",
title: `worker ${exactId}`,
},
];
assert.strictEqual(resolveAgentId(exactId.toUpperCase(), agentsWithNameCollision), exactId);
console.log("✓ full UUID resolves exactly, case-insensitively\n");
}
{
console.log("Test 3: missing full UUID does not match another agent name");
const missingId = "9eac296c-4d60-4189-a0b5-ae71dd9c6ad9";
const nameCollision = [
{
id: "c9a97e5d-4a92-4646-a016-53032146d0a1",
title: `worker ${missingId}`,
},
];
assert.strictEqual(resolveAgentId(missingId, nameCollision), null);
console.log("✓ missing full UUID does not match another agent name\n");
}
{
console.log("Test 4: ID prefix resolves");
assert.strictEqual(resolveAgentId("def5678", agents), "def5678-other-agent-id");
console.log("✓ ID prefix resolves\n");
}
{
console.log("Test 3: exact name match resolves case-insensitively");
console.log("Test 5: exact name match resolves case-insensitively");
assert.strictEqual(resolveAgentId("fix archive flow", agents), "def5678-other-agent-id");
console.log("✓ exact name match resolves case-insensitively\n");
}
{
console.log("Test 4: partial name match resolves when unique");
console.log("Test 6: partial name match resolves when unique");
assert.strictEqual(resolveAgentId("review", agents), "987zyxw-third-agent-id");
console.log("✓ partial name match resolves when unique\n");
}
{
console.log("Test 5: missing agent returns null");
console.log("Test 7: missing agent returns null");
assert.strictEqual(resolveAgentId("does not exist", agents), null);
console.log("✓ missing agent returns null\n");
}
@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import { ProviderInputSchema, type ProviderInput } from "./provider.js";
type ReviewMode = "uncommitted" | "base" | "all";
type Delivery = "auto" | "steer";
const contextLine = {
oldLineNumber: null,
newLineNumber: 12,
type: "add",
content: "const value = 1;",
} as const;
function reviewPrompt(mode: ReviewMode, delivery: Delivery): ProviderInput {
return {
type: "session.prompt",
sessionId: "session-1",
prompt: {
clientMessageId: "message-1",
delivery,
input: {
type: "message",
content: [
{
type: "review",
mimeType: "application/paseo-review",
cwd: "/workspace",
mode,
baseRef: "main",
comments: [
{
filePath: "src/index.ts",
side: "new",
lineNumber: 12,
body: "Please review this change.",
context: {
hunkHeader: "@@ -10,2 +10,3 @@",
targetLine: contextLine,
lines: [{ ...contextLine, type: "context", oldLineNumber: 11 }, contextLine],
},
},
],
},
],
},
},
};
}
describe("Provider review attachments", () => {
it.each(["uncommitted", "base", "all"] as const)(
"preserves mode %s on an ordinary prompt",
(mode) => {
const input = reviewPrompt(mode, "auto");
expect(ProviderInputSchema.parse(input)).toEqual(input);
},
);
it.each(["uncommitted", "base", "all"] as const)(
"preserves mode %s on a steering prompt",
(mode) => {
const input = reviewPrompt(mode, "steer");
expect(ProviderInputSchema.parse(input)).toEqual(input);
},
);
});
+2 -2
View File
@@ -213,7 +213,7 @@ interface ProviderReviewAttachment {
type: "review";
mimeType: "application/paseo-review";
cwd: string;
mode: "uncommitted" | "base";
mode: "uncommitted" | "base" | "all";
baseRef?: string | null;
comments: Array<{
filePath: string;
@@ -881,7 +881,7 @@ const providerContentSchema: z.ZodType<ProviderContent> = z.union([
type: z.literal("review"),
mimeType: z.literal("application/paseo-review"),
cwd: z.string(),
mode: z.enum(["uncommitted", "base"]),
mode: z.enum(["uncommitted", "base", "all"]),
baseRef: z.string().nullable().optional(),
comments: z.array(
z
@@ -3,6 +3,7 @@ import { buildAgentForkContextAttachment, curateAgentActivity } from "./activity
import type { AgentTimelineItem } from "./agent-sdk-types.js";
import type { AgentTimelineRow } from "./agent-timeline-store-types.js";
import { formatAgentMessage, projectAgentMessages } from "./agent-messages/index.js";
import { InMemoryAgentTimelineStore } from "./agent-timeline-store.js";
function toolCallItem(params: {
callId: string;
@@ -39,6 +40,29 @@ function row(seq: number, item: AgentTimelineItem): AgentTimelineRow {
};
}
/** Rows as the daemon hands them to a fork: projected by the timeline store. */
function storeTimeline(items: readonly AgentTimelineItem[]) {
const store = new InMemoryAgentTimelineStore();
store.initialize("agent-1");
for (const item of items) store.append("agent-1", item);
const { epoch, rows } = store.fetch("agent-1", { limit: 0 });
return { epoch, rows };
}
function backgroundTaskItem(input: { status: "running" | "completed"; log: string }) {
return toolCallItem({
callId: "background-task",
name: "Task",
status: input.status,
detail: {
type: "sub_agent",
subAgentType: "Explore",
description: "Audit the migrations",
log: input.log,
},
});
}
describe("curateAgentActivity", () => {
it("keeps reasoning before the assistant text that followed it", () => {
const timeline: AgentTimelineItem[] = [
@@ -414,44 +438,48 @@ second line'`,
expect(result.attachment.text).toContain("[Assistant] Done.");
});
it("rejects a checkpoint whose projected tool state changed later", () => {
expect(() =>
buildAgentForkContextAttachment({
boundaryMessageId: "assistant-1",
rows: [
row(1, { type: "user_message", text: "Run it", messageId: "user-1" }),
row(
2,
toolCallItem({
callId: "terminal-1",
name: "terminal",
status: "running",
detail: {
type: "plain_text",
label: "before boundary",
},
}),
),
row(3, {
type: "assistant_message",
text: "Partial result.",
messageId: "assistant-1",
}),
row(
4,
toolCallItem({
callId: "terminal-1",
name: "terminal",
status: "completed",
detail: {
type: "plain_text",
label: "after boundary",
},
}),
),
],
}),
).toThrow("Fork from a later completed response");
it("forks at a response that background work outlives, by message id", () => {
const { rows } = storeTimeline([
{ type: "user_message", text: "Audit in the background", messageId: "user-1" },
backgroundTaskItem({ status: "running", log: "scanning" }),
{ type: "assistant_message", text: "Audit started.", messageId: "assistant-1" },
backgroundTaskItem({ status: "completed", log: "FINAL REPORT after the response" }),
{ type: "user_message", text: "Any findings?", messageId: "user-2" },
]);
expect(rows[1]).toMatchObject({ seqStart: 2, seqEnd: 4 });
const result = buildAgentForkContextAttachment({ boundaryMessageId: "assistant-1", rows });
expect(result.itemCount).toBe(3);
expect(result.boundaryMessageId).toBe("assistant-1");
expect(result.attachment.text).toContain(
"[User] Audit in the background\n[Explore] Audit the migrations\n[Assistant] Audit started.\n",
);
expect(result.attachment.text).not.toContain("FINAL REPORT");
expect(result.attachment.text).not.toContain("Any findings?");
});
it("forks at a response that background work outlives, by timeline cursor", () => {
const { epoch, rows } = storeTimeline([
{ type: "user_message", text: "Audit in the background", messageId: "user-1" },
backgroundTaskItem({ status: "running", log: "scanning" }),
{ type: "assistant_message", text: "Audit started." },
backgroundTaskItem({ status: "completed", log: "FINAL REPORT after the response" }),
{ type: "assistant_message", text: "The audit finished." },
]);
const result = buildAgentForkContextAttachment({
cursorBoundary: { timelineEpoch: epoch, cursor: { epoch, seq: 3 } },
rows,
});
expect(result.boundaryCursor).toEqual({ epoch, seq: 3 });
expect(result.itemCount).toBe(3);
expect(result.attachment.text).toContain(
"[User] Audit in the background\n[Explore] Audit the migrations\n[Assistant] Audit started.\n",
);
expect(result.attachment.text).not.toContain("FINAL REPORT");
expect(result.attachment.text).not.toContain("The audit finished.");
});
it("forks from a checkpoint when a plugin card was updated after it", () => {
@@ -1,4 +1,4 @@
import type { AgentTimelineItem } from "./agent-sdk-types.js";
import type { AgentTimelineItem, ToolCallTimelineItem } from "./agent-sdk-types.js";
import type { AgentMessage } from "@getpaseo/protocol/agent-message";
import type { AgentAttachment } from "@getpaseo/protocol/messages";
import type { AgentTimelineRow } from "./agent-timeline-store-types.js";
@@ -13,7 +13,7 @@ const MAX_TOOL_SUMMARY_CHARS = 200;
const MAX_FORK_CONTEXT_CHARS = 100_000;
const MAX_FORK_METADATA_CHARS = 1_000;
const FORK_HISTORY_OMITTED = "[Earlier chat history omitted]\n";
// A fork copies only these kinds, so only they can make a checkpoint stale.
// A fork copies only these kinds.
const FORK_CONTEXT_KINDS: readonly AgentTimelineItem["type"][] = [
"user_message",
"assistant_message",
@@ -275,25 +275,25 @@ function selectForkContextRows(input: {
);
}
const boundarySeq = projectedRows[boundaryIndex].seqEnd;
if (
projectedRows.some(
(row) =>
FORK_CONTEXT_KINDS.includes(row.item.type) &&
row.seqStart <= boundarySeq &&
row.seqEnd > boundarySeq,
)
) {
throw new Error(
"This checkpoint changed after it was created. Fork from a later completed response instead.",
);
}
const projected = projectedRows.filter((row) => row.seqEnd <= boundarySeq);
// Only tool calls and plugin cards fold later updates into an earlier row, and a fork doesn't
// copy plugin cards. So the only row that spans the checkpoint and still reaches the fork is a
// tool call.
const items = projectedRows.flatMap((row) => {
if (row.seqEnd <= boundarySeq) return [row.item];
if (row.seqStart > boundarySeq || row.item.type !== "tool_call") return [];
return [toolCallAtCheckpoint(row.item)];
});
return {
items: projected.map((entry) => entry.item),
boundaryCursor,
boundaryMessageId,
};
return { items, boundaryCursor, boundaryMessageId };
}
/**
* A tool call that started before the checkpoint and changed after it, such as background work
* that outlives a reply. The projection keeps only its latest payload, so the fork copies it as
* still running.
*/
function toolCallAtCheckpoint(item: ToolCallTimelineItem): ToolCallTimelineItem {
return { ...item, status: "running", error: null };
}
function trimContextMetadata(value: string | null | undefined): string | null {
@@ -13457,7 +13457,20 @@ function idleCleanupHarness() {
const storage = new AgentStorage(join(workdir, "agents"), logger);
const commands: AgentPromptInput[] = [];
const prompts: AgentPromptInput[] = [];
const sessions: AgentSession[] = [];
const probes: Array<(() => Promise<boolean>) | undefined> = [];
const closeEntered = deferred<void>();
let closeGate: Promise<void> | undefined;
let closes = 0;
const recordPrompts = (session: AgentSession) => {
const index = sessions.length;
session.canStopRuntime = async () => probes[index]?.() ?? false;
session.close = async () => {
closes++;
closeEntered.resolve();
await closeGate;
};
sessions.push(session);
session.tryHandleOutOfBand = (prompt) =>
prompt === "/maintenance"
? {
@@ -13473,11 +13486,6 @@ function idleCleanupHarness() {
};
return session;
};
const sessions: AgentSession[] = [];
const probes: Array<(() => Promise<boolean>) | undefined> = [];
const closeEntered = deferred<void>();
let closeGate: Promise<void> | undefined;
let closes = 0;
const client = new (class extends TestAgentClient {
override async resumeSession(
handle: AgentPersistenceHandle,
@@ -13488,15 +13496,7 @@ function idleCleanupHarness() {
return recordPrompts(session);
}
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
const index = sessions.length;
const session: AgentSession = await super.createSession(config);
session.canStopRuntime = async () => probes[index]?.() ?? false;
session.close = async () => {
closes++;
closeEntered.resolve();
await closeGate;
};
sessions.push(session);
return recordPrompts(session);
}
})();
@@ -14419,3 +14419,39 @@ test("workspace archive preserves a loaded history agent stamp and stamps cascad
rmSync(workdir, { recursive: true, force: true });
}
});
test("idle cleanup gives a newly restored runtime a fresh idle window while keeping historical updatedAt", async () => {
const h = idleCleanupHarness();
const created = await h.create();
h.probes[0] = async () => true;
h.probes[1] = async () => true;
await h.manager.closeAgent(created.id);
await h.storage.flush();
const historicalUpdatedAt = new Date(Date.now() - 180 * 60_000);
const existingRecord = await h.storage.get(created.id);
expect(existingRecord).not.toBeNull();
const staleRecord: StoredAgentRecord = {
...existingRecord!,
updatedAt: historicalUpdatedAt.toISOString(),
lastActivityAt: historicalUpdatedAt.toISOString(),
};
await h.storage.upsert(staleRecord);
await h.storage.flush();
const closesBeforeRestore = h.closes;
const restored = await h.manager.resumeAgentFromPersistence(
created.persistence!,
{ cwd: created.cwd },
created.id,
{ updatedAt: historicalUpdatedAt },
);
expect(restored.updatedAt.getTime()).toBe(historicalUpdatedAt.getTime());
// Sweeping 1 minute after restoring an old agent must NOT immediately kill the newly opened runtime.
expect((await h.sweep({ now: h.later(1) })).collected).toEqual([]);
expect(h.closes).toBe(closesBeforeRestore);
// Sweeping 61 minutes after restoration collects the runtime as expected.
expect((await h.sweep({ now: h.later(61) })).collected).toHaveLength(1);
expect(h.closes).toBe(closesBeforeRestore + 1);
});
@@ -856,7 +856,10 @@ export class AgentManager {
private readonly inFlightAgentCloses = new Map<string, Promise<void>>();
private readonly historySessions = new WeakSet<AgentSession>();
private readonly reloadedSessionCloses = new WeakMap<AgentSession, Promise<void>>();
private readonly runtimeCleanupIdentities = new WeakMap<AgentSession, object>();
private readonly runtimeCleanupIdentities = new WeakMap<
AgentSession,
{ readonly openedAt: number }
>();
private readonly collectingRuntimes = new Set<string>();
private readonly lifecycleMutationTails = new Map<string, Promise<void>>();
private readonly agentStreamCoalescer: AgentStreamCoalescer;
@@ -2068,7 +2071,7 @@ export class AgentManager {
const updatedAt = agent.updatedAt;
let identity = this.runtimeCleanupIdentities.get(agent.session);
if (!identity) {
identity = Object.freeze({});
identity = Object.freeze({ openedAt: Date.now() });
this.runtimeCleanupIdentities.set(agent.session, identity);
}
const isEligible = () =>
@@ -2081,7 +2084,7 @@ export class AgentManager {
parentAgentId: getParentAgentIdFromLabels(agent.labels) ?? undefined,
provider: agent.provider,
sessionId: agent.persistence?.sessionId ?? null,
lastActivityAt: updatedAt.getTime(),
lastActivityAt: Math.max(updatedAt.getTime(), identity.openedAt),
isEligible,
canStopRuntime: agent.session.canStopRuntime?.bind(agent.session),
closeIfEligible: async (revalidate) => {
@@ -4342,6 +4345,7 @@ export class AgentManager {
}
this.assertAcceptingAgentRegistrations();
this.runtimeCleanupIdentities.set(session, Object.freeze({ openedAt: Date.now() }));
this.agents.set(resolvedAgentId, managed);
registered = true;
// Initialize previousStatus to track transitions
@@ -210,6 +210,8 @@ export async function unarchiveAgentState(
}
export interface SendPromptToAgentParams {
/** Awaited after the agent is loaded and its mode is set, before the prompt is handed to a run. */
beforeDispatch?: () => Promise<void>;
onDispatch?: StartAgentRunOptions["onDispatch"];
agentManager: AgentManager;
agentStorage: AgentStorage;
@@ -357,6 +359,7 @@ export async function sendPreparedPromptToAgent(
await params.agentManager.setAgentMode(params.agentId, params.sessionMode);
}
await params.beforeDispatch?.();
return await startAgentRun(params.agentManager, params.agentId, params.prompt, params.logger, {
replaceRunning: true,
onDispatch: params.onDispatch,
@@ -496,6 +496,58 @@ async function pluginMessages(session: Awaited<ReturnType<AgentClient["createSes
);
}
test("plugin rewind discards child outcomes from the withdrawn branch", async () => {
await withPluginRewindSession(
{
async handleInput(input, emit) {
if (input.type !== "session.open" || input.history !== "replay") return false;
emit({
type: "session.opened",
requestId: input.requestId,
sessionId: input.sessionId,
capabilities: CAPABILITIES,
restoration: "core",
persistence: input.persistence,
cwd: input.config.cwd,
});
emit({
type: "session.opened",
sessionId: "rewound-child",
parentSessionId: input.sessionId,
capabilities: [],
restoration: "parent",
cwd: input.config.cwd,
});
emit({ type: "session.closed", sessionId: "rewound-child" });
emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId });
return true;
},
},
async (session, harness) => {
const open = harness.inputs.find((input) => input.type === "session.open")!;
harness.emit({
type: "session.opened",
sessionId: "rewound-child",
parentSessionId: open.sessionId,
capabilities: [],
restoration: "parent",
cwd: tmpdir(),
});
harness.emit({
type: "session.turn",
sessionId: "rewound-child",
turnId: "withdrawn-turn",
state: "failed",
});
await session.revertConversation!({ messageId: "user-B" });
const history = await Array.fromAsync(session.streamHistory());
expect(history.findLast((event) => event.type === "provider_subagent")).toMatchObject({
event: { type: "upsert", id: "rewound-child", status: "completed" },
});
},
);
});
test("combined plugin rewind replays the retained native branch", async () => {
await withPluginRewindSession({}, async (session) => {
await session.revertBoth!({ messageId: "user-B" });
@@ -1690,6 +1742,11 @@ describe("PluginAgentClientRegistry", () => {
expect(manager.listProviderSubagents(agent.id)).toMatchObject([
{ id: child.id, title: child.title, status: outcome },
]);
harness.emit({ type: "session.closed", sessionId: "reusable-child" });
await nextTurn();
expect(manager.listProviderSubagents(agent.id)).toMatchObject([
{ id: child.id, title: child.title, status: outcome },
]);
} finally {
await Promise.all(manager.listAgents().map((agent) => manager.closeAgent(agent.id)));
await plugins.shutdown();
@@ -1724,6 +1781,7 @@ describe("PluginAgentClientRegistry", () => {
turnId: "second-turn",
state,
});
emit({ type: "session.closed", sessionId: "reusable-child" });
}
},
});
@@ -1750,6 +1808,100 @@ describe("PluginAgentClientRegistry", () => {
}
},
);
test("keeps a child's canceled or failed turn when the child closes normally", async () => {
const harness = createProviderHarness();
const registry = new PluginAgentClientRegistry(createTestLogger());
registry.replace([harness.registration]);
const session = await registry.clients()[harness.registration.id]!.createSession({
provider: harness.registration.id,
cwd: "/workspace",
});
try {
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
const open = harness.inputs.find((input) => input.type === "session.open")!;
const outcomes = [
["child-canceled", "canceled"],
["child-failed", "failed"],
["child-completed", "completed"],
] as const;
for (const [sessionId, state] of outcomes) {
harness.emit({
type: "session.opened",
sessionId,
parentSessionId: open.sessionId,
capabilities: [],
restoration: "parent",
cwd: "/workspace",
});
harness.emit({ type: "session.turn", sessionId, turnId: "turn", state: "started" });
harness.emit({ type: "session.turn", sessionId, turnId: "turn", state });
harness.emit({ type: "session.closed", sessionId });
}
const lastStatus = (id: string) =>
events
.flatMap((event) =>
event.type === "provider_subagent" &&
event.event.type === "upsert" &&
event.event.id === id
? [event.event.status]
: [],
)
.at(-1);
expect(lastStatus("child-canceled")).toBe("canceled");
expect(lastStatus("child-failed")).toBe("failed");
expect(lastStatus("child-completed")).toBe("completed");
} finally {
await session.close();
registry.replace([]);
}
});
test.each([
{ turns: [], error: false, expected: "completed" },
{ turns: ["canceled", "started"], error: false, expected: "completed" },
{ turns: ["failed", "started", "completed"], error: false, expected: "completed" },
{ turns: ["canceled"], error: true, expected: "failed" },
] as const)(
"closes a plugin child using its latest turn: $turns, error=$error",
async ({ turns, error, expected }) => {
const harness = createProviderHarness();
const registry = new PluginAgentClientRegistry(createTestLogger());
registry.replace([harness.registration]);
try {
const session = await registry.clients()[harness.registration.id]!.createSession({
provider: harness.registration.id,
cwd: tmpdir(),
});
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
const open = harness.inputs.find((input) => input.type === "session.open")!;
harness.emit({
type: "session.opened",
sessionId: "close-control",
parentSessionId: open.sessionId,
capabilities: [],
restoration: "parent",
cwd: tmpdir(),
});
for (const state of turns) {
harness.emit({ type: "session.turn", sessionId: "close-control", turnId: "turn", state });
}
harness.emit({
type: "session.closed",
sessionId: "close-control",
...(error ? { error: { message: "Provider failed to close" } } : {}),
});
expect(events.at(-1)).toMatchObject({
type: "provider_subagent",
event: { type: "upsert", id: "close-control", status: expected },
});
} finally {
await registry.shutdown();
}
},
);
test("gates persistence operations on negotiated provider capabilities", async () => {
const harness = createProviderHarness({ capabilities: ["session.persistence"] });
@@ -1202,6 +1202,8 @@ class PluginAgentSession implements AgentSession {
{ session: ProviderRuntimeSession; unsubscribe: () => void }
>();
private readonly childSnapshots = new Map<string, Map<string, TimelineTextSnapshot>>();
// Outcome of each child's latest finished turn, so a plain close does not report it as completed.
private readonly childTurnOutcomes = new Map<string, "completed" | "canceled" | "failed">();
private unsubscribe: (() => void) | null = null;
/** How the opened session differs from the options it was given. */
readonly openOptionsPatch: Record<string, JsonValue>;
@@ -1766,6 +1768,11 @@ class PluginAgentSession implements AgentSession {
return;
}
if (event.type === "session.turn") {
if (event.state === "started") {
this.childTurnOutcomes.delete(childId);
} else {
this.childTurnOutcomes.set(childId, event.state);
}
this.publish({
type: "provider_subagent",
provider: this.provider,
@@ -1784,7 +1791,10 @@ class PluginAgentSession implements AgentSession {
event: {
type: "upsert",
id: childId,
status: event.type === "session.runtime_failed" || event.error ? "failed" : "completed",
status:
event.type === "session.runtime_failed" || event.error
? "failed"
: (this.childTurnOutcomes.get(childId) ?? "completed"),
},
});
}
@@ -1802,6 +1812,7 @@ class PluginAgentSession implements AgentSession {
this.history.clear();
this.timelineSnapshots.clear();
this.childSnapshots.clear();
this.childTurnOutcomes.clear();
this.revertTokens.clear();
this.pendingPermissions.clear();
this.permissionResponses.clear();
@@ -113,6 +113,111 @@ test.each([
}
});
test("re-probes a v1 runtime on a forced refresh after OpenCode is updated to v2", async () => {
const { mkdtemp, writeFile, rm } = await import("node:fs/promises");
const { tmpdir } = await import("node:os");
const { join } = await import("node:path");
const { OpenCodeRuntimeClient } = await import("./runtime-client.js");
const { createTestLogger } = await import("../../../../test-utils/test-logger.js");
const root = await mkdtemp(join(tmpdir(), "opencode-version-upgrade-"));
const script = join(root, "version.cjs");
const client = new OpenCodeRuntimeClient(createTestLogger(), {
command: { mode: "replace", argv: [process.execPath, script] },
env: { XDG_STATE_HOME: root },
});
const controlLabel = async () =>
(await client.listControls({ provider: "opencode", cwd: root })).controls[0]?.label;
try {
await writeFile(script, 'console.log("1.14.46")');
expect(await controlLabel()).toBe("Auto Accept");
await writeFile(script, 'console.log("opencode v2.0.10")');
expect(await controlLabel()).toBe("Auto Accept");
// The fake binary cannot serve, so the refresh fails on the v2 startup path it switched to.
await expect(client.fetchCatalog({ scope: "global", force: true })).rejects.toThrow(
"OpenCode v2 server exited during startup",
);
expect(await controlLabel()).toBe("Auto-accept");
} finally {
await client.shutdown();
await rm(root, { recursive: true, force: true });
}
});
async function createCountingVersionBinary(prefix: string) {
const { mkdtemp, writeFile, readFile, rm } = await import("node:fs/promises");
const { tmpdir } = await import("node:os");
const { join } = await import("node:path");
const { OpenCodeRuntimeClient } = await import("./runtime-client.js");
const { createTestLogger } = await import("../../../../test-utils/test-logger.js");
const root = await mkdtemp(join(tmpdir(), prefix));
const script = join(root, "version.cjs");
const probes = join(root, "probes");
await writeFile(probes, "");
const client = new OpenCodeRuntimeClient(createTestLogger(), {
command: { mode: "replace", argv: [process.execPath, script] },
env: { XDG_STATE_HOME: root },
});
return {
client,
root,
report: (version: string) =>
writeFile(
script,
`if (process.argv.includes("--version")) require("node:fs").appendFileSync(${JSON.stringify(probes)}, "x"); console.log(${JSON.stringify(version)})`,
),
probeCount: async () => (await readFile(probes, "utf8")).length,
controlLabel: async () =>
(await client.listControls({ provider: "opencode", cwd: root })).controls[0]?.label,
dispose: async () => {
await client.shutdown();
await rm(root, { recursive: true, force: true });
},
};
}
test("keeps a v2 runtime on a forced refresh without probing the binary again", async () => {
const binary = await createCountingVersionBinary("opencode-v2-refresh-");
try {
await binary.report("opencode v2.0.10");
expect(await binary.controlLabel()).toBe("Auto-accept");
expect(await binary.probeCount()).toBe(1);
await expect(binary.client.fetchCatalog({ scope: "global", force: true })).rejects.toThrow(
"OpenCode v2 server exited during startup",
);
expect(await binary.probeCount()).toBe(1);
expect(await binary.controlLabel()).toBe("Auto-accept");
} finally {
await binary.dispose();
}
});
test.each([0, 1, 2, 3, 4, 5, 6, 8])(
"probes once when a second forced refresh starts %i microtasks after the first",
async (ticks) => {
const binary = await createCountingVersionBinary("opencode-concurrent-refresh-");
try {
await binary.report("1.14.46");
expect(await binary.controlLabel()).toBe("Auto Accept");
await binary.report("opencode v2.0.10");
const first = binary.client.fetchCatalog({ scope: "global", force: true });
for (let tick = 0; tick < ticks; tick++) await Promise.resolve();
const second = binary.client.fetchCatalog({ scope: "global", force: true });
const results = await Promise.allSettled([first, second]);
expect(results.map((result) => result.status)).toEqual(["rejected", "rejected"]);
expect(await binary.probeCount()).toBe(2);
expect(await binary.controlLabel()).toBe("Auto-accept");
} finally {
await binary.dispose();
}
},
);
test("appends a first notice after old history and records a changed major version", async () => {
const { withOpenCodeRuntimeNotice } = await import("./runtime-notice.js");
const { V2Harness } = await import("./test-utils/v2-harness.js");
@@ -155,8 +155,18 @@ export class OpenCodeRuntimeClient implements AgentClient {
return client === this.legacy ? session : withOpenCodeRuntimeNotice(session, 2, handle);
}
async fetchCatalog(options: FetchCatalogOptions, context?: ProviderRefreshContext) {
if (options.force) await this.reprobeLegacySelection();
return (await this.client()).fetchCatalog(options, context);
}
// Updating the binary does not change provider config, so the client survives it. A forced
// refresh re-probes a v1 pick so a 1.x to 2.x update takes effect without a daemon restart.
// A v2 pick owns live sessions and stays.
private async reprobeLegacySelection() {
const current = this.selected;
if (!current) return;
const client = await current.catch(() => null);
if (client === this.legacy && this.selected === current) this.selected = null;
}
async listCommands(config: AgentSessionConfig) {
return (await this.client()).listCommands(config);
}
@@ -1,5 +1,12 @@
import { STRUCTURED_OUTPUT_TOOL } from "./structured-output-plugin.mjs";
// A refused fetch proves the previous bridge stopped listening. Runtimes report
// it differently: Node puts the code on `cause`, Bun puts it on the error.
export function isRefused(error) {
const code = error?.code ?? error?.cause?.code;
return code === "ECONNREFUSED" || code === "ConnectionRefused";
}
// OpenCode owns this plugin for the lifetime of a location. Paseo supplies only
// live, per-session bindings over the authenticated OpenCode RPC channel.
export async function registerSharedBridge(context) {
@@ -130,12 +137,7 @@ export async function registerSharedBridge(context) {
} catch (error) {
// Refusal or rejected credentials mean this endpoint no longer
// serves the previous binding. Other failures are inconclusive.
if (
error?.status !== 401 &&
error?.cause?.code !== "ECONNREFUSED" &&
error?.code !== "ConnectionRefused"
)
throw error;
if (error?.status !== 401 && !isRefused(error)) throw error;
}
if (active)
return { error: "OpenCode session is already bound to another Paseo bridge" };
@@ -1,4 +1,6 @@
import { mkdtemp, rm } from "node:fs/promises";
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
@@ -167,3 +169,88 @@ test("a shared plugin binds Paseo sessions without involving unrelated sessions"
await rm(home, { recursive: true, force: true });
}
});
test("a shared plugin rebinds a session after the previous bridge stops listening", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-shared-bridge-refused-"));
const previous = createServer((_request, response) => {
response.writeHead(200, { "Content-Type": "application/json", Connection: "close" });
response.end(JSON.stringify({ tools: [] }));
});
await new Promise<void>((resolve) => previous.listen(0, "127.0.0.1", resolve));
const previousPort = (previous.address() as AddressInfo).port;
const current = new OpenCodeBridge({ paseoHome: home, logger: createTestLogger() });
current.setManifestCatalog({ tools: new Map() });
let rpc: Record<string, (input: Binding) => Promise<unknown>> = {};
let dispose: (() => Promise<void>) | undefined;
let releaseCurrent: (() => void) | undefined;
try {
const plugin = {
package: await current.materializeV2Plugin(),
options: { baseUrl: `http://127.0.0.1:${previousPort}`, token: "previous-bridge" },
};
const module = await import(
pathToFileURL(path.join(fileURLToPath(plugin.package), "server.js")).href
);
dispose = await module.default.setup({
options: {},
location: { directory: home },
provider: { reload: async () => {} },
model: { reload: async () => {} },
agent: { reload: async () => {} },
command: { reload: async () => {} },
skill: { reload: async () => {} },
rpc: {
register: async (_definition: unknown, handlers: typeof rpc) => {
rpc = handlers;
return { dispose: async () => {} };
},
},
tool: { transform: async () => ({ dispose: async () => {} }) },
session: {
context: async () => [],
get: async () => ({ parentID: "" }),
hook: async () => ({ dispose: async () => {} }),
},
});
await expect(rpc.bind({ sessionID: "paseo", ...plugin.options })).resolves.toEqual({});
await current.start();
// The restarted daemon owns the agent again, so its bridge serves this session.
releaseCurrent = current.bindSession({ sessionId: "paseo", env: {} });
await new Promise<void>((resolve) => {
previous.close(() => resolve());
previous.closeAllConnections();
});
await expect(
rpc.bind({ sessionID: "paseo", ...current.sharedV2Connection() }),
).resolves.toEqual({});
} finally {
await dispose?.();
releaseCurrent?.();
await current.close();
await new Promise<void>((resolve) => previous.close(() => resolve()));
await rm(home, { recursive: true, force: true });
}
});
test("a refused connection is the only probe failure that clears a binding", async () => {
const source = new URL("./shared-bridge-plugin.mjs", import.meta.url).href;
const { isRefused } = (await import(source)) as { isRefused: (error: unknown) => boolean };
const nodeShape = Object.assign(new Error("fetch failed"), {
cause: Object.assign(new Error("connect ECONNREFUSED"), { code: "ECONNREFUSED" }),
});
const olderBunShape = Object.assign(new TypeError("Unable to connect"), {
code: "ConnectionRefused",
});
// OpenCode 2.2.5 runs on Bun 1.4.4, which carries the code on the error and
// leaves `cause` unset. Node puts the same failure on `cause` instead.
const bunShape = Object.assign(
new TypeError("ECONNREFUSED: Unable to connect. Is the computer able to access the url?"),
{ code: "ECONNREFUSED" },
);
const inconclusive = Object.assign(new Error("request timed out"), { code: "ETIMEDOUT" });
expect(isRefused(nodeShape)).toBe(true);
expect(isRefused(olderBunShape)).toBe(true);
expect(isRefused(bunShape)).toBe(true);
expect(isRefused(inconclusive)).toBe(false);
expect(isRefused(undefined)).toBe(false);
});
@@ -1,9 +1,12 @@
import { expect, test } from "vitest";
import { mkdtemp, rm } from "node:fs/promises";
import { mkdtemp, rm, mkdir, writeFile, readdir, symlink } from "node:fs/promises";
import { tmpdir } from "node:os";
import { execFileSync, spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { join } from "node:path";
import { AgentStorage, parseStoredAgentRecord } from "../agent-storage.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { readPersistedConfig } from "../../persisted-config.js";
import { createRestartRecovery } from "./index.js";
test("recovery saves intent before normalization, clears before dispatch, and resumes each agent independently", async () => {
@@ -103,3 +106,130 @@ test("opt-out consumes pending recovery and excludes archived, private, owned an
await rm(root, { recursive: true, force: true });
}
});
const bashAvailable =
process.platform !== "win32" &&
spawnSync("bash", ["-c", "true"], { stdio: "ignore" }).status === 0;
for (const seeder of ["setup", "shell", "setup-bom"] as const) {
test.skipIf(seeder === "shell" && !bashAvailable)(
`${seeder} seed never dispatches copied running or pending agents and excludes automation`,
async () => {
const root = await mkdtemp(join(tmpdir(), "inert-seed-"));
try {
const source = join(root, "source");
const targetRoot = join(root, "target");
const target = join(targetRoot, ".dev/paseo-home");
const storage = new AgentStorage(join(root, "source-records"), createTestLogger());
for (const [id, lastStatus, resumeOnStartup] of [
["live", "running", false],
["crashed", "running", true],
["stopped", "closed", true],
] as const) {
await storage.upsert(
parseStoredAgentRecord({
id,
lastStatus,
resumeOnStartup,
provider: "codex",
cwd: root,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}),
);
}
await storage.flush();
await mkdir(source, { recursive: true });
await symlink(
join(root, "source-records"),
join(source, "agents"),
process.platform === "win32" ? "junction" : "dir",
);
await mkdir(join(source, "schedules"), { recursive: true });
await writeFile(join(source, "schedules", "schedule.json"), '{"kind":"scheduled"}');
await writeFile(join(source, "schedules", "heartbeat.json"), '{"kind":"heartbeat"}');
await writeFile(
join(source, "config.json"),
(seeder === "setup-bom" ? "\uFEFF" : "") +
JSON.stringify({
version: 1,
agents: { resumeOnStartup: true, resumePrompt: "Continue" },
}),
);
const repo = fileURLToPath(new URL("../../../../../../", import.meta.url));
const env = {
...process.env,
PASEO_SOURCE_CHECKOUT_PATH: join(root, "checkout"),
PASEO_WORKTREE_PATH: targetRoot,
PASEO_DEV_SEED_HOME: source,
PASEO_HOME: target,
};
if (seeder !== "shell")
execFileSync(process.execPath, [join(repo, "scripts/seed-worktree-dev-state.mjs")], {
env,
});
else {
const linkedRepo = join(root, "linked-checkout");
await symlink(repo, linkedRepo, "dir");
execFileSync("bash", [join(linkedRepo, "scripts/dev-home.sh")], { env });
}
expect((await readdir(target)).sort()).toEqual(["agents", "config.json"]);
const config = readPersistedConfig(target);
const copied = new AgentStorage(join(target, "agents"), createTestLogger());
const dispatched: string[] = [];
const recovery = createRestartRecovery({
storage: copied,
settings: () => ({
enabled: config.agents?.resumeOnStartup === true,
prompt: "Continue",
}),
eligible: async () => true,
dispatch: async (id) => {
dispatched.push(id);
},
report: () => {},
});
await recovery.prepareStartup();
recovery.resumePending();
for (const record of await copied.listAll())
await recovery.waitForResume(record.id, new AbortController().signal);
expect(dispatched).toEqual([]);
expect((await copied.listAll()).map((r) => [r.lastStatus, r.resumeOnStartup])).toEqual([
["closed", false],
["closed", false],
["closed", false],
]);
expect(readPersistedConfig(source).agents?.resumeOnStartup).toBe(true);
const unchangedSource = new AgentStorage(join(root, "source-records"), createTestLogger());
expect((await unchangedSource.get("live"))?.lastStatus).toBe("running");
} finally {
await rm(root, { recursive: true, force: true });
}
},
);
}
test("seeding skips an existing home without reading invalid source config", async () => {
const root = await mkdtemp(join(tmpdir(), "seed-skip-"));
try {
const source = join(root, "source");
const targetRoot = join(root, "target");
const target = join(targetRoot, ".dev/paseo-home");
await mkdir(source, { recursive: true });
await mkdir(target, { recursive: true });
await writeFile(join(source, "config.json"), "invalid JSON");
await writeFile(join(target, "keep.json"), "{}");
const repo = fileURLToPath(new URL("../../../../../../", import.meta.url));
execFileSync(process.execPath, [join(repo, "scripts/seed-worktree-dev-state.mjs")], {
env: {
...process.env,
PASEO_SOURCE_CHECKOUT_PATH: join(root, "checkout"),
PASEO_WORKTREE_PATH: targetRoot,
PASEO_DEV_SEED_HOME: source,
PASEO_DEV_RESET_HOME: "0",
},
});
expect(await readdir(target)).toEqual(["keep.json"]);
} finally {
await rm(root, { recursive: true, force: true });
}
});
@@ -0,0 +1,56 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, expect, test } from "vitest";
import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js";
let ctx: DaemonTestContext;
let cwd: string;
beforeEach(async () => {
ctx = await createDaemonTestContext();
cwd = mkdtempSync(path.join(tmpdir(), "fork-background-work-"));
});
afterEach(async () => {
await ctx.cleanup();
rmSync(cwd, { recursive: true, force: true });
}, 30_000);
test("forks from a reply that background work outlived", async () => {
const agent = await ctx.client.createAgent({ provider: "claude", cwd });
await ctx.client.sendMessage(agent.id, "Start background work");
expect((await ctx.client.waitForFinish(agent.id, 15_000)).status).toBe("idle");
await ctx.client.sendMessage(agent.id, "Any findings?");
expect((await ctx.client.waitForFinish(agent.id, 15_000)).status).toBe("idle");
const page = await ctx.client.fetchAgentTimeline(agent.id, { direction: "tail", limit: 0 });
const reply = page.entries.find(
(entry) => entry.item.type === "assistant_message" && entry.item.text === "Audit started.",
);
if (reply?.item.type !== "assistant_message" || !reply.item.messageId) {
throw new Error("Expected the reply to carry a provider message id");
}
const task = page.entries.find((entry) => entry.item.type === "tool_call");
// The Task row started before the reply and completed after it.
expect(task?.item).toMatchObject({ name: "Task", status: "completed" });
expect(task?.seqStart).toBeLessThan(reply.seqEnd);
expect(task?.seqEnd).toBeGreaterThan(reply.seqEnd);
const byMessage = await ctx.client.buildAgentForkContext(agent.id, {
boundaryMessageId: reply.item.messageId,
});
const byCursor = await ctx.client.buildAgentForkContext(agent.id, {
boundaryCursor: { epoch: page.epoch, seq: reply.seqEnd },
});
for (const fork of [byMessage, byCursor]) {
expect(fork.itemCount).toBe(3);
expect(fork.attachment?.text).toContain(
"[User] Start background work\n[Explore] Audit the migrations\n[Assistant] Audit started.\n",
);
expect(fork.attachment?.text).not.toContain("Any findings?");
}
expect(byMessage.boundaryMessageId).toBe(reply.item.messageId);
expect(byCursor.boundaryCursor).toEqual({ epoch: page.epoch, seq: reply.seqEnd });
}, 60_000);
@@ -182,6 +182,46 @@ test("ordinary Hub create and message retries do not duplicate agents or prompts
).toMatchObject({ payload: { accepted: false } });
});
test("Hub retries a message whose send failed before reaching the provider", async () => {
const hub = await launchRelationship();
const agentId = await hub.createUnrelatedLocalAgent();
const message = {
type: "send_agent_message_request",
agentId,
messageId: "mode-change-failed",
text: "retry me",
sessionMode: "full-access",
};
hub.failNextProviderModeChange();
expect(await hub.requestOrdinary({ ...message, requestId: "first-attempt" })).toMatchObject({
payload: { accepted: false, error: expect.stringContaining("mode change failure") },
});
expect(hub.providerPromptTexts().filter((text) => text === "retry me")).toHaveLength(0);
expect(await hub.requestOrdinary({ ...message, requestId: "retry" })).toMatchObject({
payload: { accepted: true, error: null },
});
expect(hub.providerPromptTexts().filter((text) => text === "retry me")).toHaveLength(1);
});
test("Hub never resubmits a message whose send failed after reaching the provider", async () => {
const hub = await launchRelationship();
const agentId = await hub.createUnrelatedLocalAgent();
const message = {
type: "send_agent_message_request",
agentId,
messageId: "turn-start-failed",
text: "do not repeat me",
};
hub.failProviderPromptStart("do not repeat me");
await hub.requestOrdinary({ ...message, requestId: "first-attempt" });
expect(await hub.requestOrdinary({ ...message, requestId: "retry" })).toMatchObject({
payload: { accepted: false, error: "agent_request_outcome_unknown" },
});
expect(hub.providerPromptTexts().filter((text) => text === "do not repeat me")).toHaveLength(0);
});
test("ordinary Hub requests survive daemon restart and restore an archived workspace", async () => {
const hub = await launchRelationship();
const create = {
@@ -458,6 +458,7 @@ export class HubRelationshipHarness {
private readonly claimedCliSockets = new Set<WebSocket>();
private readonly promptsToFail = new Set<string>();
private failNextSessionClose = false;
private failNextModeChange = false;
private observedEnrollments = 0;
private observedSockets = 0;
private readonly codex: ControlledAgentClient;
@@ -474,6 +475,11 @@ export class HubRelationshipHarness {
this.failNextSessionClose = false;
throw new Error("Requested provider session close failure");
},
beforeSetMode: async () => {
if (!this.failNextModeChange) return;
this.failNextModeChange = false;
throw new Error("Requested provider mode change failure");
},
onStartTurn: (prompt) => {
const promptText = typeof prompt === "string" ? prompt : JSON.stringify(prompt);
if (this.promptsToFail.delete(promptText)) {
@@ -674,6 +680,10 @@ export class HubRelationshipHarness {
this.promptsToFail.add(prompt);
}
failNextProviderModeChange(): void {
this.failNextModeChange = true;
}
failNextProviderSessionClose(): void {
this.failNextSessionClose = true;
}
@@ -23,7 +23,8 @@ test("message retries survive reconstruction without submitting twice", async ()
agentId: "agent",
messageId: "arrival",
request: { text: "hello" },
send: async () => {
send: async (beforeDispatch: () => Promise<void>) => {
await beforeDispatch();
deliveries++;
},
};
@@ -41,7 +42,8 @@ test("ambiguous provider delivery is never blindly replayed after restart", asyn
agentId: "agent",
messageId: "arrival",
request: {},
send: async () => {
send: async (beforeDispatch: () => Promise<void>) => {
await beforeDispatch();
deliveries++;
throw new Error("connection lost");
},
@@ -53,7 +55,7 @@ test("ambiguous provider delivery is never blindly replayed after restart", asyn
expect(deliveries).toBe(1);
});
test("failed local message preparation does not leave an ambiguous receipt", async () => {
test("a send that fails before beforeDispatch can be retried with the same message", async () => {
const { requests, directory } = await fixture();
let available = false;
let sends = 0;
@@ -61,14 +63,13 @@ test("failed local message preparation does not leave an ambiguous receipt", asy
agentId: "agent",
messageId: "message",
request: {},
prepare: async () => {
if (!available) throw new Error("load failed");
},
send: async () => {
send: async (beforeDispatch: () => Promise<void>) => {
if (!available) throw new Error("mode change failed");
await beforeDispatch();
sends++;
},
};
await expect(requests.send(input)).rejects.toThrow("load failed");
await expect(requests.send(input)).rejects.toThrow("mode change failed");
available = true;
await new MessageReceipts(directory).send(input);
available = false;
@@ -13,8 +13,11 @@ interface SendMessageInput {
agentId: string;
messageId: string;
request: unknown;
send: () => Promise<void>;
prepare?: () => Promise<void>;
/**
* Delivers the message. It awaits `beforeDispatch` immediately before the provider can receive
* the message; a failure before that call proves nothing was submitted and leaves no receipt.
*/
send: (beforeDispatch: () => Promise<void>) => Promise<void>;
}
/** Owns message delivery receipts; creation is owned by CreationService. */
@@ -48,10 +51,12 @@ export class MessageReceipts {
// A provider may have accepted the message before its receipt was committed.
throw new Error("agent_request_outcome_unknown");
}
await input.prepare?.();
const receipt = { fingerprint, agentId: input.agentId };
await writeJsonFileAtomic(file, { ...receipt, state: "pending" });
await input.send();
let pending: Promise<void> | null = null;
await input.send(() => {
pending ??= writeJsonFileAtomic(file, { ...receipt, state: "pending" });
return pending;
});
await writeJsonFileAtomic(file, { ...receipt, state: "completed" });
}
}
+6 -4
View File
@@ -8918,8 +8918,9 @@ export class Session {
queued = acceptAdmission;
});
let deliveryStatus: "queued" | undefined;
const send = async () => {
const send = async (beforeDispatch: () => Promise<void>) => {
if (msg.sourceAgentId) {
await beforeDispatch();
const receipt = this.agentMessages.enqueue({
recipientId: agentId,
messageId: msg.messageId,
@@ -8950,6 +8951,7 @@ export class Session {
activeTurnBehavior: msg.activeTurnBehavior ?? "interrupt",
sessionMode: msg.sessionMode,
clearPendingPermissions: true,
beforeDispatch,
logger: this.sessionLogger,
});
if (result.disposition === "turn_started") {
@@ -8972,12 +8974,12 @@ export class Session {
activeTurnBehavior:
msg.activeTurnBehavior ?? (msg.sourceAgentId ? "steer" : "interrupt"),
},
prepare: async () => {
send: async (beforeDispatch) => {
if (!msg.sourceAgentId) await this.prepareAgentMessage(agentId, msg.text);
await send(beforeDispatch);
},
send,
})
: send();
: send(async () => {});
void sent.catch((error) => {
if (deliveryStatus === "queued") {
this.handleAgentRunError(agentId, error, "Failed to dispatch queued agent message");
@@ -84,6 +84,7 @@ interface FakeAgentSessionOptions {
beforeTurnComplete?: (prompt: AgentPromptInput) => Promise<void>;
steerActiveTurn?: AgentSession["steerActiveTurn"];
onInterrupt?: () => void;
beforeSetMode?: (modeId: string) => Promise<void>;
historyEvents?: readonly AgentStreamEvent[];
}
@@ -95,6 +96,7 @@ export interface TestAgentClientOptions {
beforeTurnComplete?: (prompt: AgentPromptInput) => Promise<void>;
steerActiveTurn?: AgentSession["steerActiveTurn"];
onInterrupt?: () => void;
beforeSetMode?: (modeId: string) => Promise<void>;
supportsMcpServers?: boolean;
historyEvents?: readonly AgentStreamEvent[];
}
@@ -369,6 +371,7 @@ class FakeAgentSession implements AgentSession {
readonly steerActiveTurn: AgentSession["steerActiveTurn"];
private readonly onInterrupt: (() => void) | undefined;
private readonly beforeSetMode: ((modeId: string) => Promise<void>) | undefined;
private readonly closeSession: (() => Promise<void>) | undefined;
private readonly onStartTurn: TestAgentClientOptions["onStartTurn"];
private readonly beforeTurnComplete: ((prompt: AgentPromptInput) => Promise<void>) | undefined;
@@ -385,6 +388,7 @@ class FakeAgentSession implements AgentSession {
this.memoryMarker = options.memoryMarker ?? null;
this.steerActiveTurn = options.steerActiveTurn;
this.onInterrupt = options.onInterrupt;
this.beforeSetMode = options.beforeSetMode;
this.closeSession = options.closeSession;
this.onStartTurn = options.onStartTurn;
this.beforeTurnComplete = options.beforeTurnComplete;
@@ -543,6 +547,48 @@ class FakeAgentSession implements AgentSession {
this.notifySubscribers(completed);
}
private async emitEvent(event: AgentStreamEvent): Promise<void> {
await this.appendHistoryEvent(event);
this.notifySubscribers(event);
}
/**
* A background Task that outlives the turn's reply: the Task starts, the assistant replies,
* then the Task completes.
*/
private async emitBackgroundWorkTurn(): Promise<void> {
const callId = randomUUID();
const task = (status: "running" | "completed", log: string): AgentStreamEvent => ({
type: "timeline",
provider: this.providerName,
item: {
type: "tool_call",
name: "Task",
callId,
status,
error: null,
detail: {
type: "sub_agent",
subAgentType: "Explore",
description: "Audit the migrations",
log,
},
},
});
await this.emitEvent(task("running", ""));
await this.emitEvent({
type: "timeline",
provider: this.providerName,
item: { type: "assistant_message", text: "Audit started.", messageId: randomUUID() },
});
await this.emitEvent(task("completed", "Found 2 unsafe migrations."));
await this.emitEvent({
type: "turn_completed",
provider: this.providerName,
usage: { inputTokens: 1, outputTokens: 1 },
});
}
private async emitStressTurn(stress: { count: number; coalesced: boolean }): Promise<void> {
for (let index = 0; index < stress.count; index += 1) {
const stressUpdate: AgentStreamEvent = {
@@ -786,6 +832,11 @@ class FakeAgentSession implements AgentSession {
this.notifySubscribers(child);
}
if (textPrompt === "Start background work") {
await this.emitBackgroundWorkTurn();
return;
}
if (textPrompt.toLowerCase().includes("emit a turn failure")) {
const failed: AgentStreamEvent = {
type: "turn_failed",
@@ -891,6 +942,7 @@ class FakeAgentSession implements AgentSession {
}
async setMode(modeId: string): Promise<void> {
await this.beforeSetMode?.(modeId);
this.config.modeId = modeId;
}
@@ -1264,6 +1316,7 @@ class FakeAgentClient implements AgentClient {
beforeTurnComplete: this.options.beforeTurnComplete,
steerActiveTurn: this.options.steerActiveTurn,
onInterrupt: this.options.onInterrupt,
beforeSetMode: this.options.beforeSetMode,
historyEvents: this.options.historyEvents,
});
}
@@ -1294,6 +1347,7 @@ class FakeAgentClient implements AgentClient {
beforeTurnComplete: this.options.beforeTurnComplete,
steerActiveTurn: this.options.steerActiveTurn,
onInterrupt: this.options.onInterrupt,
beforeSetMode: this.options.beforeSetMode,
historyEvents: this.options.historyEvents,
});
}
@@ -306,7 +306,7 @@ export function createProviderSnapshotManagerStub(): {
export function createMessageReceiptsStub(): SessionOptions["messageReceipts"] {
return {
send: (input) => input.send(),
send: (input) => input.send(async () => {}),
};
}
@@ -1238,6 +1238,9 @@ test("a failed parent lookup is retried by the pull request status poll, keeping
paseoHome: path.join(path.dirname(repo), "home"),
deps: {
now: () => new Date(nowMs),
// Observing the workspace starts a background fetch of origin. A real one would run ssh
// against the alias host in the checkout, and can outlive disposal and hold the folder.
runGitFetch: async () => ({ changes: [], error: null }),
resolveSshHostname: async (host) => (host === "github.com-work" ? "github.com" : null),
forgeOverrides: {
// The adapter is real; only the poll is driven by the test instead of a timer.
@@ -30,6 +30,7 @@ import {
getCheckoutShortstat,
getPullRequestStatus,
getCheckoutStatus,
getCheckoutWorktreeState,
checkoutResolvedBranch,
listBranchSuggestions,
setCheckoutBaseBranch,
@@ -572,6 +573,15 @@ describe("checkout git utilities", () => {
expect(message).toBe("update file");
});
it("counts untracked files as dirty when the repository hides them from status", async () => {
execFileSync("git", ["config", "status.showUntrackedFiles", "no"], { cwd: repoDir });
writeFileSync(join(repoDir, "new-file.txt"), "not yet added\n");
expect(execFileSync("git", ["status", "--porcelain"], { cwd: repoDir }).toString()).toBe("");
expect((await getCheckoutStatus(repoDir)).isDirty).toBe(true);
expect((await getCheckoutWorktreeState(repoDir, {})).isDirty).toBe(true);
});
it("honors commit signing configuration", async () => {
execFileSync("git", ["config", "commit.gpgsign", "true"], { cwd: repoDir });
execFileSync("git", ["config", "gpg.format", "openpgp"], { cwd: repoDir });
+10 -5
View File
@@ -1413,12 +1413,17 @@ function resolveOperationBaseRef(input: {
return input.storedBaseRef ?? input.requestedBaseRef ?? input.resolvedBaseRef;
}
// Untracked files count whatever `status.showUntrackedFiles` says: archive warns from this before
// it removes a worktree, and a file nobody added yet is still work to lose.
async function isWorkingTreeDirty(cwd: string, context?: CheckoutContext): Promise<boolean> {
const { stdout } = await getRunGitCommand(context)(["status", "--porcelain"], {
cwd,
envOverlay: READ_ONLY_GIT_ENV,
logger: context?.logger,
});
const { stdout } = await getRunGitCommand(context)(
["status", "--porcelain", "--untracked-files=normal"],
{
cwd,
envOverlay: READ_ONLY_GIT_ENV,
logger: context?.logger,
},
);
return stdout.trim().length > 0;
}
@@ -57,8 +57,124 @@ index 2abb4a2..8ca967f 100644
outerScrollOffset?: Animated.SharedValue<number>;
onAnimValInit?: (animVals: ReturnType<typeof useAnimatedValues>) => void;
itemEnteringAnimation?: AnimateProps<Animated.View>["entering"];
diff --git a/node_modules/react-native-draggable-flatlist/src/components/CellRendererComponent.tsx b/node_modules/react-native-draggable-flatlist/src/components/CellRendererComponent.tsx
index e4e861a..f485139 100644
--- a/node_modules/react-native-draggable-flatlist/src/components/CellRendererComponent.tsx
+++ b/node_modules/react-native-draggable-flatlist/src/components/CellRendererComponent.tsx
@@ -8,6 +8,7 @@ import {
} from "react-native";
import Animated, {
runOnUI,
+ SharedValue,
useAnimatedStyle,
useSharedValue,
} from "react-native-reanimated";
@@ -40,12 +41,12 @@ function CellRendererComponent<T>(props: Props<T>) {
keyExtractor,
horizontal,
layoutAnimationDisabled,
+ transformHostKeys,
} = useDraggableFlatListContext<T>();
const key = keyExtractor(item, index);
const offset = useSharedValue(-1);
const size = useSharedValue(-1);
- const heldTanslate = useSharedValue(0);
const translate = useCellTranslate({
cellOffset: offset,
@@ -55,20 +56,6 @@ function CellRendererComponent<T>(props: Props<T>) {
const isActive = activeKey === key;
- const animStyle = useAnimatedStyle(() => {
- // When activeKey becomes null at the end of a drag and the list reorders,
- // the animated style may apply before the next paint, causing a flicker.
- // Solution is to hold over the last animated value until the next onLayout.
- // (Not required in web)
- if (translate.value && !isWeb) {
- heldTanslate.value = translate.value;
- }
- const t = activeKey ? translate.value : heldTanslate.value;
- return {
- transform: [horizontalAnim.value ? { translateX: t } : { translateY: t }],
- };
- }, [translate, activeKey]);
-
const updateCellMeasurements = useStableCallback(() => {
const onSuccess: MeasureLayoutOnSuccessCallback = (x, y, w, h) => {
if (isWeb && horizontal) x += scrollOffset.value;
@@ -99,7 +86,6 @@ function CellRendererComponent<T>(props: Props<T>) {
});
const onCellLayout = useStableCallback((e?: LayoutChangeEvent) => {
- heldTanslate.value = 0;
updateCellMeasurements();
if (onLayout && e) onLayout(e);
});
@@ -162,14 +148,58 @@ function CellRendererComponent<T>(props: Props<T>) {
? itemLayoutAnimation
: undefined
}
- style={[props.style, baseStyle, animStyle]}
+ style={[props.style, baseStyle]}
pointerEvents={activeKey ? "none" : "auto"}
>
- <CellProvider isActive={isActive}>{children}</CellProvider>
+ <CellTransformHost
+ key={transformHostKeys.get(key) ?? 0}
+ translate={translate}
+ dragging={activeKey !== null}
+ horizontal={horizontal}
+ >
+ <CellProvider isActive={isActive}>{children}</CellProvider>
+ </CellTransformHost>
</Animated.View>
);
}
+// Owns the cell's drag translate. The list remounts it, with the cell's whole subtree, when a
+// drop moves the cell: the reorder commit then paints the new slot with no translate, because a
+// new view has no last animated transform for Reanimated to re-apply.
+function CellTransformHost({
+ translate,
+ dragging,
+ horizontal,
+ children,
+}: {
+ translate: SharedValue<number>;
+ dragging: boolean;
+ horizontal: boolean;
+ children: React.ReactNode;
+}) {
+ const { horizontalAnim } = useAnimatedValues();
+ const animStyle = useAnimatedStyle(() => {
+ const t = dragging ? translate.value : 0;
+ return {
+ transform: [horizontalAnim.value ? { translateX: t } : { translateY: t }],
+ };
+ }, [translate, dragging]);
+
+ return (
+ <Animated.View
+ collapsable={false}
+ style={[horizontal ? styles.row : styles.column, animStyle]}
+ >
+ {children}
+ </Animated.View>
+ );
+}
+
+const styles = {
+ row: { flexDirection: "row" as const },
+ column: { flexDirection: "column" as const },
+};
+
export default typedMemo(CellRendererComponent);
declare global {
diff --git a/node_modules/react-native-draggable-flatlist/src/components/DraggableFlatList.tsx b/node_modules/react-native-draggable-flatlist/src/components/DraggableFlatList.tsx
index 7c88afc..a74140f 100644
index 7c88afc..ad75714 100644
--- a/node_modules/react-native-draggable-flatlist/src/components/DraggableFlatList.tsx
+++ b/node_modules/react-native-draggable-flatlist/src/components/DraggableFlatList.tsx
@@ -16,9 +16,11 @@ import {
@@ -88,13 +204,26 @@ index 7c88afc..a74140f 100644
+ sessionId: number;
+ index: number;
+ } | null>(null);
const {
dragHitSlop = DEFAULT_PROPS.dragHitSlop,
@@ -144,19 +157,88 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
@@ -100,6 +113,12 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
} = props;
let [activeKey, setActiveKey] = useState<string | null>(null);
+ // Reanimated re-applies a view's last animated transform to every React commit, so a moved
+ // cell would paint once at its new slot plus its old drag translate. The drop gives each
+ // moved cell a new transform host instead: a fresh view has no animated props to re-apply.
+ const [transformHostKeys, setTransformHostKeys] = useState<
+ ReadonlyMap<string, number>
+ >(() => new Map());
const [layoutAnimationDisabled, setLayoutAnimationDisabled] = useState(
!propsRef.current.enableLayoutAnimationExperimental
);
@@ -144,19 +163,88 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
});
}, [props.data, keyExtractor, keyToIndexRef]);
+ const onDragCancel = useStableCallback((sessionId: number, index: number) => {
+ if (activeDragRef.current?.sessionId !== sessionId) return;
+ activeDragRef.current = null;
@@ -168,7 +297,7 @@ index 7c88afc..a74140f 100644
- activeCellOffset.value = cellData.measurements.offset;
- activeCellSize.value = cellData.measurements.size;
- }
const { onDragBegin } = propsRef.current;
if (index !== undefined) {
- spacerIndexAnim.value = index;
@@ -187,10 +316,10 @@ index 7c88afc..a74140f 100644
setActiveKey(activeKey);
onDragBegin?.(index);
}
@@ -218,8 +300,25 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
@@ -218,14 +306,46 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
props.onRelease?.(index);
});
+ const finalizeCancelledDrag = () => {
+ "worklet";
+ const activeIndex = activeIndexAnim.value;
@@ -212,12 +341,33 @@ index 7c88afc..a74140f 100644
+ if (activeDragRef.current?.sessionId !== sessionId) return;
+ activeDragRef.current = null;
const { onDragEnd, data } = props;
const newData = [...data];
@@ -238,26 +337,6 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
if (from !== to) {
newData.splice(from, 1);
newData.splice(to, 0, data[from]);
+ const movedKeys = data
+ .slice(Math.min(from, to), Math.max(from, to) + 1)
+ .map((item, offset) => keyExtractor(item, Math.min(from, to) + offset));
+ setTransformHostKeys((previous) => {
+ const next = new Map<string, number>();
+ data.forEach((item, index) => {
+ const itemKey = keyExtractor(item, index);
+ const hostKey = previous.get(itemKey);
+ if (hostKey !== undefined) next.set(itemKey, hostKey);
+ });
+ for (const movedKey of movedKeys) {
+ next.set(movedKey, (previous.get(movedKey) ?? 0) + 1);
+ }
+ return next;
+ });
}
onDragEnd?.({ from, to, data: newData });
@@ -238,26 +358,6 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
props.onPlaceholderIndexChange?.(index);
});
- // Handle case where user ends drag without moving their finger.
- useAnimatedReaction(
- () => {
@@ -241,15 +391,15 @@ index 7c88afc..a74140f 100644
useAnimatedReaction(
() => {
return spacerIndexAnim.value;
@@ -271,11 +350,24 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
@@ -271,11 +371,24 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
);
const gestureDisabled = useSharedValue(false);
+ const touchStartX = useSharedValue(0);
+ const touchStartY = useSharedValue(0);
+ const manualPanActivated = useSharedValue(false);
+ const coordinatesDragWithExternalGesture = Boolean(props.simultaneousHandlers);
- const panGesture = Gesture.Pan()
+ let panGesture = Gesture.Pan()
.onBegin((evt) => {
@@ -267,7 +417,7 @@ index 7c88afc..a74140f 100644
panGestureState.value = evt.state;
})
.onUpdate((evt) => {
@@ -286,8 +378,15 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
@@ -286,8 +399,15 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
: evt.translationY;
touchTranslate.value = translation;
})
@@ -284,15 +434,15 @@ index 7c88afc..a74140f 100644
// Set touch val to current translate val
isTouchActiveNative.value = false;
const translation = horizontalAnim.value
@@ -299,6 +398,7 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
@@ -299,6 +419,7 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
// Only call onDragEnd if actually dragging a cell
if (activeIndexAnim.value === -1 || disabled.value) return;
+ terminalHandled.value = true;
disabled.value = true;
runOnJS(onRelease)(activeIndexAnim.value);
const springTo = placeholderOffset.value - activeCellOffset.value;
@@ -309,22 +409,84 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
@@ -309,22 +430,84 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
runOnJS(onDragEnd)({
from: activeIndexAnim.value,
to: spacerIndexAnim.value,
@@ -371,7 +521,7 @@ index 7c88afc..a74140f 100644
+ stateManager.fail();
+ }
});
if (dragHitSlop) panGesture.hitSlop(dragHitSlop);
- if (activationDistanceProp) {
+ if (coordinatesDragWithExternalGesture) {
@@ -380,7 +530,7 @@ index 7c88afc..a74140f 100644
const activeOffset = [-activationDistanceProp, activationDistanceProp];
if (props.horizontal) {
panGesture.activeOffsetX(activeOffset);
@@ -332,6 +494,21 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
@@ -332,6 +515,21 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
panGesture.activeOffsetY(activeOffset);
}
}
@@ -399,12 +549,14 @@ index 7c88afc..a74140f 100644
+ props.dragGestureHostPresented === false
+ ? Gesture.Simultaneous(panGesture, Gesture.Manual().enabled(false))
+ : panGesture;
const onScroll = useStableCallback((scrollOffset: number) => {
props.onScrollOffsetChange?.(scrollOffset);
@@ -373,8 +550,9 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
@@ -372,9 +570,11 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
keyExtractor={keyExtractor}
horizontal={!!props.horizontal}
layoutAnimationDisabled={layoutAnimationDisabled}
+ transformHostKeys={transformHostKeys}
>
- <GestureDetector gesture={panGesture}>
+ <GestureDetector gesture={detectorGesture}>
@@ -426,17 +578,17 @@ index 1559352..33eb5de 100644
useSharedValue,
@@ -48,7 +48,7 @@ function NestableDraggableFlatListInner<T>(
});
const onListContainerLayout = useStableCallback(async ({ containerRef }) => {
- const nodeHandle = findNodeHandle(scrollableRef.current);
+ const nodeHandle = scrollableRef.current;
const onSuccess = (_x: number, y: number) => {
listVerticalOffset.value = y;
@@ -74,6 +74,10 @@ function NestableDraggableFlatListInner<T>(
}
);
+ const onDragTerminate = useStableCallback(() => {
+ setOuterScrollEnabled(true);
+ });
@@ -452,6 +604,37 @@ index 1559352..33eb5de 100644
onAnimValInit={onAnimValInit}
/>
);
diff --git a/node_modules/react-native-draggable-flatlist/src/context/draggableFlatListContext.tsx b/node_modules/react-native-draggable-flatlist/src/context/draggableFlatListContext.tsx
index 75f7db7..e99faf3 100644
--- a/node_modules/react-native-draggable-flatlist/src/context/draggableFlatListContext.tsx
+++ b/node_modules/react-native-draggable-flatlist/src/context/draggableFlatListContext.tsx
@@ -5,6 +5,7 @@ type Props<T> = {
keyExtractor: (item: T, index: number) => string;
horizontal: boolean;
layoutAnimationDisabled: boolean;
+ transformHostKeys: ReadonlyMap<string, number>;
children: React.ReactNode;
};
@@ -19,6 +20,7 @@ export default function DraggableFlatListProvider<T>({
keyExtractor,
horizontal,
layoutAnimationDisabled,
+ transformHostKeys,
children,
}: Props<T>) {
const value = useMemo(
@@ -27,8 +29,9 @@ export default function DraggableFlatListProvider<T>({
keyExtractor,
horizontal,
layoutAnimationDisabled,
+ transformHostKeys,
}),
- [activeKey, keyExtractor, horizontal, layoutAnimationDisabled]
+ [activeKey, keyExtractor, horizontal, layoutAnimationDisabled, transformHostKeys]
);
return (
diff --git a/node_modules/react-native-draggable-flatlist/src/types.ts b/node_modules/react-native-draggable-flatlist/src/types.ts
index d6755c8..1b244a6 100644
--- a/node_modules/react-native-draggable-flatlist/src/types.ts
+3 -55
View File
@@ -4,62 +4,10 @@ default_dev_paseo_root() {
git rev-parse --show-toplevel 2>/dev/null || pwd
}
copy_json_tree() {
local source_dir="$1"
local target_dir="$2"
if [ ! -d "$source_dir" ]; then
return
fi
mkdir -p "$target_dir"
if command -v rsync >/dev/null 2>&1; then
rsync -a --include='*/' --include='*.json' --exclude='*' "$source_dir/" "$target_dir/"
return
fi
while IFS= read -r -d '' source_file; do
local relative_path="${source_file#"$source_dir"/}"
local target_file="$target_dir/$relative_path"
mkdir -p "$(dirname "$target_file")"
cp "$source_file" "$target_file"
done < <(find "$source_dir" -type f -name '*.json' -print0)
}
has_files() {
[ -d "$1" ] && [ -n "$(find "$1" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null)" ]
}
seed_worktree_paseo_home() {
local source_home="${PASEO_DEV_SEED_HOME:-$HOME/.paseo}"
local target_home="$1"
if [ ! -d "$source_home" ]; then
echo " Seed: skipped (${source_home} missing)"
return
fi
if [ "$source_home" = "$target_home" ]; then
echo " Seed: skipped (source is target)"
return
fi
if [ "${PASEO_DEV_RESET_HOME:-0}" = "1" ]; then
rm -rf "$target_home"
elif has_files "$target_home"; then
echo " Seed: skipped (${target_home} already has data)"
return
fi
mkdir -p "$target_home"
echo " Seed: copying metadata from ${source_home}"
copy_json_tree "$source_home/agents" "$target_home/agents"
copy_json_tree "$source_home/projects" "$target_home/projects"
if [ -f "$source_home/config.json" ]; then
cp "$source_home/config.json" "$target_home/config.json"
fi
echo " Seed: copied metadata from ${source_home}"
local script_dir
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
node "$script_dir/seed-dev-home.mjs" "${PASEO_DEV_SEED_HOME:-$HOME/.paseo}" "$1"
}
configure_dev_daemon_config() {
+4
View File
@@ -48,6 +48,10 @@ const patchedPackages = [
nodeModulesPath: "node_modules/@legendapp/list",
patchPrefix: "@legendapp+list+",
},
// Native sidebar lists. Besides gesture sessions and panel-gesture arbitration, the drop gives
// each moved cell a fresh transform host. Reanimated re-applies a view's last animated
// transform to every React commit, so a reused host painted one frame of new order plus old
// drag translate (upstream computerjazz/react-native-draggable-flatlist#621, open).
{
nodeModulesPath: "node_modules/react-native-draggable-flatlist",
patchPrefix: "react-native-draggable-flatlist+",
+84
View File
@@ -0,0 +1,84 @@
import {
cpSync,
realpathSync,
existsSync,
mkdirSync,
readdirSync,
rmSync,
statSync,
readFileSync,
writeFileSync,
} from "node:fs";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
export function seedPaseoHome(source, target) {
if (!existsSync(source)) {
console.log(` Seed: skipped (${source} missing)`);
return;
}
if (source === target) {
console.log(" Seed: skipped (source is target)");
return;
}
if (process.env.PASEO_DEV_RESET_HOME !== "1" && hasEntries(target)) {
console.log(` Seed: skipped (${target} already has data)`);
return;
}
const configPath = join(source, "config.json");
const config = existsSync(configPath)
? JSON.parse(readFileSync(configPath, "utf8").replace(/^\uFEFF/, ""))
: { version: 1 };
if (process.env.PASEO_DEV_RESET_HOME === "1") {
rmSync(target, { recursive: true, force: true });
}
mkdirSync(target, { recursive: true });
console.log(` Seed: copying metadata from ${source}`);
copyJsonTree(join(source, "agents"), join(target, "agents"));
copyJsonTree(join(source, "projects"), join(target, "projects"));
// A snapshot does not own the source daemon's pending work. Recovery normalizes
// copied running records on startup without sending a prompt when disabled.
config.agents = { ...config.agents, resumeOnStartup: false };
writeFileSync(join(target, "config.json"), JSON.stringify(config, null, 2));
console.log(` Seed: copied metadata from ${source}`);
}
// Durable JSON metadata only. Runtime files (pid files, sockets, logs) must not be copied.
function copyJsonTree(source, target) {
if (!existsSync(source)) {
return;
}
cpSync(source, target, {
recursive: true,
dereference: true,
filter: (path) => isDirectory(path) || path.endsWith(".json"),
});
}
function hasEntries(path) {
try {
return readdirSync(path).length > 0;
} catch {
return false;
}
}
function isDirectory(path) {
try {
return statSync(path).isDirectory();
} catch {
return false;
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
seedPaseoHome(process.argv[2], process.argv[3]);
}
+7 -64
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env node
import { cpSync, existsSync, mkdirSync, readdirSync, rmSync, statSync } from "node:fs";
import { cpSync, existsSync, mkdirSync } from "node:fs";
import { dirname, join } from "node:path";
// Worktree setup runs through a stable script shell: bash on macOS/Linux, but PowerShell
@@ -7,6 +7,8 @@ import { dirname, join } from "node:path";
// `./scripts/*.sh`) cannot express this step portably, so the seeding lives in Node — the
// one interpreter every Paseo checkout already depends on.
import { seedPaseoHome } from "./seed-dev-home.mjs";
const sourceRoot = process.env.PASEO_SOURCE_CHECKOUT_PATH;
const targetRoot = process.env.PASEO_WORKTREE_PATH || process.cwd();
@@ -14,55 +16,12 @@ if (!sourceRoot || sourceRoot === targetRoot) {
process.exit(0);
}
seedPaseoHome();
seedPaseoHome(
process.env.PASEO_DEV_SEED_HOME || join(sourceRoot, ".dev/paseo-home"),
join(targetRoot, ".dev/paseo-home"),
);
copyServerEnv();
function seedPaseoHome() {
const source = process.env.PASEO_DEV_SEED_HOME || join(sourceRoot, ".dev/paseo-home");
const target = join(targetRoot, ".dev/paseo-home");
if (!existsSync(source)) {
console.log(` Seed: skipped (${source} missing)`);
return;
}
if (source === target) {
console.log(" Seed: skipped (source is target)");
return;
}
if (process.env.PASEO_DEV_RESET_HOME === "1") {
rmSync(target, { recursive: true, force: true });
} else if (hasEntries(target)) {
console.log(` Seed: skipped (${target} already has data)`);
return;
}
mkdirSync(target, { recursive: true });
console.log(` Seed: copying metadata from ${source}`);
copyJsonTree(join(source, "agents"), join(target, "agents"));
copyJsonTree(join(source, "projects"), join(target, "projects"));
const config = join(source, "config.json");
if (existsSync(config)) {
cpSync(config, join(target, "config.json"));
}
console.log(` Seed: copied metadata from ${source}`);
}
// Durable JSON metadata only. Runtime files (pid files, sockets, logs) must not be copied.
function copyJsonTree(source, target) {
if (!existsSync(source)) {
return;
}
cpSync(source, target, {
recursive: true,
filter: (path) => isDirectory(path) || path.endsWith(".json"),
});
}
function copyServerEnv() {
const source = join(sourceRoot, "packages/server/.env");
const target = join(targetRoot, "packages/server/.env");
@@ -77,19 +36,3 @@ function copyServerEnv() {
cpSync(source, target);
console.log(` Env: copied ${source}`);
}
function hasEntries(path) {
try {
return readdirSync(path).length > 0;
} catch {
return false;
}
}
function isDirectory(path) {
try {
return statSync(path).isDirectory();
} catch {
return false;
}
}