mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-08 17:34:51 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f792ac8a4e | ||
|
|
e8a9e2d699 | ||
|
|
5feec6fa35 | ||
|
|
138d9d63ab |
@@ -1,11 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## 0.15.3 - 2026-11-06
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed conversation text becoming blurred when opening or dismissing the keyboard on iOS 26+ ([#8918](https://github.com/getpaseo/paseo/pull/8918) by [@dgreensp](https://github.com/dgreensp))
|
||||
|
||||
## 0.15.2 - 2026-11-05
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## 0.15.3 - 2026-11-06
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复了 iOS 26 及更高版本中打开或收起键盘时聊天内容变模糊的问题 ([#8918](https://github.com/getpaseo/paseo/pull/8918),贡献者:[@dgreensp](https://github.com/dgreensp))
|
||||
|
||||
## 0.15.2 - 2026-11-05
|
||||
|
||||
### 新增
|
||||
|
||||
+3
-27
@@ -259,27 +259,6 @@ archived workspace. History navigation must not infer workspace lifecycle from `
|
||||
or mutate either lifecycle. The workspace route asks the daemon for authoritative recovery state;
|
||||
only the route's explicit Unarchive or Restore action changes the archived workspace.
|
||||
|
||||
Workspace archive is undoable from the archived toast, or with Cmd/Ctrl+Z, while the toast is up.
|
||||
The daemon runs one archive lane per workspace, shared by every connection
|
||||
(`packages/server/src/server/workspace-archive-operations.ts`):
|
||||
|
||||
| A request arrives while | Result |
|
||||
| ------------------------ | --------------------------------------------------------- |
|
||||
| an archive runs: archive | joins the running archive |
|
||||
| an archive runs: undo | aborts the archive, waits for it to settle, then restores |
|
||||
| an undo runs: archive | waits for the undo, then archives |
|
||||
| an undo runs: undo | joins the running undo |
|
||||
|
||||
A request takes its place in the lane before its first `await`. Arrival order decides, so an undo
|
||||
sent right behind its archive still cancels it.
|
||||
|
||||
An aborted archive stops at its next checkpoint and reports `canceled`; the undo unarchives only
|
||||
the agents that archive itself archived. Deleting the worktree directory is the point of no
|
||||
return: an undo after it recreates the worktree through workspace recovery. The client shows or
|
||||
hides the row by the latest local intent, so a late remove or upsert from a superseded request
|
||||
does not flip it back. Tabs and layout are purged only once the undo window closes with the
|
||||
archive committed.
|
||||
|
||||
History navigation opens the selected agent without changing either archive state. Workspace
|
||||
**Restore** recovers only the workspace; the selected archived agent stays open with its callout.
|
||||
The agent's **Unarchive** runs the provider's native unarchive hook before interactive resume and
|
||||
@@ -334,13 +313,10 @@ Running provider-native subagents contribute `running` to the workspace owned by
|
||||
|
||||
A finished or failed workspace can be marked unread after it has been reviewed. The daemon restores
|
||||
attention on its newest eligible workspace-root agent, `error` for an agent whose lifecycle is
|
||||
`error` and `finished` otherwise, without sending a new notification. Opening a workspace restores
|
||||
its selected tab; attention on another agent remains unread until that agent is focused or the
|
||||
workspace is explicitly marked read.
|
||||
`error` and `finished` otherwise, without sending a new notification. Opening the workspace clears that attention through the normal focus flow.
|
||||
|
||||
Attention is set by the agent finishing or failing and acknowledged by deliberate focus or
|
||||
interaction with its chat, including repeat clicks in an already focused pane. A completion
|
||||
arriving while focused stays unread until the next interaction; leaving does not acknowledge it. Loading an agent's runtime is
|
||||
Attention is set by the agent finishing or failing and cleared by the client's
|
||||
`workspace.clear_attention`, which fires when the user reads the chat. Loading an agent's runtime is
|
||||
neither, so resuming carries the stored attention and the stored last-activity time through
|
||||
untouched. Forging either makes a background resume look like the user read a workspace and like the
|
||||
agent worked in it just now, which rewrites the sidebar timestamp permanently — persisted
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Agent stream disclosures
|
||||
|
||||
Expanding or collapsing something in the chat timeline (a turn's summary line, a tool-call group,
|
||||
a tool call's inline details, a todo card, a long user message's show more, a plan card) keeps the
|
||||
tapped trigger at the same screen y in the first painted frame. No animation.
|
||||
a tool call's inline details, a todo card) keeps the tapped trigger at the same screen y in the
|
||||
first painted frame. No animation.
|
||||
|
||||
## The contract
|
||||
|
||||
@@ -14,20 +14,12 @@ const handleToggle = () => hold(() => setExpanded((value) => !value));
|
||||
```
|
||||
|
||||
`view.tsx` wraps every timeline row in `DisclosureRow`, which routes `hold` to the strategy's
|
||||
`holdRow(rowId, apply, triggerEdge)` (`packages/app/src/agent-stream/disclosure.tsx`). Outside a
|
||||
timeline row, `apply` runs as is. The strategy owns when `apply` runs and how the row keeps its place.
|
||||
A toggle that calls `setState` directly skips the hold: on Android the expanded content paints
|
||||
over the next row for a frame before the list learns the new size.
|
||||
|
||||
`triggerEdge` names the edge of the row the trigger sits on, and the strategy keeps that edge in
|
||||
place. Header triggers sit on the top edge, the default. A long user message's show more sits under
|
||||
the text it opens, so `MessagePreview` passes `{ triggerEdge: "bottom" }`: the text grows and shrinks
|
||||
above the trigger, and the rows below stay put.
|
||||
`holdRow(rowId, apply)` (`packages/app/src/agent-stream/disclosure.tsx`). Outside a timeline row,
|
||||
`apply` runs as is. The strategy owns when `apply` runs and how the row keeps its place.
|
||||
|
||||
Following output:
|
||||
|
||||
- A hold detaches the bottom anchor controller (`holdForDisclosure`), except a bottom-edge hold on
|
||||
native while following: there the end pin already keeps the row's bottom in place.
|
||||
- A hold always detaches the bottom anchor controller (`holdForDisclosure`).
|
||||
- When the hold ends, the controller re-sticks only if the list sits at its end and no user scroll
|
||||
owns the viewport (`releaseDisclosure({ isAtEnd })`). Expanding at the bottom therefore leaves
|
||||
the list detached with the scroll-to-bottom pill showing; collapsing back to the end resumes
|
||||
@@ -41,16 +33,13 @@ ends at its end and the trigger moves by the difference.
|
||||
|
||||
## Web
|
||||
|
||||
`holdRow` pins the row's trigger edge in the reading anchor and runs `apply`; the next reading
|
||||
reconcile restores that edge after layout, before paint (`strategy-web.tsx`). The pin records the
|
||||
scroll offset it was taken at, because the browser clamps `scrollTop` when a collapse shrinks the
|
||||
content past the offset, before the reconcile reads it. Restoring from the clamped offset would
|
||||
apply the shrink twice.
|
||||
`holdRow` pins the row in the reading anchor and runs `apply`; the next reading reconcile restores
|
||||
the row's top after layout, before paint (`strategy-web.tsx`).
|
||||
|
||||
## Native
|
||||
|
||||
On compact layouts, tool calls and tool-call groups open a sheet instead of expanding, so they do
|
||||
not hold. Summary lines, todo cards, plan cards and show more expand inline.
|
||||
not hold. Summary lines and todo cards expand inline.
|
||||
|
||||
The timeline is a LegendList in forward order: one `data` array of history rows followed by the
|
||||
live head, oldest first (`strategy-native.tsx`). LegendList keeps a row in place across a size
|
||||
@@ -62,14 +51,14 @@ toggle; its applied commit took up to 3.5 s of JS on a long turn.
|
||||
|
||||
One owner keeps the scroll offset at any moment:
|
||||
|
||||
| State | Owner |
|
||||
| ---------------------- | ----------------------------------------------------------------------------------------------------------------- |
|
||||
| Before rows are placed | LegendList's initial scroll to the end. It keeps the rows hidden until they are measured. |
|
||||
| Following the end | The end pin (`native-end-pin.tsx`), also for a bottom-edge hold. |
|
||||
| Disclosure hold | LegendList's `maintainVisibleContentPosition` on the hold's anchor row, or the end pin for the last row's bottom. |
|
||||
| User scroll | The finger. The controller detaches at drag start. |
|
||||
| Detached | LegendList's `maintainVisibleContentPosition` on the first visible row. |
|
||||
| Keyboard motion | Nobody. The keyboard translates the list with the composer and leaves the offset alone. |
|
||||
| State | Owner |
|
||||
| ---------------------- | ----------------------------------------------------------------------------------------- |
|
||||
| Before rows are placed | LegendList's initial scroll to the end. It keeps the rows hidden until they are measured. |
|
||||
| Following the end | The end pin (`native-end-pin.tsx`). |
|
||||
| Disclosure hold | LegendList's `maintainVisibleContentPosition`, restoring only the held row. |
|
||||
| User scroll | The finger. The controller detaches at drag start. |
|
||||
| Detached | LegendList's `maintainVisibleContentPosition` on the first visible row. |
|
||||
| Keyboard motion | Nobody. The keyboard translates the list with the composer and leaves the offset alone. |
|
||||
|
||||
The end pin is a zero-height view at the bottom of the content and the content's first native
|
||||
child, with React Native's `maintainVisibleContentPosition` set to `{ minIndexForVisible: 0 }`. The
|
||||
@@ -89,38 +78,15 @@ jumps by the keyboard height after the keyboard settles.
|
||||
|
||||
### The hold
|
||||
|
||||
`holdRow(rowId, apply, triggerEdge)` runs in the toggle's event:
|
||||
`holdRow(rowId, apply)` runs in the toggle's event:
|
||||
|
||||
1. Pick the anchor and detach the controller (`holdForDisclosure`). LegendList restores row
|
||||
tops only, so a top-edge hold anchors the toggled row and a bottom-edge hold anchors the row
|
||||
below it, whose top is the toggled row's bottom (`disclosureAnchor`). The pin goes away
|
||||
and LegendList's `maintainVisibleContentPosition` takes over, restoring only the anchor row
|
||||
(`shouldRestorePosition`). A bottom-edge hold while following skips this step: the end pin
|
||||
keeps the content end, and the row's bottom with it, in the frame the new height mounts.
|
||||
|
||||
A bottom-edge hold on the last row, such as a prompt the agent has not answered yet, has no
|
||||
row below it: its bottom is the content end (the running indicator is the list footer, not a
|
||||
row). The hold mounts the end pin while detached, and `apply` runs a frame later. The pin
|
||||
anchors only once it is mounted, and a pin that mounts in the same commit as the new height
|
||||
anchors nothing. The pin's commit mounts before that frame's JS callbacks run, so `apply`'s
|
||||
commit mounts with the pin already in place. The pin keeps the distance from the end, and each
|
||||
content size change clamps it to the scroll room left, so the hold re-sticks if the list was
|
||||
at its end when tapped or a collapse used up the room. The release tracks that distance
|
||||
instead of reading the offset: the new content size reaches JS before the pin's scroll does.
|
||||
A reply landing below the row ends the pin in the commit that mounts the reply, so the reply
|
||||
grows below the row and the trigger stays where it was. A reply that lands before `apply` runs
|
||||
moves the hold to the reply's row.
|
||||
|
||||
Upstream LegendList anchors only rows from the first fully visible one down to the last fully
|
||||
visible one, and a size change with no data change keeps the first row in view whatever
|
||||
1. Mark the row held and detach the controller (`holdForDisclosure`). The pin goes away and
|
||||
LegendList's `maintainVisibleContentPosition` takes over, restoring only the held row
|
||||
(`shouldRestorePosition`). Upstream LegendList anchors only rows from the first fully visible
|
||||
one down, and a size change with no data change keeps the first row in view whatever
|
||||
`shouldRestorePosition` says. A row cut off at the top would then let the row below it hold
|
||||
instead, and a bottom-edge anchor just under the viewport would not hold at all. The LegendList
|
||||
patch counts the rows cut off at the top, and the row just past the bottom, as in view and
|
||||
applies `shouldRestorePosition` to size changes too. It also drops LegendList's Android clamp
|
||||
prediction: the scroll view clamps after the adjustment
|
||||
([scroll view corrections](#scroll-view-corrections)), so the prediction moved a collapse near
|
||||
the end to the end.
|
||||
|
||||
instead, moving the trigger by the row's size change. The LegendList patch counts rows cut off
|
||||
at the top as in view and applies `shouldRestorePosition` to size changes too.
|
||||
2. Run `apply`.
|
||||
3. Call the row's `useSyncLayout` trigger. The toggle changes the row from its own state, so
|
||||
LegendList's container does not re-render and would learn the new height from `onLayout` a task
|
||||
@@ -164,7 +130,7 @@ With every turn collapsed, flinging from a cold open into history did not blank
|
||||
|
||||
### LegendList patch
|
||||
|
||||
`patches/@legendapp+list+3.7.1.patch` carries twelve changes for the timeline. Its header lists
|
||||
`patches/@legendapp+list+3.7.1.patch` carries ten changes for the timeline. Its header lists
|
||||
them by purpose, and
|
||||
[legend-list #593](https://github.com/LegendApp/legend-list/issues/593) reports each upstream.
|
||||
Drop a hunk when the installed LegendList ships its fix.
|
||||
|
||||
@@ -34,7 +34,6 @@ So arrival sets a _target_ and the reveal rate is derived from the backlog inste
|
||||
- **First sight of a text is revealed whole.** Only growth is paced. This is what makes history hydration, timeline replay, a virtualized row remounting on scroll, and an already-finished message all render complete on first paint without a special case for each.
|
||||
- **Leaving `phase: "streaming"` snaps the reveal.** A completed turn must never be left holding characters. `layoutStream` sets the phase, so anything outside the live head with an active turn is already complete.
|
||||
- **The reducer queue commits on a frame, with a timer as the ceiling.** A frame callback never fires in a hidden tab, so a timer races it and wins when nothing is painting — the store has to keep advancing either way.
|
||||
- **A turn boundary commits the queue before turn liveness changes.** The final reply arrives a few milliseconds before `turn_completed`, so it is still queued when the turn ends. `applyStreamEvent` in `viewed-timeline-sync.ts` flushes the agent's queue first; otherwise the chat paints a finished turn with no reply, drops the footer, and jumps twice.
|
||||
- **A history row re-renders only when its item or layout item identity changes.** Every
|
||||
coalesced tick hands the list a new layout. Without a memo boundary each tick re-rendered every
|
||||
mounted row (measured on the inverted FlatList native used before LegendList: about 50 rows on a
|
||||
|
||||
+11
-2
@@ -179,8 +179,17 @@ This is the Android counterpart of the iOS local-simulator flow in [development.
|
||||
`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:
|
||||
lists the content-position corrections and the React Native issues they work around.
|
||||
|
||||
The timeline's `nativeID` (`agent-chat-scroll-native-virtualized`) also identifies it to the native
|
||||
scroll owner: a nested panel's edge overshoot and unconsumed fling must leave the timeline still.
|
||||
Ordinary scroll parents retain Android's handoff. Keep that identity in sync with
|
||||
`agent-stream/strategy-native.tsx`; the end pin is absent while reading history, so it cannot
|
||||
identify the timeline for this rule.
|
||||
|
||||
`VisibleContentPositionClampTest` mounts through React Native's own mounting manager;
|
||||
`PanelEdgeTest` covers both panel edges, flings, interior/direct scrolling, and ordinary-parent
|
||||
handoff with the real compiled scroll views:
|
||||
|
||||
```bash
|
||||
cd packages/app/android
|
||||
|
||||
@@ -276,7 +276,7 @@ its teardown; reconnect creates new server IDs for surviving client handles. See
|
||||
|
||||
**Top-level WS envelopes** are `hello`, `recording_state`, `ping`/`pong`, and `session` (which wraps the rich union of session messages).
|
||||
|
||||
Client liveness checks use the top-level JSON `ping`/`pong` envelope, not a session RPC or RFC6455 control ping. Current clients ping every 10 seconds, beginning one interval after connecting; a window with no validated inbound frame is a miss, and two misses in a row tear the socket down. When the app returns to the foreground the client runs a fresh cycle of two 5-second windows back to back, then reconnects without backoff, because a socket that died while the OS had JS suspended stays `OPEN` and accepts sends: only inbound frames prove it. Misses and probes from before the suspension do not count toward that cycle, and a window whose timer fires long after its deadline spanned another suspension, so it is discarded and the cycle starts over. Any complete frame inside a window keeps the connection, so a slow multi-frame transfer survives; a single frame that takes longer than the two windows to arrive after foreground does not, and the fix for that is smaller daemon-to-client frames, not a longer window. The first ping claims an application-ownership lease for that physical socket, all later inbound activity renews it, and the daemon forcibly terminates the socket if the lease expires. A legacy or raw socket that never sends an application ping never enters this lease and is not closed for omitting one. Session RPC timeouts are operation failures, not proof that the socket is dead. A subscription bootstrap timeout closes its source to clear any unknown server-owned registration; the failed handle is released before other surviving handles reconnect.
|
||||
Client liveness checks use the top-level JSON `ping`/`pong` envelope, not a session RPC or RFC6455 control ping. Current clients ping every 10 seconds, beginning one interval after connecting. The first ping claims an application-ownership lease for that physical socket, all later inbound activity renews it, and the daemon forcibly terminates the socket if the lease expires. A legacy or raw socket that never sends an application ping never enters this lease and is not closed for omitting one. Session RPC timeouts are operation failures, not proof that the socket is dead. A subscription bootstrap timeout closes its source to clear any unknown server-owned registration; the failed handle is released before other surviving handles reconnect.
|
||||
|
||||
Every physical send path enforces an 8 MiB outbound high-water mark, including JSON broadcasts, binary terminal frames, and the encrypted relay adapter's asynchronous queue. This sits above the terminal stream's 4 MiB soft backpressure threshold, leaving room for snapshot catch-up before the hard cutoff. JSON is serialized once per broadcast after sockets already at the limit are removed, then its exact byte length is checked for every remaining socket. A frame that would cross the limit is not sent; that physical socket is forcibly terminated without disturbing other sockets attached to the same logical session. Multiple tabs and simultaneous direct and relay paths may legitimately share a client id.
|
||||
|
||||
|
||||
+2
-6
@@ -68,11 +68,7 @@ the sidebar. Apply it as `backgroundColor`; setting `opacity` on the control als
|
||||
|
||||
`destructive` is filled with `destructive`. It only appears inside a confirm. The button on the page is `outline`; the destructive button is the confirm button inside the dialog.
|
||||
|
||||
Controls come in two sizes, defined once in `control-geometry.ts`. The default is the right size almost everywhere — pages, dialogs, forms, settings rows, the setup flow — so don't pass a size. It is 32px tall where a pointer drives it and 44px where controls are touch-sized, so every touch control meets the iOS HIG's 44pt hit region. `sm` is 28px pointer and 32px touch, with `fontSize.sm` labels, and is for tight chrome only: toolbars, nested settings rows, dense inline row actions. A `size="sm"` on an ordinary page or dialog is wrong, and so is choosing a size from `useIsCompactFormFactor()`.
|
||||
|
||||
Dense toolbar rows, such as the composer's, are the exception to the 44pt control. Touch-sized the row is 44pt tall and each control is a 36pt circle (`TOOLBAR_DISC_SIZE`): send and stop fill it, and every other control's pressed highlight fills it, so every highlight is the same fully rounded shape. Labelled pickers are 36pt pills. A control lays out at the width of what it paints (`toolbarSlot`, `toolbarContentSlot`) and its circle reaches into the gaps, so `TOOLBAR_CONTROL_GAP`, 20pt, is the visible space between glyphs. Toolbar glyphs match the header icons: 20pt, the app's 1.5px stroke, resting at `foreground` (`toolbarGlyphsFor`). The context ring is 18pt with a 2px stroke (`TOOLBAR_RINGS`): a circle fills its box where an outline glyph does not, and a thin circle reads lighter than a glyph at the same stroke. A disc's solid glyph, such as the stop square, stays at the `md` size (`TOOLBAR_DISC_GLYPH_SIZES`). Pointer-sized toolbars keep 28px controls, a 4px gap, and a 16pt muted glyph that brightens on hover.
|
||||
|
||||
In the composer, the first glyph's ink starts on the text column's edge, and the trailing control is always a disc, so the row does not shift as it changes between voice mode, send and stop. The disc nests into the corner, as far from the right edge as from the bottom, and touch-sized the composer's corner radius is concentric with it (`packages/app/src/composer/input/toolbar-rails.ts`).
|
||||
Controls come in two sizes, defined once in `control-geometry.ts`. The default is the right size almost everywhere — pages, dialogs, forms, settings rows, the setup flow — so don't pass a size. It is 32px tall where a pointer drives it and 44px where controls are touch-sized, so every touch control meets the iOS HIG's 44pt hit region. The hit region is not the painted area: a filled toolbar control such as the composer's send or stop keeps the 44pt frame and paints a 36pt disc inside it (`TOOLBAR_DISC_SIZE`), so it does not outweigh its unpainted neighbours. `sm` is 28px pointer and 32px touch, with `fontSize.sm` labels, and is for tight chrome only: toolbars, nested settings rows, dense inline row actions. A `size="sm"` on an ordinary page or dialog is wrong, and so is choosing a size from `useIsCompactFormFactor()`.
|
||||
|
||||
One gate, `resolveControlSizing`, picks touch or pointer sizing: touch when the layout is compact or the device has a touch screen. An iPad with the sidebar docked is wide and touch-sized; a desktop is pointer-sized; a phone is both compact and touch. The touch-screen fact is read once at startup (`hasTouchScreen` in `hooks/use-fine-pointer.ts`), so attaching a mouse or keyboard to a tablet never shrinks its controls. Stylesheets take sizes from `bySizing({ touch, pointer })`, which resolves to a breakpoint value, so a caller never sees which sizing it got. Rows, icon buttons and icon slots take theirs from `styles/density.ts`, which uses the same gate. An `{ xs, md }` literal for a hit target or glyph is wrong: on an iPad it yields the pointer size.
|
||||
|
||||
@@ -144,7 +140,7 @@ Things align to their glyphs, not to their boxes. A row's leading icon, its titl
|
||||
|
||||
Pick the rails from the content, then hold them. A settings card establishes a leading rail at the icon's left edge and a trailing rail at the last glyph's right edge; every row in that card uses the same two. A row whose icon is absent still starts its title on the leading rail. Indentation is a new rail, not an arbitrary offset.
|
||||
|
||||
The pressable is bigger than the glyph, and that is fine. Hit areas grow outward from the aligned content — they never move it. A button that looks two pixels off because its padding is asymmetric is misaligned even though its box is correct. The composer toolbar is the worked example: its outermost ink, the attach `+` on the left and the trailing glyph or send/stop disc on the right, sits on the text column's edges, and the 44pt touch slots overhang into the composer padding by each end control's ink inset (`composerToolbarOverhang`).
|
||||
The pressable is bigger than the glyph, and that is fine. Hit areas grow outward from the aligned content — they never move it. A button that looks two pixels off because its padding is asymmetric is misaligned even though its box is correct.
|
||||
|
||||
Optical alignment beats arithmetic when a glyph disagrees with its bounding box. Icons with visual weight on one side, chevrons, and single-character labels usually need a small nudge to look centered. Trust the eye, then leave a comment saying the offset is optical.
|
||||
|
||||
|
||||
+1
-1
@@ -68,7 +68,7 @@ When app-wide routes such as `/new`, `/settings`, or `/sessions` navigate back
|
||||
into a host workspace, use `navigateToWorkspace()`. Do not make the caller
|
||||
branch on its current route.
|
||||
|
||||
Pass only `serverId` and `workspaceId` to restore the workspace’s selected tab.
|
||||
Pass only `serverId` and `workspaceId` for normal attention-aware navigation.
|
||||
When the action names a specific tab, pass it as `target`; that explicit choice
|
||||
is authoritative. Callers should not choose between separate route and tab
|
||||
navigation APIs.
|
||||
|
||||
+1
-1
@@ -135,7 +135,7 @@ Heuristic: if your hover state is going to be `useState`'d and read by anything
|
||||
|
||||
Sometimes the revealed content can't live inside the trigger — a hover card portals into a different layer, a tooltip floats above other content, a popover renders into a `Portal`. There's a real visual gap the user has to cross with the cursor.
|
||||
|
||||
For a card whose content the user can point at or press, use `HoverCard` (`packages/app/src/components/ui/hover-card.tsx`). It is built on `useHoverSafeZone` (`packages/app/src/hooks/use-hover-safe-zone.ts`), which computes a rectangular "bridge" across the gap between the trigger and the content, whether the card sits beside, above, or below the trigger. While the pointer is inside trigger, content, or the bridge, the card stays open. A short grace timer absorbs jitter at the edges. Only one card per overlay layer is open: pointing at another trigger replaces the open card at once, so a sweep down a list never shows two cards or leaves one behind. `Tooltip` takes no pointer events and closes as soon as the pointer leaves the trigger. If the trigger has no focusable child, give `HoverCardTrigger` `focusable` and an accessible label so keyboard users can open the same content.
|
||||
For a card whose content the user can point at or press, use `HoverCard` (`packages/app/src/components/ui/hover-card.tsx`). It is built on `useHoverSafeZone` (`packages/app/src/hooks/use-hover-safe-zone.ts`), which computes a rectangular "bridge" across the gap between the trigger and the content, whether the card sits beside, above, or below the trigger. While the pointer is inside trigger, content, or the bridge, the card stays open. A short grace timer absorbs jitter at the edges. `Tooltip` takes no pointer events and closes as soon as the pointer leaves the trigger. If the trigger has no focusable child, give `HoverCardTrigger` `focusable` and an accessible label so keyboard users can open the same content.
|
||||
|
||||
Don't roll your own. The math is annoying, the edge cases (pointer leaves window, drag in progress, content unmounts) are subtle, and we already paid for the hook.
|
||||
|
||||
|
||||
@@ -84,10 +84,3 @@ its DOM-derived scope.
|
||||
Escape interrupts an agent only in the message input or an unclaimed surface.
|
||||
A focused terminal excludes that binding. The source leaves terminal Escape alone;
|
||||
terminal Escape/Ctrl forwarding is a separate feature.
|
||||
|
||||
## Keys shared with text editing
|
||||
|
||||
A binding on a key the platform already uses for editing, such as Cmd/Ctrl+Z for
|
||||
workspace archive undo, declares `editable: false` and registers its handler with
|
||||
`isActive` true only while it has work. An inactive handler leaves the key
|
||||
unhandled, so the pipeline skips `preventDefault` and the platform's own undo runs.
|
||||
|
||||
@@ -570,13 +570,6 @@ Register a usage source from `index.server.ts` with `server.registerUsageSource(
|
||||
|
||||
When the host’s **Check usage** switch is off, the daemon rejects usage requests before discovery, without reading logins or calling vendors. Otherwise, the daemon calls discovery for `usage.list_reports`; the client gates this RPC on `server_info.features.usageSources`. The old `provider.usage.list` RPC maps discovered reports for older clients.
|
||||
|
||||
## Replace sidebar content
|
||||
|
||||
The sidebar view slot is global across hosts, while header and footer contributions retain their
|
||||
host selection. The slot owns selection loading, fallback and the error boundary; the plugin owns
|
||||
its layout. This keeps plugin failures from removing navigation or recovery controls. The
|
||||
[sidebar view guide](../public-docs/plugins/sidebar-views.md) owns the author API.
|
||||
|
||||
## Contribute sidebar items
|
||||
|
||||
Sidebar header and footer items are plugin components, not descriptors. The
|
||||
|
||||
@@ -15,20 +15,17 @@ supervisor (paseo.pid) ──► daemon worker ──► launcher ──► term
|
||||
- The worker exits on its own when it has no controller and no terminals, on `shutdown`, on SIGTERM (killing its terminals), and when its record disappears (a deleted `$PASEO_HOME`: nobody can ever reach it again).
|
||||
- Its stderr goes to `$PASEO_HOME/terminal-worker.log`. No pipe to the daemon survives the daemon.
|
||||
|
||||
The worker retains a fixed 10,000 history rows plus the screen in its existing xterm. Replay uses `@xterm/addon-serialize` 0.14 against headless xterm 6; no second cell grid or lifetime byte log is retained. The serializer boundary owns cursor position, visibility and explicit program-selected presentation; the addon alone does not preserve all three. An unset program style leaves each client’s configured cursor defaults intact. It also restores SGR mouse encoding: the addon preserves mouse tracking but omits its encoding, so switching away and back otherwise sends legacy wheel reports that Copilot CLI cannot use. These missing modes require xterm's internal services because its public modes API omits them. Standard modes come from the serializer; the existing input-mode tracker retains protocol modes such as Kitty and Win32 input. Commit that tracker with parsed output so replay bytes and metadata describe the same cut.
|
||||
The worker retains a fixed 10,000 history rows plus the screen in its existing xterm. Replay uses `@xterm/addon-serialize` 0.14 against headless xterm 6; no second cell grid or lifetime byte log is retained. The serializer boundary owns cursor position, visibility and explicit program-selected presentation; the addon alone does not preserve all three. An unset program style leaves each client’s configured cursor defaults intact. Standard modes come from the serializer; the existing input-mode tracker retains protocol modes such as Kitty and Win32 input. Commit that tracker with parsed output so replay bytes and metadata describe the same cut.
|
||||
|
||||
Which triggers keep terminals and which close them is owned by the stop reason (`keepTerminals`) that flows from the request through the supervisor to `stop()`. The kill side always ends in the reaper (`terminal-worker-reaper.ts`), the only path that signals terminal processes outside a connected daemon.
|
||||
|
||||
## Record, socket and secret
|
||||
|
||||
The worker writes `$PASEO_HOME/terminal-worker/<pid>.json` (owner-only) with its pid, protocol version, socket path, random secret, runtime copy and terminal programs. One daemon holds the home's pid lock. Its records and sockets belong to that home.
|
||||
The worker writes `$PASEO_HOME/terminal-worker/<pid>.json` (owner-only) with its identity (birth time + command line carrying `--paseo-terminal-worker <recordId>`), protocol version, socket path, a random secret, the copy it runs from, and each terminal program's pid and birth time.
|
||||
|
||||
- The first frame must be `hello` with the secret; anything else closes the connection. Anyone authenticated can type into every shell, so the record and Unix socket are `0600` inside a `0700` directory.
|
||||
- New socket names include the record id. A stale record whose pid belongs to a newer worker cannot reach that newer worker. Existing records keep their recorded paths. Legacy paths use only the home and pid, so force-killing through one also requires successful authentication. A legacy socket that cannot authenticate stays unconfirmed.
|
||||
- A missing pid or refused socket retires the record without signaling the worker. An accepting socket identifies the live worker. Cleanup asks it to shut down, then probes again before force-killing it after the grace period (`taskkill /T` on Windows). An inconclusive probe counts toward the worker limit and is retried on the next start or stop without signaling it.
|
||||
- Workers never query their own birth time or command line. Readers accept legacy identity fields but ignore them. Daemons through 0.15.1 report new records unreadable and cannot adopt them.
|
||||
- Windows terminal programs have no recorded birth time and terminal lifecycle operations never launch PowerShell. Linux and macOS retain program birth-time checks through `ps` with `LC_ALL=C`; Linux also walks sessions to find surviving jobs.
|
||||
- Unix socket paths have a byte budget (103 on macOS, 107 on Linux). Long dev homes use an owner-only fallback under `$TMPDIR/paseo-<uid>/` or `/tmp/paseo-<uid>/`. An existing directory must be a real `0700` directory owned by you; a shared root is never chmodded.
|
||||
- The first frame on the socket must be `hello` with the secret; anything else closes the connection. Anyone who connects can type into every shell, so the record is `0600`, the unix socket `0600` in a `0700` directory.
|
||||
- Unix socket paths have a byte budget (103 on macOS, 107 on Linux). Dev homes under worktrees blow it, so `terminal-worker-socket-path.ts` tries `$PASEO_HOME/terminal-worker/sock/`, then `$TMPDIR/paseo-<uid>/`, then `/tmp/paseo-<uid>/`, and accepts an existing directory only if it is a real `0700` directory owned by you. It never chmods a shared root.
|
||||
- Cleanup signals a process only when its birth time matches the record. Birth times come from `ps -o lstart` with `LC_ALL=C` (it follows `LC_TIME` otherwise) and from `Win32_Process.CreationDate`. A missing birth time is "unknown", never matched by command text: a shell that ran `exec` keeps its pid and birth time but not its argv.
|
||||
- A kill cleanup that cannot confirm every process gone writes `closing-<recordId>.json` beside the worker record. The worker rewrites its own record whenever a terminal starts or exits, so the kill intent has to live in a file it never writes. A closing record is never adopted; the next stop or start retries it.
|
||||
- An unreadable record of either kind makes `daemon status` and a kill restart fail with its path: a kill cleanup that met it could not check what it names, and leaves nothing else behind. A read that fails with `EPERM`, `EBUSY` or `EACCES` is retried for 250ms first: on Windows, opening a record while the worker renames its new copy over it fails that way for a moment.
|
||||
|
||||
@@ -43,7 +40,7 @@ The worker writes `$PASEO_HOME/terminal-worker/<pid>.json` (owner-only) with its
|
||||
| two or more older | shuts it down; its terminals close (logged) |
|
||||
| newer, or a version it cannot read | leaves it running, undriven; `list_terminals` and `create_terminal` answer `rpc_error` `terminal_worker_incompatible`, and `paseo daemon status` prints why |
|
||||
|
||||
Never more than two workers run. A new worker launches only when none is current, none is incompatible and fewer than two run. The count comes from the records, so an unreachable worker counts, as does one whose socket probe is inconclusive, and one a cleanup could not confirm gone. When two run, the daemon starts with no current worker, and the first terminal created after one of them exits launches it.
|
||||
Never more than two workers run. A new worker launches only when none is current, none is incompatible and fewer than two run. The count comes from the records, so an unreachable or unidentified worker counts, and so does one a cleanup could not confirm gone. When two run, the daemon starts with no current worker, and the first terminal created after one of them exits launches it.
|
||||
|
||||
**The frozen envelope never changes across versions:** the frame format (4-byte length + `v8.serialize`), `hello`/`helloAck`, the `listTerminals` info fields and `shutdown`. That is what lets any daemon close any worker: a kill stop sends `shutdown` to a newer worker it cannot otherwise drive. Protocol 5 hard cuts unreleased protocols 1 through 4, including the acknowledgement-based replay contract: those workers are obsolete and close. For future versions, keep a previous-version adapter so the draining worker can still be driven, and drop the adapter for the version before that.
|
||||
|
||||
|
||||
@@ -69,8 +69,8 @@ Source coverage still includes interleaved tool updates.
|
||||
|
||||
Canonical IDs define runs. With missing IDs, human prompts, heartbeats and typed received
|
||||
agent messages each start a fallback turn. This deterministic fallback is shared by paging
|
||||
and presentation; it cannot recover historical batching or steering. An underfilled viewport
|
||||
loads older history until it fills or history is exhausted. Further history is user-driven.
|
||||
and presentation; it cannot recover historical batching or steering. Older history remains
|
||||
user-driven by scrolling upward.
|
||||
|
||||
A failed catch-up or subscription reconcile retries on its own, doubling from 1s to a 30s ceiling.
|
||||
A fixed 1s retry turned a persistent daemon-side refusal — a Codex thread that already has an active
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
Fixed
|
||||
- Fixed conversation text becoming blurred when opening or dismissing the keyboard on iOS 26+
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -1,4 +0,0 @@
|
||||
Fixed
|
||||
- Fixed conversation text becoming blurred when opening or dismissing the keyboard on iOS 26+
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -1,4 +0,0 @@
|
||||
Fixed
|
||||
- Fixed conversation text becoming blurred when opening or dismissing the keyboard on iOS 26+
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -1,4 +0,0 @@
|
||||
Fixed
|
||||
- Fixed conversation text becoming blurred when opening or dismissing the keyboard on iOS 26+
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-vwivjBU5VFpM9VD7Wx4IKHN6xEVlg/KBMJCCMuA1JEw=
|
||||
sha256-wO0vJVhh1urkt4q9NiQ0RSXUj++WWuHTneWSPelU+JQ=
|
||||
|
||||
Generated
+28
-28
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.15.3",
|
||||
"version": "0.15.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.15.3",
|
||||
"version": "0.15.2",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
@@ -37468,7 +37468,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.15.3",
|
||||
"version": "0.15.2",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/language": "6.12.4",
|
||||
@@ -39637,13 +39637,13 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.15.3",
|
||||
"version": "0.15.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.15.3",
|
||||
"@getpaseo/protocol": "0.15.3",
|
||||
"@getpaseo/server": "0.15.3",
|
||||
"@getpaseo/client": "0.15.2",
|
||||
"@getpaseo/protocol": "0.15.2",
|
||||
"@getpaseo/server": "0.15.2",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -39890,11 +39890,11 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.15.3",
|
||||
"version": "0.15.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.15.3",
|
||||
"@getpaseo/relay": "0.15.3",
|
||||
"@getpaseo/protocol": "0.15.2",
|
||||
"@getpaseo/relay": "0.15.2",
|
||||
"ws": "^8.20.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
@@ -39906,7 +39906,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.15.3",
|
||||
"version": "0.15.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -40149,7 +40149,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.15.3",
|
||||
"version": "0.15.2",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -40195,7 +40195,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.15.3",
|
||||
"version": "0.15.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "6.12.4",
|
||||
@@ -40225,7 +40225,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.15.3",
|
||||
"version": "0.15.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^1.4.0",
|
||||
@@ -40233,8 +40233,8 @@
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.15.3",
|
||||
"@getpaseo/protocol": "0.15.3",
|
||||
"@getpaseo/client": "0.15.2",
|
||||
"@getpaseo/protocol": "0.15.2",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "~19.2.0",
|
||||
"react": "19.2.3",
|
||||
@@ -40244,8 +40244,8 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@getpaseo/client": "0.15.3",
|
||||
"@getpaseo/protocol": "0.15.3",
|
||||
"@getpaseo/client": "0.15.2",
|
||||
"@getpaseo/protocol": "0.15.2",
|
||||
"react": ">=19.1.0 <20",
|
||||
"react-native": ">=0.81.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -40267,7 +40267,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.15.3",
|
||||
"version": "0.15.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"semver": "^7.8.5",
|
||||
@@ -40295,7 +40295,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.15.3",
|
||||
"version": "0.15.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@noble/ciphers": "^2.4.0",
|
||||
@@ -40517,17 +40517,17 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.15.3",
|
||||
"version": "0.15.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.15.3",
|
||||
"@getpaseo/highlight": "0.15.3",
|
||||
"@getpaseo/plugin": "0.15.3",
|
||||
"@getpaseo/protocol": "0.15.3",
|
||||
"@getpaseo/relay": "0.15.3",
|
||||
"@getpaseo/client": "0.15.2",
|
||||
"@getpaseo/highlight": "0.15.2",
|
||||
"@getpaseo/plugin": "0.15.2",
|
||||
"@getpaseo/protocol": "0.15.2",
|
||||
"@getpaseo/relay": "0.15.2",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -41474,7 +41474,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.15.3",
|
||||
"version": "0.15.2",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
@@ -43084,7 +43084,7 @@
|
||||
},
|
||||
"plugins": {
|
||||
"name": "@getpaseo/builtin-plugins",
|
||||
"version": "0.15.3",
|
||||
"version": "0.15.2",
|
||||
"dependencies": {
|
||||
"@getpaseo/plugin": "*",
|
||||
"zod": "^4.4.3"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.15.3",
|
||||
"version": "0.15.2",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
|
||||
@@ -18,10 +18,8 @@ test.describe("Agent timeline sync retry", () => {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
const agent = await seedLongMockAgentTimeline({ turns: 2 });
|
||||
try {
|
||||
gate.holdNextClientRequest("clear_agent_attention");
|
||||
await openAgentTimeline(page, agent);
|
||||
await expectTimelinePromptVisible(page, agent.newestPrompt);
|
||||
await gate.waitForHeldClientRequest();
|
||||
|
||||
gate.failTimelineResponses(agent.agentId);
|
||||
await disconnectViewedTimeline(page, gate);
|
||||
|
||||
@@ -1,327 +0,0 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test as baseTest } from "../support/fixtures";
|
||||
import { expectAgentIdle } from "../support/helpers/agent-stream";
|
||||
import { submitMessage } from "../support/helpers/composer";
|
||||
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||
import {
|
||||
openAgentRoute,
|
||||
seedMockAgentWorkspace,
|
||||
type MockAgentWorkspace,
|
||||
} from "../support/helpers/mock-agent";
|
||||
|
||||
// A turn ends with its final reply, `turn_completed`, and the idle status snapshot arriving
|
||||
// within a few milliseconds of each other. These tests hold all three, let the running
|
||||
// turn paint, then release them together and sample every painted frame.
|
||||
|
||||
interface Box {
|
||||
top: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface TurnEndFrame {
|
||||
at: number;
|
||||
running: boolean;
|
||||
footer: Box | null;
|
||||
userRow: Box | null;
|
||||
replyVisible: boolean;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__turnEndFrames?: { active: boolean; frames: TurnEndFrame[] };
|
||||
}
|
||||
}
|
||||
|
||||
const test = baseTest.extend<{ agent: MockAgentWorkspace }>({
|
||||
agent: async ({ page: _page }, provide) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "turn-end-",
|
||||
title: "Turn end",
|
||||
initialPrompt: "emit 4 tool agent stream updates",
|
||||
});
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
await provide(agent);
|
||||
await agent.cleanup();
|
||||
},
|
||||
});
|
||||
|
||||
async function recordTurnEndFrames(
|
||||
page: Page,
|
||||
input: { prompt: string; reply: string | null },
|
||||
): Promise<void> {
|
||||
await page.evaluate(({ prompt, reply }) => {
|
||||
const state = { active: true, frames: [] as TurnEndFrame[] };
|
||||
const isVisible = (element: Element | null | undefined) => Boolean(element?.checkVisibility());
|
||||
const boxOf = (element: Element | null | undefined): Box | null => {
|
||||
if (!element || !isVisible(element)) return null;
|
||||
const rect = element.getBoundingClientRect();
|
||||
return rect.height > 0 ? { top: rect.top, height: rect.height } : null;
|
||||
};
|
||||
const sample = () => {
|
||||
const viewport = Array.from(
|
||||
document.querySelectorAll('[data-testid="agent-chat-scroll"]'),
|
||||
).find((candidate) => isVisible(candidate));
|
||||
const footers = Array.from(viewport?.querySelectorAll('[data-testid="turn-footer"]') ?? [])
|
||||
.map(boxOf)
|
||||
.filter((box): box is Box => box !== null)
|
||||
.sort((left, right) => left.top - right.top);
|
||||
const userRow = Array.from(
|
||||
viewport?.querySelectorAll('[data-testid="user-message"]') ?? [],
|
||||
).find((candidate) => candidate.textContent?.includes(prompt));
|
||||
const replyRow =
|
||||
reply === null
|
||||
? undefined
|
||||
: Array.from(viewport?.querySelectorAll('[data-testid="assistant-message"]') ?? []).find(
|
||||
(candidate) => candidate.textContent?.includes(reply),
|
||||
);
|
||||
state.frames.push({
|
||||
at: performance.now(),
|
||||
running: Boolean(viewport?.querySelector('[data-testid="turn-working-indicator"]')),
|
||||
footer: footers.at(-1) ?? null,
|
||||
userRow: boxOf(userRow),
|
||||
replyVisible: boxOf(replyRow) !== null,
|
||||
});
|
||||
if (state.active) requestAnimationFrame(() => setTimeout(sample, 0));
|
||||
};
|
||||
window.__turnEndFrames = state;
|
||||
requestAnimationFrame(() => setTimeout(sample, 0));
|
||||
}, input);
|
||||
}
|
||||
|
||||
async function recordPaintsFor(page: Page, durationMs: number): Promise<void> {
|
||||
const until = await page.evaluate((duration) => performance.now() + duration, durationMs);
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => window.__turnEndFrames?.frames.at(-1)?.at ?? 0))
|
||||
.toBeGreaterThanOrEqual(until);
|
||||
}
|
||||
|
||||
async function stopRecording(page: Page): Promise<TurnEndFrame[]> {
|
||||
return page.evaluate(() => {
|
||||
const state = window.__turnEndFrames;
|
||||
if (!state) throw new Error("Turn end frames were not recorded");
|
||||
state.active = false;
|
||||
return state.frames;
|
||||
});
|
||||
}
|
||||
|
||||
async function recordTurnEnd(
|
||||
page: Page,
|
||||
agent: MockAgentWorkspace,
|
||||
input: { prompt: string; reply: string | null },
|
||||
): Promise<TurnEndFrame[]> {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId });
|
||||
await expectAgentIdle(page);
|
||||
|
||||
if (input.reply === null) {
|
||||
gate.setAgentStreamItemSuppressed("assistant_message", true);
|
||||
} else {
|
||||
gate.holdNextAgentStreamItem("assistant_message");
|
||||
}
|
||||
gate.holdNextAgentStreamEvent("turn_completed");
|
||||
// The daemon sends more than one idle snapshot; hold the first and drop the rest until release.
|
||||
gate.holdNextAgentUpdate(agent.agentId, "idle");
|
||||
gate.setServerMessageSuppressed("agent_update", true);
|
||||
await recordTurnEndFrames(page, input);
|
||||
await submitMessage(page, input.prompt);
|
||||
await Promise.all([
|
||||
input.reply === null ? Promise.resolve() : gate.waitForHeldAgentStreamItem("assistant_message"),
|
||||
gate.waitForHeldAgentStreamEvent("turn_completed"),
|
||||
gate.waitForHeldAgentUpdate(agent.agentId, "idle"),
|
||||
]);
|
||||
await expect(page.getByTestId("turn-working-indicator")).toBeVisible();
|
||||
await recordPaintsFor(page, 200);
|
||||
|
||||
if (input.reply !== null) gate.releaseHeldAgentStreamItem("assistant_message");
|
||||
gate.releaseHeldAgentStreamEvent("turn_completed");
|
||||
gate.releaseHeldAgentUpdate(agent.agentId, "idle");
|
||||
gate.setServerMessageSuppressed("agent_update", false);
|
||||
await expectAgentIdle(page);
|
||||
await recordPaintsFor(page, 300);
|
||||
return stopRecording(page);
|
||||
}
|
||||
|
||||
function describeFrames(frames: TurnEndFrame[]): string {
|
||||
return frames
|
||||
.map(
|
||||
(frame, index) =>
|
||||
`${index}: ${frame.running ? "running " : "complete"} footer=${
|
||||
frame.footer ? `${frame.footer.top.toFixed(1)}+${frame.footer.height.toFixed(1)}` : "none"
|
||||
} prompt=${frame.userRow?.top.toFixed(1) ?? "none"} reply=${frame.replyVisible}`,
|
||||
)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function expectSteadyTurnEnd(frames: TurnEndFrame[], input: { reply: string | null }): void {
|
||||
const firstRunning = frames.findIndex((frame) => frame.running && frame.footer !== null);
|
||||
const firstComplete = frames.findIndex((frame, index) => index > firstRunning && !frame.running);
|
||||
const record = `frames:\n${describeFrames(frames)}`;
|
||||
expect(firstRunning, record).toBeGreaterThanOrEqual(0);
|
||||
expect(firstComplete, record).toBeGreaterThan(firstRunning);
|
||||
|
||||
const transition = frames.slice(firstRunning);
|
||||
const runningHeight = transition[0].footer?.height;
|
||||
expect(
|
||||
transition.map((frame) => frame.footer?.height ?? null),
|
||||
`the footer must stay painted at one height\n${record}`,
|
||||
).toEqual(transition.map(() => runningHeight));
|
||||
if (input.reply !== null) {
|
||||
expect(
|
||||
frames.slice(firstComplete).every((frame) => frame.replyVisible),
|
||||
`a completed turn must show its reply in the same frame\n${record}`,
|
||||
).toBe(true);
|
||||
}
|
||||
// Tool rows land while the turn runs; only the turn end itself must be one step.
|
||||
const ending = frames.slice(firstComplete - 1);
|
||||
const promptMoves = ending
|
||||
.slice(1)
|
||||
.filter((frame, index) => frame.userRow?.top !== ending[index].userRow?.top);
|
||||
expect(
|
||||
promptMoves.length,
|
||||
`the prompt must move at most once as the turn ends\n${record}`,
|
||||
).toBeLessThanOrEqual(1);
|
||||
}
|
||||
|
||||
test.describe("Turn end", () => {
|
||||
test("ends a plain reply without moving or dropping its footer", async ({ page, agent }) => {
|
||||
const input = { prompt: "emit 1 coalesced agent stream updates", reply: "stress-update-0" };
|
||||
expectSteadyTurnEnd(await recordTurnEnd(page, agent, input), input);
|
||||
});
|
||||
|
||||
test("ends a tool turn with its reply and completed footer together", async ({ page, agent }) => {
|
||||
const input = { prompt: "emit 3 tool agent stream updates", reply: "Completed 3 commands" };
|
||||
expectSteadyTurnEnd(await recordTurnEnd(page, agent, input), input);
|
||||
});
|
||||
|
||||
test("keeps the footer height when a turn ends on a tool call", async ({ page, agent }) => {
|
||||
const input = { prompt: "emit 2 tool agent stream updates", reply: null };
|
||||
expectSteadyTurnEnd(await recordTurnEnd(page, agent, input), input);
|
||||
});
|
||||
});
|
||||
|
||||
async function expectTimingInsideFooter(page: Page): Promise<void> {
|
||||
const label = page.getByRole("button", { name: /^Worked for/ }).last();
|
||||
const paintedLines = await label.getByTestId("turn-footer-timing-label").evaluate((element) => {
|
||||
const labelBounds = element.parentElement!.getBoundingClientRect();
|
||||
const rowBounds = element.closest('[data-testid="turn-footer"]')!.getBoundingClientRect();
|
||||
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
|
||||
const lines: boolean[] = [];
|
||||
while (walker.nextNode()) {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(walker.currentNode);
|
||||
for (const rect of range.getClientRects()) {
|
||||
lines.push(
|
||||
rect.left >= Math.max(rowBounds.left, labelBounds.left) - 0.5 &&
|
||||
rect.right <= Math.min(rowBounds.right, labelBounds.right) + 0.5 &&
|
||||
rect.top >= Math.max(rowBounds.top, labelBounds.top) - 0.5 &&
|
||||
rect.bottom <= Math.min(rowBounds.bottom, labelBounds.bottom) + 0.5,
|
||||
);
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
});
|
||||
expect(paintedLines.length).toBeGreaterThan(0);
|
||||
expect(paintedLines.every(Boolean)).toBe(true);
|
||||
}
|
||||
|
||||
async function expectFooterWithinWidth(page: Page, width: number): Promise<void> {
|
||||
const footer = page.getByTestId("turn-footer").last();
|
||||
await footer.evaluate((element, availableWidth) => {
|
||||
element.style.width = `${availableWidth}px`;
|
||||
}, width);
|
||||
const label = page.getByRole("button", { name: /^Worked for/ }).last();
|
||||
await expect(label).toBeVisible();
|
||||
await expectTimingInsideFooter(page);
|
||||
const controls = footer.getByRole("button", { name: /Copy turn|Fork chat/ });
|
||||
const before = await controls.evaluateAll((elements) =>
|
||||
elements.map((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return { x: rect.x, y: rect.y };
|
||||
}),
|
||||
);
|
||||
const footerBefore = await footer.boundingBox();
|
||||
const labelBefore = await label.boundingBox();
|
||||
await label.hover({ position: { x: labelBefore!.width - 2, y: labelBefore!.height - 2 } });
|
||||
await expect(label.getByTestId("turn-footer-timing-label")).toContainText(/\d:\d\d/);
|
||||
expect(await label.boundingBox()).toMatchObject({
|
||||
x: labelBefore!.x,
|
||||
width: labelBefore!.width,
|
||||
height: labelBefore!.height,
|
||||
});
|
||||
expect(
|
||||
await controls.evaluateAll((elements) =>
|
||||
elements.map((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return { x: rect.x, y: rect.y };
|
||||
}),
|
||||
),
|
||||
).toEqual(before);
|
||||
expect(await footer.boundingBox()).toMatchObject({
|
||||
x: footerBefore!.x,
|
||||
y: footerBefore!.y,
|
||||
width: footerBefore!.width,
|
||||
});
|
||||
await expectTimingInsideFooter(page);
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(label.getByTestId("turn-footer-timing-label")).toContainText("Worked for");
|
||||
}
|
||||
|
||||
test("footer timing fits the chat column and swapping labels keeps controls in place", async ({
|
||||
page,
|
||||
agent,
|
||||
}) => {
|
||||
await openAgentRoute(page, agent);
|
||||
await expectFooterWithinWidth(page, 140);
|
||||
await expectFooterWithinWidth(page, 150);
|
||||
await expectFooterWithinWidth(page, 300);
|
||||
await expectFooterWithinWidth(page, 880);
|
||||
});
|
||||
|
||||
async function visibleFooterLeftEdges(page: Page): Promise<number[]> {
|
||||
return page.evaluate(() => {
|
||||
const viewport = Array.from(
|
||||
document.querySelectorAll('[data-testid="agent-chat-scroll"]'),
|
||||
).find((candidate) => candidate.checkVisibility());
|
||||
return Array.from(viewport?.querySelectorAll('[data-testid="turn-footer"]') ?? [])
|
||||
.filter((footer) => footer.checkVisibility())
|
||||
.map((footer) => Math.round(footer.getBoundingClientRect().left));
|
||||
});
|
||||
}
|
||||
|
||||
async function expectFootersInOneColumn(page: Page): Promise<void> {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const edges = await visibleFooterLeftEdges(page);
|
||||
return edges.length >= 2 && new Set(edges).size === 1 ? "one column" : edges;
|
||||
})
|
||||
.toBe("one column");
|
||||
}
|
||||
|
||||
test("keeps the latest turn's footer in the chat column while it runs and after it ends", async ({
|
||||
page,
|
||||
agent,
|
||||
}) => {
|
||||
// Wider than the chat's max width, so the column is centered with space on both sides.
|
||||
await page.setViewportSize({ width: 1600, height: 1000 });
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
await openAgentRoute(page, agent);
|
||||
await expectAgentIdle(page);
|
||||
|
||||
gate.holdNextAgentStreamEvent("turn_completed");
|
||||
gate.holdNextAgentUpdate(agent.agentId, "idle");
|
||||
gate.setServerMessageSuppressed("agent_update", true);
|
||||
await submitMessage(page, "emit 1 coalesced agent stream updates");
|
||||
await Promise.all([
|
||||
gate.waitForHeldAgentStreamEvent("turn_completed"),
|
||||
gate.waitForHeldAgentUpdate(agent.agentId, "idle"),
|
||||
]);
|
||||
await expect(page.getByTestId("turn-working-indicator")).toBeVisible();
|
||||
await expectFootersInOneColumn(page);
|
||||
|
||||
gate.releaseHeldAgentStreamEvent("turn_completed");
|
||||
gate.releaseHeldAgentUpdate(agent.agentId, "idle");
|
||||
gate.setServerMessageSuppressed("agent_update", false);
|
||||
await expectAgentIdle(page);
|
||||
await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0);
|
||||
await expectFootersInOneColumn(page);
|
||||
});
|
||||
@@ -6,7 +6,6 @@ import { connectSeedClient } from "../support/helpers/seed-client";
|
||||
import { createTempGitRepo } from "../support/helpers/workspace";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import {
|
||||
closeActiveAgentWithShortcut,
|
||||
archiveAgentFromDaemon,
|
||||
archiveAgentFromSessions,
|
||||
clickSessionRow,
|
||||
@@ -21,12 +20,8 @@ import {
|
||||
openWorkspaceWithAgents,
|
||||
primeAdditionalPage,
|
||||
resetSeededPageState,
|
||||
unarchiveAgentFromConversation,
|
||||
reloadWorkspace,
|
||||
} from "../support/helpers/archive-tab";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { expectComposerDraft, fillComposerDraft, submitMessage } from "../support/helpers/composer";
|
||||
import { awaitAssistantMessage, expectAgentIdle } from "../support/helpers/agent-stream";
|
||||
import { expectAgentTabActive } from "../support/helpers/launcher";
|
||||
|
||||
test.describe("Archive tab reconciliation", () => {
|
||||
@@ -56,34 +51,6 @@ test.describe("Archive tab reconciliation", () => {
|
||||
await tempRepo?.cleanup();
|
||||
});
|
||||
|
||||
test("restoring a closed agent retains its unsent composer draft after reload", async ({
|
||||
page,
|
||||
}) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "archived-composer-",
|
||||
title: "Restore unsent draft",
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, agent);
|
||||
await submitMessage(page, "A completed conversation before closing");
|
||||
await awaitAssistantMessage(page);
|
||||
await expectAgentIdle(page);
|
||||
await fillComposerDraft(page, "Keep this unsent message after closing");
|
||||
await expectComposerDraft(page, "Keep this unsent message after closing");
|
||||
await closeActiveAgentWithShortcut(page, agent.agentId);
|
||||
await expect.poll(() => fetchAgentArchivedAt(agent.client, agent.agentId)).not.toBeNull();
|
||||
await page.reload();
|
||||
await openSessions(page);
|
||||
await expectSessionRowArchived(page, "Restore unsent draft");
|
||||
await clickSessionRow(page, "Restore unsent draft");
|
||||
await expectArchivedAgentFocused(page, agent.agentId);
|
||||
await unarchiveAgentFromConversation(page);
|
||||
await expectComposerDraft(page, "Keep this unsent message after closing");
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("non-UI archive prunes the archived tab across open pages and reload", async ({ page }) => {
|
||||
const archived = await createIdleAgent(client, {
|
||||
cwd: tempRepo.path,
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import { writeFile, unlink } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import {
|
||||
expectNoBranchSwitcherInWorkspaceHeader,
|
||||
@@ -17,95 +14,6 @@ import {
|
||||
waitForSidebarHydration,
|
||||
} from "../support/helpers/workspace-ui";
|
||||
|
||||
// Hold the real daemon response until the test has observed the pending UI.
|
||||
async function holdBranchSuggestions(page: Page) {
|
||||
let requestCount = 0;
|
||||
let release!: () => void;
|
||||
let gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (browser) => {
|
||||
const server = browser.connectToServer();
|
||||
browser.onMessage((message) => {
|
||||
if (typeof message === "string") {
|
||||
const envelope = JSON.parse(message);
|
||||
if (envelope.type === "session" && envelope.message?.type === "branch_suggestions_request")
|
||||
requestCount++;
|
||||
}
|
||||
server.send(message);
|
||||
});
|
||||
server.onMessage(async (message) => {
|
||||
if (typeof message === "string") {
|
||||
const envelope = JSON.parse(message);
|
||||
if (
|
||||
envelope.type === "session" &&
|
||||
envelope.message?.type === "branch_suggestions_response"
|
||||
) {
|
||||
await gate;
|
||||
}
|
||||
}
|
||||
browser.send(message);
|
||||
});
|
||||
});
|
||||
return {
|
||||
release: () => release(),
|
||||
holdNextResponse: () => {
|
||||
gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
},
|
||||
requestCount: () => requestCount,
|
||||
};
|
||||
}
|
||||
|
||||
async function expectInitialBranchLoading(page: Page, release: () => void): Promise<void> {
|
||||
await page.getByRole("button", { name: "Current branch: main. Press to switch branch." }).click();
|
||||
const picker = page.getByTestId("combobox-desktop-container");
|
||||
await expect(picker.getByText("Loading...", { exact: true })).toBeVisible();
|
||||
await expect(picker.getByText("No branches found.", { exact: true })).toHaveCount(0);
|
||||
release();
|
||||
await expect(picker.getByText("dev", { exact: true })).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(picker).not.toBeVisible();
|
||||
}
|
||||
|
||||
async function expectFailedRefreshRecovery(
|
||||
page: Page,
|
||||
repoPath: string,
|
||||
responses: Awaited<ReturnType<typeof holdBranchSuggestions>>,
|
||||
) {
|
||||
// Expire the actual query freshness window before reopening the cached picker.
|
||||
const started = Date.now();
|
||||
await expect.poll(() => Date.now() - started, { timeout: 20_000 }).toBeGreaterThan(15_000);
|
||||
const picker = page.getByTestId("combobox-desktop-container");
|
||||
const packedRefs = path.join(repoPath, ".git", "packed-refs");
|
||||
await writeFile(packedRefs, "invalid packed refs\n");
|
||||
try {
|
||||
await page
|
||||
.getByRole("button", { name: "Current branch: dev. Press to switch branch." })
|
||||
.click();
|
||||
await expect(picker.getByRole("alert")).toHaveText(
|
||||
"Could not load branches. Reopen the picker to try again.",
|
||||
);
|
||||
} finally {
|
||||
await unlink(packedRefs);
|
||||
}
|
||||
await expect(picker.getByText("No branches found.", { exact: true })).toHaveCount(0);
|
||||
responses.holdNextResponse();
|
||||
const beforeRetry = responses.requestCount();
|
||||
await page.keyboard.press("Escape");
|
||||
await page.getByRole("button", { name: "Current branch: dev. Press to switch branch." }).click();
|
||||
await expect(picker.getByRole("alert")).toHaveText("Loading...");
|
||||
await expect.poll(responses.requestCount).toBe(beforeRetry + 1);
|
||||
await expect(picker).toBeVisible();
|
||||
responses.release();
|
||||
await expect(picker.getByRole("alert")).toHaveCount(0);
|
||||
await expect(picker.getByText("main", { exact: true })).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expectWorkspaceBranch(page, "dev");
|
||||
expect(responses.requestCount()).toBe(beforeRetry + 1);
|
||||
}
|
||||
|
||||
async function renameWorkspaceViaSidebar(
|
||||
page: Page,
|
||||
input: { workspaceId: string; title: string },
|
||||
@@ -151,7 +59,6 @@ test.describe("Branch switcher", () => {
|
||||
try {
|
||||
expect(workspace.workspaceName).toBe("main");
|
||||
|
||||
const releaseBranches = await holdBranchSuggestions(page);
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: workspace.workspaceId });
|
||||
@@ -175,10 +82,8 @@ test.describe("Branch switcher", () => {
|
||||
// and switching it checks out the real branch on disk.
|
||||
await openChangesPanel(page);
|
||||
await expectWorkspaceBranch(page, "main");
|
||||
await expectInitialBranchLoading(page, releaseBranches.release);
|
||||
await switchBranchFromChangesPanel(page, { from: "main", to: "dev" });
|
||||
await expectWorkspaceBranch(page, "dev");
|
||||
await expectFailedRefreshRecovery(page, workspace.repoPath, releaseBranches);
|
||||
|
||||
// The custom title is unaffected by the branch switch.
|
||||
await expect(headerTitle).toHaveText(customTitle, { timeout: 30_000 });
|
||||
|
||||
@@ -8,11 +8,7 @@ import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { connectSeedClient } from "../support/helpers/seed-client";
|
||||
import { createTempGitRepo } from "../support/helpers/workspace";
|
||||
import {
|
||||
openChangesPanel,
|
||||
openChangesTreePanel,
|
||||
waitForWorkspaceTabsVisible,
|
||||
} from "../support/helpers/workspace-tabs";
|
||||
import { openChangesPanel, waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||
|
||||
interface DirtyWorkspace {
|
||||
id: string;
|
||||
@@ -266,126 +262,6 @@ test.afterEach(async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("Changes without a comparison base explains how to view pending changes", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithManyTinyDiffs(2, "dev");
|
||||
await openWorkspaceChangesTree(page, workspace);
|
||||
await expect(
|
||||
page.getByText("Comparison base is unavailable. Select Uncommitted to view pending changes."),
|
||||
).toBeVisible();
|
||||
await selectChangesComparison(page, "Uncommitted");
|
||||
await expect(changesTree(page).getByText("file-0000.ts", { exact: true })).toBeVisible();
|
||||
});
|
||||
async function openWorkspaceChangesTree(page: Page, workspace: DirtyWorkspace): Promise<void> {
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await openChangesTreePanel(page);
|
||||
}
|
||||
|
||||
test("streamed Changes shows the tree before completion and applies a one-file refresh", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(120_000);
|
||||
const workspace = await createLargeStreamedWorkspace();
|
||||
await addOversizedSibling(workspace);
|
||||
const stream = await holdDiffCompletion(page);
|
||||
await openWorkspaceChangesSurface(page, workspace, 90_000);
|
||||
await expectAnnouncedTreeAndLoader(page);
|
||||
await page.screenshot({ path: testInfo.outputPath("tree-loading.png") });
|
||||
stream.finish();
|
||||
await expect.poll(() => stream.completed, { timeout: 60_000 }).toBeGreaterThan(0);
|
||||
await expect(page.getByRole("progressbar", { name: "Loading..." })).toHaveCount(0);
|
||||
const connections = stream.connections;
|
||||
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("src/file-0000.ts, +90, -1");
|
||||
await addOneLineToFirstFile(workspace);
|
||||
await expect.poll(() => stream.changedBodies.at(-1), { timeout: 30_000 }).toBe(1);
|
||||
await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("src/file-0000.ts, +91, -1");
|
||||
await expect(page.getByRole("progressbar", { name: "Loading..." })).toHaveCount(0);
|
||||
expect(stream.connections).toBe(connections);
|
||||
await page.screenshot({ path: testInfo.outputPath("refreshed.png") });
|
||||
});
|
||||
|
||||
async function createLargeStreamedWorkspace(): Promise<DirtyWorkspace> {
|
||||
const workspace = await createWorkspaceWithManyTinyDiffs(252);
|
||||
await Promise.all(
|
||||
Array.from({ length: 252 }, (_, file) =>
|
||||
writeFile(
|
||||
path.join(workspace.repoPath, `src/file-${String(file).padStart(4, "0")}.ts`),
|
||||
Array.from(
|
||||
{ length: 90 },
|
||||
(_unused, line) =>
|
||||
`export const fn${line} = (param: string) => ({ key: param, value: ${file} });`,
|
||||
).join("\n") + "\n",
|
||||
),
|
||||
),
|
||||
);
|
||||
return workspace;
|
||||
}
|
||||
async function addOversizedSibling(workspace: DirtyWorkspace): Promise<void> {
|
||||
await writeFile(path.join(workspace.repoPath, "zz-too-large.txt"), "x".repeat(1024 * 1024 + 1));
|
||||
}
|
||||
async function addOneLineToFirstFile(workspace: DirtyWorkspace): Promise<void> {
|
||||
const file = path.join(workspace.repoPath, "src/file-0000.ts");
|
||||
await writeFile(file, (await readFile(file, "utf8")) + "export const refreshed = true;\n");
|
||||
}
|
||||
async function holdDiffCompletion(page: Page) {
|
||||
const { decodeDiffFrame, DiffOpcode } = await import("@getpaseo/protocol/binary-frames/index");
|
||||
let holding = true;
|
||||
let connections = 0;
|
||||
let completed = 0;
|
||||
const completions: Array<() => void> = [];
|
||||
const changedBodies: number[] = [];
|
||||
const bodyCounts = new Map<number, number>();
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (browserSocket) => {
|
||||
connections++;
|
||||
const serverSocket = browserSocket.connectToServer();
|
||||
browserSocket.onMessage((message) => serverSocket.send(message));
|
||||
serverSocket.onMessage((message) => {
|
||||
if (
|
||||
typeof message !== "string" &&
|
||||
message[0] >= DiffOpcode.Begin &&
|
||||
message[0] <= DiffOpcode.End
|
||||
) {
|
||||
const frame = decodeDiffFrame(message);
|
||||
if (frame.opcode === DiffOpcode.Begin) bodyCounts.set(frame.slot, 0);
|
||||
else if (frame.opcode === DiffOpcode.Bodies)
|
||||
bodyCounts.set(frame.slot, (bodyCounts.get(frame.slot) ?? 0) + frame.files.length);
|
||||
else {
|
||||
completed++;
|
||||
changedBodies.push(bodyCounts.get(frame.slot) ?? 0);
|
||||
}
|
||||
}
|
||||
if (holding && typeof message !== "string" && message[0] === DiffOpcode.End) {
|
||||
completions.push(() => browserSocket.send(message));
|
||||
return;
|
||||
}
|
||||
browserSocket.send(message);
|
||||
});
|
||||
});
|
||||
return {
|
||||
changedBodies,
|
||||
get connections() {
|
||||
return connections;
|
||||
},
|
||||
get completed() {
|
||||
return completed;
|
||||
},
|
||||
finish: () => {
|
||||
holding = false;
|
||||
completions.splice(0).forEach((send) => send());
|
||||
},
|
||||
};
|
||||
}
|
||||
async function expectWorkingDiffLoaded(page: Page, timeout: number): Promise<void> {
|
||||
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
await expect(panel.getByTestId("diff-file-0")).toBeAttached({ timeout });
|
||||
}
|
||||
async function expectAnnouncedTreeAndLoader(page: Page): Promise<void> {
|
||||
await expect(changesTree(page).first()).toBeVisible();
|
||||
await expect(page.getByRole("progressbar", { name: "Loading..." }).first()).toBeVisible();
|
||||
}
|
||||
|
||||
test("Changes opens All changes for a clean checkout", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithCommittedDiff();
|
||||
|
||||
@@ -551,15 +427,6 @@ test("Changes shows the base as text on a host without the base-branch capabilit
|
||||
expect(baseRequests).toBe(0);
|
||||
});
|
||||
|
||||
test("Changes shows no base on a checkout that is not a Paseo worktree", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithCommittedDiff();
|
||||
await openWorkspaceChangesSurface(page, workspace);
|
||||
const header = page.getByTestId("changes-repository-header").filter({ visible: true });
|
||||
await expect(header).toContainText("feature");
|
||||
await expect(header).not.toContainText("main");
|
||||
await expect(page.getByTestId("workspace-base-branch-trigger")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("Changes keeps a manual comparison when checkout dirtiness changes", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
@@ -623,35 +490,6 @@ test("an empty Changes comparison links to the populated comparison", async ({ p
|
||||
await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("committed-only.ts, +1, -0");
|
||||
});
|
||||
|
||||
test("legacy diff hosts keep All, Committed and Uncommitted comparisons", async ({ page }) => {
|
||||
await useLegacyDiffHost(page);
|
||||
const workspace = await createWorkspaceWithScrollableComparisons();
|
||||
await openWorkspaceChangesSurface(page, workspace);
|
||||
await expect(changesTree(page).getByText("committed", { exact: true })).toBeVisible();
|
||||
await expect(changesTree(page).getByText("uncommitted", { exact: true })).toBeVisible();
|
||||
await selectChangesComparison(page, "Committed");
|
||||
await expectWorkingComparisonFiles(page, "committed");
|
||||
await selectChangesComparison(page, "Uncommitted");
|
||||
await expectWorkingComparisonFiles(page, "uncommitted");
|
||||
});
|
||||
async function useLegacyDiffHost(page: Page): Promise<void> {
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (browserSocket) => {
|
||||
const serverSocket = browserSocket.connectToServer();
|
||||
browserSocket.onMessage((message) => serverSocket.send(message));
|
||||
serverSocket.onMessage((message) => {
|
||||
if (typeof message === "string") {
|
||||
const envelope = JSON.parse(message);
|
||||
if (envelope.message?.payload?.status === "server_info") {
|
||||
delete envelope.message.payload.features.diffSubscription;
|
||||
browserSocket.send(JSON.stringify(envelope));
|
||||
return;
|
||||
}
|
||||
}
|
||||
browserSocket.send(message);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test("Changes comparison controls the working diff and tree selection focuses its file", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -1464,13 +1302,11 @@ test("canvas diff uses the overlay scrollbar and its thumb controls vertical scr
|
||||
test("canvas headers keep a many-file diff bounded while scrolling end to end", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(300_000);
|
||||
test.setTimeout(180_000);
|
||||
const workspace = await createWorkspaceWithManyTinyDiffs(2_000);
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChangesSurface(page, workspace, 90_000);
|
||||
|
||||
// Measure document bounds after delivery, independently of the cold Git read.
|
||||
await expectWorkingDiffLoaded(page, 240_000);
|
||||
const root = page.getByTestId("git-diff-canvas-root");
|
||||
const scroller = page.getByTestId("git-diff-scroll");
|
||||
await expect(root.locator('[data-testid^="git-diff-sticky-header-"]')).toHaveCount(2);
|
||||
@@ -2049,16 +1885,12 @@ async function createWorkspaceWithTwoSelectionDiffs(): Promise<DirtyWorkspace> {
|
||||
return { id: created.workspace.id, repoPath: repo.path };
|
||||
}
|
||||
|
||||
async function createWorkspaceWithManyTinyDiffs(
|
||||
fileCount: number,
|
||||
branch = "main",
|
||||
): Promise<DirtyWorkspace> {
|
||||
async function createWorkspaceWithManyTinyDiffs(fileCount: number): Promise<DirtyWorkspace> {
|
||||
const files = Array.from({ length: fileCount }, (_, index) => ({
|
||||
path: `src/file-${String(index).padStart(4, "0")}.ts`,
|
||||
content: "export const value = 0;\n",
|
||||
}));
|
||||
const repo = await createTempGitRepo("changes-canvas-many-files-", { files });
|
||||
if (branch !== "main") execFileSync("git", ["branch", "-m", branch], { cwd: repo.path });
|
||||
await Promise.all(
|
||||
files.map((file, index) =>
|
||||
writeFile(path.join(repo.path, file.path), `export const value = ${index + 1};\n`),
|
||||
|
||||
@@ -5,10 +5,8 @@ import {
|
||||
holdDirectorySuggestionResponses,
|
||||
expectOneLineTruncatedFileResult,
|
||||
expectStableCommandCenterLayout,
|
||||
expectLayoutObservationToCatchPanelShift,
|
||||
failDirectorySuggestionRequests,
|
||||
startCommandCenterLayoutObservation,
|
||||
shiftOutsideCommandCenter,
|
||||
} from "../support/helpers/command-center-file-search";
|
||||
import {
|
||||
scrollCommandCenterAwayFromSelection,
|
||||
@@ -86,9 +84,7 @@ test("workspace file search stays geometrically stable through delayed loading a
|
||||
await expect(panel.getByTestId("command-center-file-search-loading")).toBeVisible();
|
||||
await expect(panel.getByText("Searching files...", { exact: true })).toHaveCount(0);
|
||||
await expect(panel.getByTestId("command-center-file-search-loading")).toBeHidden();
|
||||
await shiftOutsideCommandCenter(page);
|
||||
await expectStableCommandCenterLayout(page);
|
||||
await expectLayoutObservationToCatchPanelShift(page);
|
||||
|
||||
await row.click();
|
||||
await expectFileTabOpen(page, FILE_PATH);
|
||||
|
||||
@@ -105,21 +105,7 @@ interface PopoverFrameRecorderWindow extends Window {
|
||||
__stopComposerAutocompleteFrameRecorder?: () => void;
|
||||
}
|
||||
|
||||
interface ListCommandsReply {
|
||||
commands: readonly { name: string; description: string; argumentHint: string }[];
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const TEST_COMMANDS_REPLY: ListCommandsReply = { commands: TEST_COMMANDS, error: null };
|
||||
|
||||
/**
|
||||
* Rewrites every `list_commands_response` with `reply()`. A reply that returns a promise holds
|
||||
* the response until it settles, standing in for a slow host.
|
||||
*/
|
||||
async function installListCommandsStub(
|
||||
page: Page,
|
||||
reply: () => ListCommandsReply | Promise<ListCommandsReply> = () => TEST_COMMANDS_REPLY,
|
||||
): Promise<void> {
|
||||
async function installListCommandsStub(page: Page): Promise<void> {
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
|
||||
@@ -144,20 +130,14 @@ async function installListCommandsStub(
|
||||
};
|
||||
};
|
||||
};
|
||||
const payload = parsed.message?.payload;
|
||||
if (
|
||||
parsed.type === "session" &&
|
||||
parsed.message?.type === "list_commands_response" &&
|
||||
payload
|
||||
parsed.message.payload
|
||||
) {
|
||||
const send = (result: ListCommandsReply) => {
|
||||
payload.commands = result.commands;
|
||||
payload.error = result.error;
|
||||
ws.send(JSON.stringify(parsed));
|
||||
};
|
||||
const result = reply();
|
||||
if (result instanceof Promise) void result.then(send);
|
||||
else send(result);
|
||||
parsed.message.payload.commands = TEST_COMMANDS;
|
||||
parsed.message.payload.error = null;
|
||||
ws.send(JSON.stringify(parsed));
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
@@ -169,17 +149,6 @@ async function installListCommandsStub(
|
||||
});
|
||||
}
|
||||
|
||||
function createHeldReply(): {
|
||||
reply: () => Promise<ListCommandsReply>;
|
||||
release: () => void;
|
||||
} {
|
||||
let release = () => {};
|
||||
const held = new Promise<ListCommandsReply>((resolve) => {
|
||||
release = () => resolve(TEST_COMMANDS_REPLY);
|
||||
});
|
||||
return { reply: () => held, release };
|
||||
}
|
||||
|
||||
async function openAppWideNewWorkspace(page: Page): Promise<void> {
|
||||
await page.getByTestId("sidebar-global-new-workspace").first().click();
|
||||
await page.waitForURL((url) => url.pathname === "/new", { timeout: 30_000 });
|
||||
@@ -576,17 +545,16 @@ test.describe("Composer autocomplete", () => {
|
||||
const input = composerLocator(page);
|
||||
await expect(input).toBeEditable({ timeout: 30_000 });
|
||||
|
||||
// `/hea` lists two commands and `/he` enough to reach the height cap, so the list grows.
|
||||
await input.fill("/hea");
|
||||
await input.fill("/he");
|
||||
const popover = page.getByTestId("composer-autocomplete-popover");
|
||||
await expect(popover.getByText("/heapdump", { exact: true }).first()).toBeVisible({
|
||||
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
const beforeDelete = await visiblePopoverBox(page);
|
||||
|
||||
await input.press("Backspace");
|
||||
await expect(input).toHaveValue("/he");
|
||||
await expect(popover.getByText("/hello", { exact: true }).first()).toBeVisible({
|
||||
await expect(input).toHaveValue("/h");
|
||||
await expect(popover.getByText("/history", { exact: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
const afterDelete = await visiblePopoverBox(page);
|
||||
@@ -765,116 +733,6 @@ test.describe("Composer autocomplete", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("forgets an arrow-key choice once the search changes", async ({ page }) => {
|
||||
await installListCommandsStub(page);
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "autocomplete-stale-choice-",
|
||||
title: "Autocomplete stale choice",
|
||||
});
|
||||
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
await expectWorkspaceTabVisible(page, session.agentId);
|
||||
const input = composerLocator(page);
|
||||
await expect(input).toBeEditable({ timeout: 30_000 });
|
||||
await input.fill("/");
|
||||
const popover = page.getByTestId("composer-autocomplete-popover");
|
||||
await expect(popover.getByText("/tdd", { exact: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
// Down wraps from the default bottom row to the top: /clear, then /exit.
|
||||
await input.press("ArrowDown");
|
||||
await input.press("ArrowDown");
|
||||
await input.press("h");
|
||||
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible();
|
||||
await input.press("Backspace");
|
||||
await expect(input).toHaveValue("/");
|
||||
await input.press("Enter");
|
||||
|
||||
await expect(input).toHaveValue("/tdd ");
|
||||
await expectWorkspaceTabVisible(page, session.agentId);
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("shows loading inside the popover until a slow host answers", async ({ page }) => {
|
||||
const host = createHeldReply();
|
||||
await installListCommandsStub(page, host.reply);
|
||||
const agent = await openReadyMockAgent(page);
|
||||
|
||||
try {
|
||||
const input = composerLocator(page);
|
||||
await expect(input).toBeEditable({ timeout: 30_000 });
|
||||
await input.fill("/");
|
||||
|
||||
const popover = page.getByTestId("composer-autocomplete-popover");
|
||||
await expect(popover.getByTestId("composer-autocomplete-loading")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
const loading = await visiblePopoverBox(page);
|
||||
|
||||
host.release();
|
||||
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(popover.getByTestId("composer-autocomplete-loading")).toHaveCount(0);
|
||||
const results = await visiblePopoverBox(page);
|
||||
|
||||
expect(Math.abs(results.bottom - loading.bottom)).toBeLessThanOrEqual(4);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("shows why commands failed, keeps Enter from choosing a hidden command, and retries", async ({
|
||||
page,
|
||||
}) => {
|
||||
let reply: () => ListCommandsReply | Promise<ListCommandsReply> = () => ({
|
||||
commands: [],
|
||||
error: "Timed out waiting for the agent session",
|
||||
});
|
||||
await installListCommandsStub(page, () => reply());
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "autocomplete-failing-host-",
|
||||
title: "Autocomplete failing host",
|
||||
});
|
||||
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
await expectWorkspaceTabVisible(page, session.agentId);
|
||||
const input = composerLocator(page);
|
||||
await expect(input).toBeEditable({ timeout: 30_000 });
|
||||
await input.fill("/");
|
||||
|
||||
const popover = page.getByTestId("composer-autocomplete-popover");
|
||||
const errorRow = popover.getByTestId("composer-autocomplete-error");
|
||||
await expect(errorRow).toContainText("Timed out waiting for the agent session", {
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await input.press("Enter");
|
||||
await page.waitForTimeout(500);
|
||||
await expectWorkspaceTabVisible(page, session.agentId);
|
||||
|
||||
await input.fill("/");
|
||||
await expect(errorRow).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
const host = createHeldReply();
|
||||
reply = host.reply;
|
||||
await errorRow.getByRole("button", { name: "Retry" }).click();
|
||||
await expect(popover.getByTestId("composer-autocomplete-loading")).toBeVisible();
|
||||
|
||||
host.release();
|
||||
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test.describe("compact sidebar layering", () => {
|
||||
test.use({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true });
|
||||
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { openWorkspaceWithAgents } from "../support/helpers/archive-tab";
|
||||
import { fillComposerDraft } from "../support/helpers/composer";
|
||||
import {
|
||||
expectComposerToolbarOnTextRails,
|
||||
expectNoCollapsedComposerToolbarFrame,
|
||||
expectNoFrameTruncatesModelBesideThinking,
|
||||
readSettledModelTriggerLabels,
|
||||
recordComposerToolbarFrames,
|
||||
recordModelTriggerFramesFromLoad,
|
||||
sweepModelTriggerLabels,
|
||||
} from "../support/helpers/composer-control-density";
|
||||
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
@@ -130,128 +124,3 @@ test.describe("Composer controls folding on a touch device", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Folded model control on a phone", () => {
|
||||
// Wide enough for "Ten second stream" and "Low" side by side; narrower, they no longer fit.
|
||||
test.use({ viewport: { width: 480, height: 844 }, isMobile: true, hasTouch: true });
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test("hides the thinking label before the model label truncates", async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-density-labels-",
|
||||
title: "Label priority chat",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
try {
|
||||
await recordModelTriggerFramesFromLoad(page);
|
||||
await openAgentRoute(page, session);
|
||||
await expect(
|
||||
page.getByTestId("combined-model-selector-thinking-label").filter({ visible: true }),
|
||||
).toHaveText("Low");
|
||||
await readSettledModelTriggerLabels(page);
|
||||
// From the first frame on, the control never guesses: it paints both labels only once it
|
||||
// has measured that they fit.
|
||||
await expectNoFrameTruncatesModelBesideThinking(page);
|
||||
|
||||
const sweep = await sweepModelTriggerLabels(page, {
|
||||
from: 480,
|
||||
to: 280,
|
||||
step: 4,
|
||||
height: 844,
|
||||
});
|
||||
|
||||
expect(sweep.widest).toMatchObject({
|
||||
modelText: "Ten second stream",
|
||||
thinkingShown: true,
|
||||
modelTruncated: false,
|
||||
});
|
||||
expect(sweep.thinkingHidden).toMatchObject({
|
||||
modelText: "Ten second stream",
|
||||
modelTruncated: false,
|
||||
});
|
||||
expect(sweep.modelTruncated.width).toBeLessThan(sweep.thinkingHidden.width);
|
||||
// A clean tail: the label ends on a whole word and the ellipsis, never on a space.
|
||||
expect(sweep.modelTruncated).toMatchObject({
|
||||
modelText: "Ten second…",
|
||||
thinkingShown: false,
|
||||
});
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Composer toolbar rails on touch", () => {
|
||||
test.use({ isMobile: true, hasTouch: true });
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
for (const viewport of [
|
||||
{ device: "phone", width: 390, height: 844 },
|
||||
{ device: "tablet", width: 1024, height: 1366 },
|
||||
]) {
|
||||
test(`the toolbar starts on the text rail and ends in a disc nested in the corner on a ${viewport.device}`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: viewport.width, height: viewport.height });
|
||||
const idle = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-rails-idle-",
|
||||
title: "Rails idle chat",
|
||||
});
|
||||
let running: Awaited<ReturnType<typeof seedMockAgentWorkspace>> | null = null;
|
||||
try {
|
||||
await openAgentRoute(page, idle);
|
||||
await expect(page.getByTestId("message-input-root").filter({ visible: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await test.step("empty composer: attach on the left, voice mode on the right", async () => {
|
||||
await expectComposerToolbarOnTextRails(page);
|
||||
});
|
||||
await test.step("draft: the send disc on the right", async () => {
|
||||
await fillComposerDraft(page, "Refactor the composer toolbar");
|
||||
await expect(page.getByRole("button", { name: /send/i }).first()).toBeVisible();
|
||||
await expectComposerToolbarOnTextRails(page);
|
||||
});
|
||||
|
||||
running = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-rails-running-",
|
||||
title: "Rails running chat",
|
||||
model: "one-minute-stream",
|
||||
initialPrompt: "stream for a while",
|
||||
});
|
||||
await openAgentRoute(page, running);
|
||||
await test.step("running agent: the stop disc on the right", async () => {
|
||||
await expect(page.getByRole("button", { name: /stop agent/i }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expectComposerToolbarOnTextRails(page);
|
||||
});
|
||||
} finally {
|
||||
await idle.cleanup();
|
||||
await running?.cleanup();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test.describe("Composer trailing disc on a new chat", () => {
|
||||
test.use({ isMobile: true, hasTouch: true, viewport: { width: 1024, height: 1366 } });
|
||||
|
||||
test("an empty draft holds a disabled send disc until there is something to send", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "composer-placeholder-send-" });
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await clickNewChat(page);
|
||||
const send = page
|
||||
.getByTestId("message-input-root")
|
||||
.filter({ visible: true })
|
||||
.getByRole("button", { name: "Send message", exact: true });
|
||||
await expect(send).toBeDisabled({ timeout: 30_000 });
|
||||
await fillComposerDraft(page, "Refactor the composer toolbar");
|
||||
await expect(send).toBeEnabled();
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import type { CDPSession, Locator } from "@playwright/test";
|
||||
import type { Locator } from "@playwright/test";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { seedSubagent } from "../support/helpers/subagents";
|
||||
import { submitMessage } from "../support/helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { gateNextAgentMessage } from "../support/helpers/agent-message-gate";
|
||||
import {
|
||||
@@ -862,91 +860,3 @@ async function expectReviewEditorBody(page: Page, body: string) {
|
||||
body,
|
||||
);
|
||||
}
|
||||
|
||||
async function swipeComposerTracks(page: Page, client: CDPSession, direction: "left" | "right") {
|
||||
const track = page.getByTestId("composer-track-scroll");
|
||||
const box = await track.boundingBox();
|
||||
if (!box) throw new Error("Composer tracks are not visible");
|
||||
const start = direction === "left" ? box.x + box.width - 5 : box.x + 5;
|
||||
const end = direction === "left" ? box.x + 5 : box.x + box.width - 5;
|
||||
await swipeTouch(client, start, end, box.y + box.height / 2);
|
||||
}
|
||||
|
||||
async function swipeTouch(client: CDPSession, start: number, end: number, y: number) {
|
||||
await client.send("Input.dispatchTouchEvent", {
|
||||
type: "touchStart",
|
||||
touchPoints: [{ x: start, y }],
|
||||
});
|
||||
for (let step = 1; step <= 8; step++) {
|
||||
await client.send("Input.dispatchTouchEvent", {
|
||||
type: "touchMove",
|
||||
touchPoints: [{ x: start + ((end - start) * step) / 8, y }],
|
||||
});
|
||||
}
|
||||
await client.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
|
||||
}
|
||||
|
||||
async function trackScrollOffset(track: Locator): Promise<number> {
|
||||
return track.evaluate((node) => node.scrollLeft);
|
||||
}
|
||||
|
||||
test.describe("touch composer tracks", () => {
|
||||
test.use({ hasTouch: true, isMobile: true });
|
||||
test("compact composer scrolls its task, subagent and changes tracks without squeezing them", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedChangedAgent("composer-tracks-overflow-");
|
||||
const touch = await page.context().newCDPSession(page);
|
||||
try {
|
||||
await seedSubagent(
|
||||
{ client: workspace.client, repoPath: workspace.cwd, workspaceId: workspace.workspaceId },
|
||||
{ parentId: workspace.agentId, title: "Track child" },
|
||||
);
|
||||
await page.setViewportSize({ width: 1000, height: 844 });
|
||||
await openAgentRoute(page, {
|
||||
workspaceId: workspace.workspaceId,
|
||||
agentId: workspace.agentId,
|
||||
});
|
||||
await submitMessage(page, "emit 1 agent stream updates");
|
||||
const tasks = page.getByTestId("agent-task-list-header");
|
||||
const subagents = page.getByTestId("subagents-track-header");
|
||||
const changes = page.getByTestId("composer-diff-stat-pill");
|
||||
await expect(tasks).toBeVisible();
|
||||
await expect(subagents).toBeVisible();
|
||||
await expect(changes).toBeVisible();
|
||||
const wideWidths = await Promise.all(
|
||||
[tasks, subagents, changes].map(async (pill) => (await pill.boundingBox())!.width),
|
||||
);
|
||||
await page.setViewportSize({ width: 200, height: 844 });
|
||||
const narrowWidths = await Promise.all(
|
||||
[tasks, subagents, changes].map(async (pill) => (await pill.boundingBox())!.width),
|
||||
);
|
||||
for (const [index, width] of wideWidths.entries())
|
||||
expect(narrowWidths[index]).toBeGreaterThanOrEqual(width - 1);
|
||||
const track = page.getByTestId("composer-track-scroll");
|
||||
expect(await track.evaluate((node) => node.scrollWidth)).toBeGreaterThan(
|
||||
await track.evaluate((node) => node.clientWidth),
|
||||
);
|
||||
await swipeComposerTracks(page, touch, "left");
|
||||
await expect.poll(() => trackScrollOffset(track)).toBeGreaterThan(0);
|
||||
await swipeComposerTracks(page, touch, "right");
|
||||
await expect.poll(() => trackScrollOffset(track)).toBeLessThanOrEqual(1);
|
||||
await swipeComposerTracks(page, touch, "left");
|
||||
await expect.poll(() => trackScrollOffset(track)).toBeGreaterThan(0);
|
||||
await expect(page.getByTestId("sidebar-sessions")).not.toBeInViewport();
|
||||
await expect(page.getByTestId("workspace-explorer-sidebar")).not.toBeInViewport();
|
||||
await page.getByRole("button", { name: "Open menu" }).click();
|
||||
await expect(page.getByTestId("sidebar-sessions")).toBeInViewport({ ratio: 1 });
|
||||
await page.getByTestId("sidebar-close").click();
|
||||
await expect(page.getByTestId("sidebar-sessions")).not.toBeInViewport();
|
||||
await expect(changes).toBeInViewport();
|
||||
await changes.click();
|
||||
await expect(
|
||||
page.getByTestId("changes-header").filter({ visible: true }).first(),
|
||||
).toBeVisible();
|
||||
} finally {
|
||||
await touch.detach();
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import {
|
||||
pasteRichTextIntoComposer,
|
||||
fillComposerDraft,
|
||||
copyLatestTurnIntoComposer,
|
||||
selectComposerDraftRange,
|
||||
undoComposerEdit,
|
||||
redoComposerEdit,
|
||||
blurComposerInput,
|
||||
roundTripComposerTabBeforePaint,
|
||||
composerLocator,
|
||||
@@ -19,7 +13,6 @@ import {
|
||||
expectWorkspaceHeaderVisible,
|
||||
resizeComposerViewport,
|
||||
submitMessage,
|
||||
submitMessageWithButton,
|
||||
typeIntoFocusedComposer,
|
||||
} from "../support/helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
@@ -35,7 +28,6 @@ import {
|
||||
openMobileAgentSidebarWithKeyboard,
|
||||
swipeOpenMobileExplorer,
|
||||
} from "../support/helpers/sidebar";
|
||||
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||
import { readScrollMetrics } from "../support/helpers/agent-bottom-anchor";
|
||||
import {
|
||||
expectTimelinePromptPositionPreserved,
|
||||
@@ -63,9 +55,7 @@ test("clicking chat text or background focuses the feed for keyboard paging", as
|
||||
await expect
|
||||
.poll(async () => (await readScrollMetrics(page)).offsetY)
|
||||
.toBeLessThan(beforePaging.offsetY - beforePaging.viewportHeight / 2);
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("chat-keyboard-paging.png"),
|
||||
});
|
||||
await page.screenshot({ path: testInfo.outputPath("chat-keyboard-paging.png") });
|
||||
await page.keyboard.press("PageDown");
|
||||
await expect
|
||||
.poll(async () => (await readScrollMetrics(page)).distanceFromBottom)
|
||||
@@ -134,86 +124,20 @@ test("focusing the feed preserves assistant text selection and code copy control
|
||||
});
|
||||
|
||||
test("submitting a message leaves the composer ready for the next message", async ({ page }) => {
|
||||
const copiedTable =
|
||||
"| Resource | Status |\n| --- | --- |\n| [Unicode](https://example.com/日本語) | ~~closed~~ |\n| [Space](<https://example.com/a b>) | **open** |\n| [ASCII](https://example.com/1) | closed |";
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-focus-",
|
||||
title: "Composer focus",
|
||||
featureValues: { mockAssistantResponse: copiedTable },
|
||||
});
|
||||
|
||||
const connection = await installDaemonWebSocketGate(page);
|
||||
try {
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
await submitMessageWithButton(page, "Pointer submission");
|
||||
await expectComposerDraft(page, "");
|
||||
await expectComposerFocused(page);
|
||||
await typeIntoFocusedComposer(page, "Ready after Send");
|
||||
await expectComposerDraft(page, "Ready after Send");
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
await expectComposerFocused(page);
|
||||
|
||||
await submitMessage(page, "First message");
|
||||
await expectComposerFocused(page);
|
||||
|
||||
await typeIntoFocusedComposer(page, "Investigate: replace this. Thanks");
|
||||
await selectComposerDraftRange(page, 13, 25);
|
||||
await pasteRichTextIntoComposer({
|
||||
page,
|
||||
html: '<a href="https://example.com/issues/39150">this FR</a>',
|
||||
text: "this FR",
|
||||
});
|
||||
await expectComposerDraft(
|
||||
page,
|
||||
"Investigate: [this FR](https://example.com/issues/39150). Thanks",
|
||||
);
|
||||
await undoComposerEdit(page);
|
||||
await expectComposerDraft(page, "Investigate: replace this. Thanks");
|
||||
await redoComposerEdit(page);
|
||||
await expectComposerDraft(
|
||||
page,
|
||||
"Investigate: [this FR](https://example.com/issues/39150). Thanks",
|
||||
);
|
||||
await typeIntoFocusedComposer(page, " next");
|
||||
await expectComposerDraft(
|
||||
page,
|
||||
"Investigate: [this FR](https://example.com/issues/39150) next. Thanks",
|
||||
);
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
await expect(page.getByRole("button", { name: "Copy turn", exact: true }).last()).toBeVisible();
|
||||
await connection.drop();
|
||||
await connection.waitForBlockedConnection();
|
||||
await pasteRichTextIntoComposer({
|
||||
page,
|
||||
html: '<a href="https://example.com/offline">offline link</a>',
|
||||
text: "offline link",
|
||||
});
|
||||
await expectComposerDraft(
|
||||
page,
|
||||
"Investigate: [this FR](https://example.com/issues/39150) next[offline link](https://example.com/offline). Thanks",
|
||||
);
|
||||
await fillComposerDraft(page, "");
|
||||
await pasteRichTextIntoComposer({
|
||||
page,
|
||||
html: '<table><thead><tr><th>Resource</th><th>Status</th></tr></thead><tbody><tr><td><a href="https://example.com/issue">Issue</a></td><td>closed</td></tr></tbody></table>',
|
||||
text: "Resource\tStatus\nIssue\tclosed",
|
||||
});
|
||||
await expectComposerDraft(
|
||||
page,
|
||||
"| Resource | Status |\n| --- | --- |\n| [Issue](https://example.com/issue) | closed |",
|
||||
);
|
||||
await fillComposerDraft(page, "");
|
||||
await pasteRichTextIntoComposer({
|
||||
page,
|
||||
html: '<a href="https://example.com/">Issue</a><table></table>',
|
||||
text: "Issue",
|
||||
});
|
||||
await expectComposerDraft(page, "[Issue](https://example.com/)");
|
||||
await copyLatestTurnIntoComposer(page);
|
||||
await expectComposerDraft(page, copiedTable);
|
||||
await expectComposerFocused(page);
|
||||
await typeIntoFocusedComposer(page, "Second message");
|
||||
await expectComposerDraft(page, "Second message");
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
@@ -244,11 +168,7 @@ async function waitForAnimationFrames(page: Page, count: number): Promise<void>
|
||||
test.describe("touch screen wide enough for the desktop layout", () => {
|
||||
// A tablet or an unfolded foldable gets the desktop layout, but focusing the
|
||||
// composer there raises the on-screen keyboard over the conversation.
|
||||
test.use({
|
||||
viewport: { width: 1024, height: 768 },
|
||||
isMobile: true,
|
||||
hasTouch: true,
|
||||
});
|
||||
test.use({ viewport: { width: 1024, height: 768 }, isMobile: true, hasTouch: true });
|
||||
|
||||
test("remounting an inline composer does not restore focus on touch", async ({ page }) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
@@ -272,9 +192,7 @@ test.describe("touch screen wide enough for the desktop layout", () => {
|
||||
test("returning to an inactive inline composer does not restore focus on touch", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({
|
||||
repoPrefix: "composer-touch-retained-",
|
||||
});
|
||||
const workspace = await seedWorkspace({ repoPrefix: "composer-touch-retained-" });
|
||||
try {
|
||||
const first = await createMockIdleAgent(workspace.client, {
|
||||
cwd: workspace.repoPath,
|
||||
@@ -322,9 +240,6 @@ test.describe("touch screen wide enough for the desktop layout", () => {
|
||||
// Autofocus makes its first attempt on the next animation frame.
|
||||
await waitForAnimationFrames(page, 4);
|
||||
await expectComposerNotFocused(page);
|
||||
await submitMessageWithButton(page, "Touch submission");
|
||||
await expectComposerDraft(page, "");
|
||||
await expectComposerNotFocused(page);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ test.describe("Direct terminal shortcut pane placement", () => {
|
||||
});
|
||||
});
|
||||
|
||||
async function prepareNarrowSplit(page: Page, workspaceId: string) {
|
||||
async function prepareUnfocusedCompactPane(page: Page, workspaceId: string) {
|
||||
await page.setViewportSize({ width: 1600, height: 900 });
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
@@ -74,38 +74,33 @@ async function prepareNarrowSplit(page: Page, workspaceId: string) {
|
||||
};
|
||||
}
|
||||
|
||||
async function expectPanesKeepTabRails(page: Page, panes: Locator[]) {
|
||||
for (const pane of panes) {
|
||||
await expect(pane.getByTestId("workspace-tabs-row")).toBeVisible();
|
||||
}
|
||||
await expect(
|
||||
page.getByTestId("workspace-tab-switcher-trigger").filter({ visible: true }),
|
||||
).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function launchTerminalFromPaneRail(page: Page, pane: Locator) {
|
||||
await pane.getByTestId("workspace-new-tab-button").click();
|
||||
await page.getByTestId("workspace-new-tab-menu-terminal").filter({ visible: true }).click();
|
||||
async function launchTerminalFromCompactPane(page: Page, pane: Locator) {
|
||||
await pane.getByTestId("workspace-tab-switcher-trigger").click();
|
||||
await page.getByTestId("workspace-tab-switcher-new-terminal").click();
|
||||
await expect(pane.getByTestId("terminal-surface")).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
async function maximizeAndRestorePane(pane: Locator) {
|
||||
await pane.getByTestId("workspace-maximize-pane").click();
|
||||
await pane.getByTestId("workspace-restore-pane").click();
|
||||
async function maximizeAndRestoreCompactPane(page: Page, pane: Locator) {
|
||||
await pane.getByTestId("workspace-header-menu-trigger").click();
|
||||
await expect(page.getByRole("menuitem", { name: "Split pane right", exact: true })).toBeVisible();
|
||||
await page.getByRole("menuitem", { name: "Maximize pane", exact: true }).click();
|
||||
await pane.getByTestId("workspace-header-menu-trigger").click();
|
||||
await page.getByRole("menuitem", { name: "Restore pane", exact: true }).click();
|
||||
}
|
||||
|
||||
test("narrow split panes keep their tab rails and launches stay in the owning pane", async ({
|
||||
test("compact pane launch stays in its owning pane and pane controls remain reachable", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "narrow-split-launch-" });
|
||||
const workspace = await seedWorkspace({ repoPrefix: "compact-pane-launch-" });
|
||||
try {
|
||||
const { originalPane, otherPane } = await prepareNarrowSplit(page, workspace.workspaceId);
|
||||
await expectPanesKeepTabRails(page, [originalPane, otherPane]);
|
||||
await launchTerminalFromPaneRail(page, originalPane);
|
||||
const { originalPane, otherPane } = await prepareUnfocusedCompactPane(
|
||||
page,
|
||||
workspace.workspaceId,
|
||||
);
|
||||
await launchTerminalFromCompactPane(page, originalPane);
|
||||
await expect(otherPane.getByTestId("workspace-new-tab-panel")).toBeVisible();
|
||||
await maximizeAndRestorePane(originalPane);
|
||||
await maximizeAndRestoreCompactPane(page, originalPane);
|
||||
await expect(otherPane).toBeVisible();
|
||||
await expectPanesKeepTabRails(page, [originalPane, otherPane]);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
@@ -123,15 +118,17 @@ async function expectPaneHasWindowDragSurface(pane: Locator) {
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
test("narrow desktop split panes retain window dragging with both sidebars closed", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "narrow-split-window-drag-" });
|
||||
test("compact desktop panes retain window dragging with both sidebars closed", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "compact-pane-window-drag-" });
|
||||
try {
|
||||
await installLocalDesktopHost(page, workspace.repoPath);
|
||||
const { originalPane, otherPane } = await prepareNarrowSplit(page, workspace.workspaceId);
|
||||
const { originalPane, otherPane } = await prepareUnfocusedCompactPane(
|
||||
page,
|
||||
workspace.workspaceId,
|
||||
);
|
||||
await runWorkspaceActionFromCommandCenter(page, "Toggle left sidebar");
|
||||
await expectPanesKeepTabRails(page, [originalPane, otherPane]);
|
||||
await expect(originalPane.getByTestId("workspace-tab-switcher-trigger")).toBeVisible();
|
||||
await expect(otherPane.getByTestId("workspace-tab-switcher-trigger")).toBeVisible();
|
||||
await expectPaneHasWindowDragSurface(originalPane);
|
||||
await expectPaneHasWindowDragSurface(otherPane);
|
||||
} finally {
|
||||
|
||||
@@ -79,12 +79,6 @@ async function previewExplorerResize(page: Page, distance: number) {
|
||||
return { ...measurement, width: after };
|
||||
}
|
||||
|
||||
async function expectCleanChangesLoaded(page: Page): Promise<void> {
|
||||
await expect(
|
||||
page.getByTestId("changes-tree-panel").getByText("No changes to display", { exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
async function expectExplorerWidthSurvivesReload(page: Page, width: number): Promise<void> {
|
||||
await page.reload();
|
||||
const explorer = page.getByTestId("workspace-explorer-sidebar");
|
||||
@@ -106,7 +100,6 @@ test("Explorer drag previews without rerendering panes or rewriting the shared s
|
||||
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
|
||||
await page.getByTestId("workspace-explorer-toggle").first().click();
|
||||
await expect(page.getByTestId("workspace-explorer-sidebar")).toBeVisible();
|
||||
await expectCleanChangesLoaded(page);
|
||||
// Verify that direct pane renders are being counted before resetting them.
|
||||
expect(
|
||||
await page.evaluate(() =>
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import {
|
||||
closeDiagramFullscreen,
|
||||
expectInlineDiagramFitsViewport,
|
||||
toggleDiagramSourceAndVerifyControls,
|
||||
closeDiagramFullscreenFromOutside,
|
||||
expectCompletedDiagram,
|
||||
expectFullscreenDiagram,
|
||||
@@ -23,36 +21,6 @@ const MERMAID_DIAGRAM = [
|
||||
"```",
|
||||
].join("\n");
|
||||
|
||||
test("bounds a tall inline diagram", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const diagram = [
|
||||
"```mermaid",
|
||||
"flowchart TD",
|
||||
...Array.from({ length: 30 }, (_, i) => `N${i} --> N${i + 1}`),
|
||||
"```",
|
||||
].join("\n");
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "mermaid-tall-",
|
||||
title: "Tall Mermaid",
|
||||
featureValues: {
|
||||
mockStreamingAssistantResponse: diagram,
|
||||
mockStreamingAssistantIntervalMs: 10,
|
||||
},
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, agent);
|
||||
await requestDiagram(agent);
|
||||
await waitForDiagramTurnToComplete(agent);
|
||||
await expectCompletedDiagram(page, ["N0", "N30"]);
|
||||
await expectInlineDiagramFitsViewport(page);
|
||||
await openDiagramFullscreen(page);
|
||||
await expectFullscreenDiagram(page, ["N0", "N30"]);
|
||||
await closeDiagramFullscreen(page);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("opens a Mermaid diagram fullscreen and closes it again", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
|
||||
@@ -72,10 +40,6 @@ test("opens a Mermaid diagram fullscreen and closes it again", async ({ page })
|
||||
await expectCompletedDiagram(page, ["Start", "Ship"]);
|
||||
});
|
||||
|
||||
await test.step("Source toggles preserve the diagram and exclude hidden controls", async () => {
|
||||
await toggleDiagramSourceAndVerifyControls(page);
|
||||
});
|
||||
|
||||
await test.step("The toolbar opens the diagram fullscreen", async () => {
|
||||
await openDiagramFullscreen(page);
|
||||
await expectFullscreenDiagram(page, ["Start", "Review", "Ship"]);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { expectComposerModel } from "../support/helpers/agent-profiles";
|
||||
import {
|
||||
delayBrowserAgentCreatedStatus,
|
||||
openNewWorkspaceComposer,
|
||||
@@ -70,8 +69,6 @@ test.describe("New workspace live pin", () => {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openNewWorkspaceComposer(page, seeded);
|
||||
// The composer appears before its provider catalog has hydrated.
|
||||
await expectComposerModel(page, "Ten second stream");
|
||||
await submitNewWorkspacePrompt(page, "Pin me while I start");
|
||||
|
||||
await creation.waitForCreateRequest();
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { mkdirSync, mkdtempSync, realpathSync } from "node:fs";
|
||||
import { mkdtempSync, realpathSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { Locator } from "@playwright/test";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import {
|
||||
cleanupRewindFlow,
|
||||
launchAgent,
|
||||
@@ -15,11 +14,7 @@ import {
|
||||
expectTaskListEntries,
|
||||
openCompletedTaskList,
|
||||
} from "../support/helpers/task-list";
|
||||
import { submitMessage, submitMessageWithButton } from "../support/helpers/composer";
|
||||
import { waitForSettledPosition } from "../support/helpers/sheet-layout";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { submitMessage } from "../support/helpers/composer";
|
||||
|
||||
interface TaskScenario {
|
||||
provider: RewindFlowProvider;
|
||||
@@ -119,425 +114,3 @@ test.describe("real provider composer task lists", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function openBackgroundInventory(
|
||||
page: Page,
|
||||
options: { compact: boolean; count: number; timeout: number },
|
||||
): Promise<Locator> {
|
||||
const pill = page.getByRole("button", {
|
||||
name: options.count === 1 ? "1 background task" : `${options.count} background tasks`,
|
||||
});
|
||||
await expect(pill).toBeVisible({ timeout: options.timeout });
|
||||
await pill.click();
|
||||
const panel = page.getByTestId(
|
||||
options.compact ? "background-work-panel-content" : "background-work-panel",
|
||||
);
|
||||
if (options.compact) await waitForSettledPosition(panel);
|
||||
return panel;
|
||||
}
|
||||
|
||||
async function openBackgroundWork(page: Page, compact: boolean): Promise<void> {
|
||||
const panel = await openBackgroundInventory(page, { compact, count: 1, timeout: 30_000 });
|
||||
await expect(panel).toContainText(/sleep 120/);
|
||||
}
|
||||
|
||||
async function expectBackgroundItems(
|
||||
panel: Locator,
|
||||
selected: string,
|
||||
sibling: string,
|
||||
): Promise<void> {
|
||||
await expect(panel).toContainText(selected);
|
||||
await expect(panel).toContainText(sibling);
|
||||
}
|
||||
|
||||
async function stopBackgroundItem(panel: Locator, selected: string): Promise<void> {
|
||||
const escaped = selected.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
await panel.getByRole("button", { name: new RegExp(`Stop .*${escaped}`) }).click();
|
||||
}
|
||||
|
||||
async function expectStoppedItemAndSibling(
|
||||
panel: Locator,
|
||||
selected: string,
|
||||
sibling: string,
|
||||
): Promise<void> {
|
||||
await expect(panel).not.toContainText(selected, { timeout: 40_000 });
|
||||
await expect(panel).toContainText(sibling);
|
||||
}
|
||||
|
||||
async function closeBackgroundWork(page: Page, compact: boolean): Promise<void> {
|
||||
const panel = page.getByTestId(
|
||||
compact ? "background-work-panel-content" : "background-work-panel",
|
||||
);
|
||||
if (compact) {
|
||||
await page
|
||||
.getByRole("button", { name: "Bottom sheet backdrop" })
|
||||
.click({ position: { x: 12, y: 12 } });
|
||||
} else {
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
await expect(panel).toBeHidden();
|
||||
}
|
||||
|
||||
async function expectBackgroundAgentRunning(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("workspace-status-indicator-running").first()).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Stop agent", exact: true })).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function expectAssistantReply(page: Page, text: string): Promise<void> {
|
||||
await expect(page.getByTestId("assistant-message").filter({ hasText: text }).last()).toBeVisible({
|
||||
timeout: 120_000,
|
||||
});
|
||||
}
|
||||
|
||||
async function expectBackgroundPill(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("button", { name: "1 background task" })).toBeVisible();
|
||||
}
|
||||
|
||||
async function expectBackgroundWorkOnAnotherClient(page: Page): Promise<void> {
|
||||
const another = await page.context().newPage();
|
||||
try {
|
||||
await another.goto(page.url());
|
||||
await openBackgroundWork(another, false);
|
||||
} finally {
|
||||
await another.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function startBackgroundWorkWithoutOpeningPane(workspace: SeededWorkspace): Promise<void> {
|
||||
const agent = await workspace.client.createAgent({
|
||||
provider: "claude",
|
||||
model: "haiku",
|
||||
modeId: "bypassPermissions",
|
||||
cwd: workspace.workspaceDirectory,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: "Background work before opening",
|
||||
});
|
||||
await workspace.client.sendAgentMessage(
|
||||
agent.id,
|
||||
"Use Bash with run_in_background to run exactly sleep 120. Do not wait for it. Reply SPAWNED immediately.",
|
||||
);
|
||||
const result = await workspace.client.waitForFinish(agent.id, 120_000);
|
||||
expect(result.status).toBe("idle");
|
||||
}
|
||||
|
||||
async function expectWorkspaceRunning(page: Page, workspaceId: string): Promise<void> {
|
||||
const row = page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`);
|
||||
await expect(row.getByTestId("workspace-status-indicator-running")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
test("Claude keeps background work visible while accepting another turn", async ({ page }) => {
|
||||
test.setTimeout(300_000);
|
||||
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-background-work-")));
|
||||
let handle: AgentHandle | undefined;
|
||||
try {
|
||||
handle = await launchAgent({ page, provider: "claude", cwd, mode: "full-access" });
|
||||
await sendMessage(
|
||||
handle,
|
||||
"Use Bash with run_in_background to run exactly sleep 120. Do not wait for it. Reply SPAWNED immediately.",
|
||||
);
|
||||
await expectBackgroundAgentRunning(page);
|
||||
await openBackgroundWork(page, false);
|
||||
await page.screenshot({
|
||||
path: path.resolve("../../.artifacts/phase1-claude-background.png"),
|
||||
animations: "disabled",
|
||||
});
|
||||
await closeBackgroundWork(page, false);
|
||||
await sendMessage(handle, "Reply exactly HELLO without stopping the background command.");
|
||||
await expectBackgroundPill(page);
|
||||
await expectBackgroundWorkOnAnotherClient(page);
|
||||
} finally {
|
||||
await cleanupRewindFlow({ handle, cwd });
|
||||
}
|
||||
});
|
||||
|
||||
test("Claude background work remains readable in dark mode", async ({ page }) => {
|
||||
test.setTimeout(300_000);
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("@paseo:app-settings", JSON.stringify({ theme: "pureBlack" }));
|
||||
});
|
||||
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-background-dark-")));
|
||||
let handle: AgentHandle | undefined;
|
||||
try {
|
||||
handle = await launchAgent({ page, provider: "claude", cwd, mode: "full-access" });
|
||||
await sendMessage(
|
||||
handle,
|
||||
"Use Bash with run_in_background to run exactly sleep 120. Do not wait for it. Reply SPAWNED immediately.",
|
||||
);
|
||||
await openBackgroundWork(page, false);
|
||||
await page.screenshot({
|
||||
path: path.resolve("../../.artifacts/review-background-dark.png"),
|
||||
animations: "disabled",
|
||||
});
|
||||
} finally {
|
||||
await cleanupRewindFlow({ handle, cwd });
|
||||
}
|
||||
});
|
||||
|
||||
test("Claude background work opens in a compact sheet", async ({ page }) => {
|
||||
test.setTimeout(300_000);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-background-compact-")));
|
||||
let handle: AgentHandle | undefined;
|
||||
try {
|
||||
handle = await launchAgent({ page, provider: "claude", cwd, mode: "full-access" });
|
||||
await submitMessageWithButton(
|
||||
page,
|
||||
"Use Bash with run_in_background to run exactly sleep 120. Do not wait for it. Reply SPAWNED immediately.",
|
||||
);
|
||||
await expectAssistantReply(page, "SPAWNED");
|
||||
await openBackgroundWork(page, true);
|
||||
await page.screenshot({
|
||||
path: path.resolve("../../.artifacts/phase1-claude-compact.png"),
|
||||
animations: "disabled",
|
||||
});
|
||||
await closeBackgroundWork(page, true);
|
||||
await submitMessageWithButton(
|
||||
page,
|
||||
"Reply exactly HELLO without stopping the background command.",
|
||||
);
|
||||
await expectAssistantReply(page, "HELLO");
|
||||
await expectBackgroundPill(page);
|
||||
} finally {
|
||||
await cleanupRewindFlow({ handle, cwd });
|
||||
}
|
||||
});
|
||||
|
||||
test("background work updates the sidebar before the agent is opened", async ({ page }) => {
|
||||
test.setTimeout(300_000);
|
||||
const workspace = await seedWorkspace({ repoPrefix: "background-unopened-" });
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await expect(
|
||||
page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspace.workspaceId}`),
|
||||
).toBeVisible();
|
||||
await startBackgroundWorkWithoutOpeningPane(workspace);
|
||||
await expectWorkspaceRunning(page, workspace.workspaceId);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("Codex keeps a live command visible after its turn", async ({ page }) => {
|
||||
test.setTimeout(300_000);
|
||||
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-codex-background-")));
|
||||
let handle: AgentHandle | undefined;
|
||||
try {
|
||||
handle = await launchAgent({
|
||||
page,
|
||||
provider: "codex",
|
||||
cwd,
|
||||
mode: "full-access",
|
||||
providerConfig: { model: "gpt-6-astra" },
|
||||
});
|
||||
await sendMessage(
|
||||
handle,
|
||||
"Use exec_command to run sleep 120 with yield_time_ms 1000. Do not wait for or stop it. Reply SPAWNED immediately.",
|
||||
);
|
||||
await expectBackgroundAgentRunning(page);
|
||||
await openBackgroundWork(page, false);
|
||||
await page.screenshot({
|
||||
path: path.resolve("../../.artifacts/phase2-codex-background.png"),
|
||||
animations: "disabled",
|
||||
});
|
||||
await closeBackgroundWork(page, false);
|
||||
await sendMessage(handle, "Reply exactly HELLO without stopping the background command.");
|
||||
await expectBackgroundPill(page);
|
||||
await expectBackgroundWorkOnAnotherClient(page);
|
||||
} finally {
|
||||
await cleanupRewindFlow({ handle, cwd });
|
||||
}
|
||||
});
|
||||
|
||||
test("OpenCode keeps a live command visible after its turn", async ({ page }) => {
|
||||
test.setTimeout(300_000);
|
||||
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-opencode-background-")));
|
||||
let handle: AgentHandle | undefined;
|
||||
try {
|
||||
handle = await launchAgent({
|
||||
page,
|
||||
provider: "opencode",
|
||||
cwd,
|
||||
mode: "full-access",
|
||||
providerConfig: { model: "openrouter/google/gemini-3.6-flash" },
|
||||
});
|
||||
await sendMessage(
|
||||
handle,
|
||||
"Use the shell tool with background true to run sleep 120. Do not wait or stop it. Reply SPAWNED immediately.",
|
||||
);
|
||||
await expectBackgroundAgentRunning(page);
|
||||
await openBackgroundWork(page, false);
|
||||
await page.screenshot({
|
||||
path: path.resolve("../../.artifacts/phase2-opencode-background.png"),
|
||||
animations: "disabled",
|
||||
});
|
||||
await closeBackgroundWork(page, false);
|
||||
await sendMessage(handle, "Reply exactly HELLO without stopping the background command.");
|
||||
await expectBackgroundPill(page);
|
||||
await expectBackgroundWorkOnAnotherClient(page);
|
||||
} finally {
|
||||
await cleanupRewindFlow({ handle, cwd });
|
||||
}
|
||||
});
|
||||
|
||||
for (const scenario of [
|
||||
{
|
||||
provider: "claude" as const,
|
||||
prompt:
|
||||
"Use Bash with run_in_background to start sleep 180 and sleep 181 as two separate background commands. Do not wait for them. Reply SPAWNED immediately.",
|
||||
providerConfig: { model: "haiku" },
|
||||
},
|
||||
{
|
||||
provider: "codex" as const,
|
||||
prompt:
|
||||
"Use exec_command twice to start sleep 180 and sleep 181 separately with yield_time_ms 1000. Do not wait for or stop either process. Reply SPAWNED immediately.",
|
||||
providerConfig: { model: "gpt-6-astra" },
|
||||
},
|
||||
{
|
||||
provider: "opencode" as const,
|
||||
prompt:
|
||||
"Use the shell tool twice with background true: start sleep 180, then start sleep 181. Do not wait for or stop either. Reply SPAWNED immediately.",
|
||||
providerConfig: { model: "openrouter/google/gemini-3.6-flash" },
|
||||
},
|
||||
]) {
|
||||
test(`${scenario.provider} stops selected background work after the turn`, async ({ page }) => {
|
||||
test.setTimeout(300_000);
|
||||
mkdirSync(path.resolve("../../.artifacts"), { recursive: true });
|
||||
const cwd = realpathSync(mkdtempSync(path.resolve("../../.artifacts/phase3-qa-workspace-")));
|
||||
let handle: AgentHandle | undefined;
|
||||
try {
|
||||
handle = await launchAgent({
|
||||
page,
|
||||
provider: scenario.provider,
|
||||
cwd,
|
||||
mode: "full-access",
|
||||
providerConfig: scenario.providerConfig,
|
||||
});
|
||||
await sendMessage(handle, scenario.prompt);
|
||||
const panel = await openBackgroundInventory(page, {
|
||||
compact: false,
|
||||
count: 2,
|
||||
timeout: 60_000,
|
||||
});
|
||||
await expectBackgroundItems(panel, "sleep 180", "sleep 181");
|
||||
await stopBackgroundItem(panel, "sleep 180");
|
||||
await expectStoppedItemAndSibling(panel, "sleep 180", "sleep 181");
|
||||
await page.screenshot({
|
||||
path: path.resolve(`../../.artifacts/phase3-${scenario.provider}-stop.png`),
|
||||
animations: "disabled",
|
||||
});
|
||||
await page.keyboard.press("Escape");
|
||||
await sendMessage(handle, "Reply exactly HELLO without stopping the background command.");
|
||||
await expectBackgroundPill(page);
|
||||
} finally {
|
||||
await cleanupRewindFlow({ handle, cwd });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("Claude stops a selected monitor in the compact sheet", async ({ page }) => {
|
||||
test.setTimeout(300_000);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
mkdirSync(path.resolve("../../.artifacts"), { recursive: true });
|
||||
const cwd = realpathSync(mkdtempSync(path.resolve("../../.artifacts/phase3-monitor-workspace-")));
|
||||
let handle: AgentHandle | undefined;
|
||||
try {
|
||||
handle = await launchAgent({
|
||||
page,
|
||||
provider: "claude",
|
||||
cwd,
|
||||
mode: "full-access",
|
||||
providerConfig: { model: "opus" },
|
||||
});
|
||||
await submitMessageWithButton(
|
||||
page,
|
||||
'Use exactly two separate Monitor tool calls, each with command `sleep 150`, timeout_ms 150000, persistent false, descriptions "probe monitor one" and "probe monitor two". Launch both independently, do not wait, then reply SPAWNED. Do not use any other tools.',
|
||||
);
|
||||
await expectAssistantReply(page, "SPAWNED");
|
||||
const panel = await openBackgroundInventory(page, { compact: true, count: 2, timeout: 30_000 });
|
||||
await expectBackgroundItems(panel, "probe monitor one", "probe monitor two");
|
||||
await expect(panel.getByText("sleep 150", { exact: true })).toHaveCount(2);
|
||||
await page.screenshot({
|
||||
path: path.resolve("../../.artifacts/refinement-monitor-compact.png"),
|
||||
animations: "disabled",
|
||||
});
|
||||
await stopBackgroundItem(panel, "probe monitor one");
|
||||
await expectStoppedItemAndSibling(panel, "probe monitor one", "probe monitor two");
|
||||
await page.screenshot({
|
||||
path: path.resolve("../../.artifacts/phase3-claude-monitor-compact.png"),
|
||||
animations: "disabled",
|
||||
});
|
||||
await closeBackgroundWork(page, true);
|
||||
await submitMessageWithButton(
|
||||
page,
|
||||
"Reply exactly HELLO without stopping the background monitor.",
|
||||
);
|
||||
await expectAssistantReply(page, "HELLO");
|
||||
await expectBackgroundPill(page);
|
||||
await handle.client.sendAgentMessage(
|
||||
handle.agentId,
|
||||
"Use Bash to run sleep 20 in the foreground, then reply WAITED.",
|
||||
);
|
||||
await expect(page.getByRole("button", { name: "Stop agent", exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await handle.client.sendAgentMessage(
|
||||
handle.agentId,
|
||||
"Reply exactly INTERRUPTED without stopping the background monitor.",
|
||||
{ activeTurnBehavior: "interrupt" },
|
||||
);
|
||||
expect((await handle.client.waitForFinish(handle.agentId, 120_000)).status).toBe("idle");
|
||||
await expectBackgroundPill(page);
|
||||
await expect
|
||||
.poll(async () => handle!.client.listAgentBackgroundWork(handle!.agentId))
|
||||
.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ description: "probe monitor two", kind: "monitor" }),
|
||||
]),
|
||||
);
|
||||
} finally {
|
||||
await cleanupRewindFlow({ handle, cwd });
|
||||
}
|
||||
});
|
||||
|
||||
test("Claude shows monitor description and command in the wide track", async ({ page }) => {
|
||||
test.setTimeout(300_000);
|
||||
const cwd = realpathSync(
|
||||
mkdtempSync(path.resolve("../../.artifacts/refinement-wide-workspace-")),
|
||||
);
|
||||
let handle: AgentHandle | undefined;
|
||||
try {
|
||||
handle = await launchAgent({
|
||||
page,
|
||||
provider: "claude",
|
||||
cwd,
|
||||
mode: "full-access",
|
||||
providerConfig: { model: "opus" },
|
||||
});
|
||||
await sendMessage(
|
||||
handle,
|
||||
'Use Monitor with command `sleep 150`, timeout_ms 150000, persistent false, description "Watch the build". Do not wait; reply SPAWNED. Do not use other tools.',
|
||||
);
|
||||
await expectBackgroundAgentRunning(page);
|
||||
const workspaceRow = page.getByTestId(
|
||||
`sidebar-workspace-row-${getServerId()}:${handle.workspaceId}`,
|
||||
);
|
||||
await workspaceRow.scrollIntoViewIfNeeded();
|
||||
await expect(workspaceRow.getByTestId("workspace-status-indicator-running")).toBeVisible();
|
||||
const panel = await openBackgroundInventory(page, {
|
||||
compact: false,
|
||||
count: 1,
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(panel).toContainText("Watch the build");
|
||||
await expect(panel).toContainText("sleep 150");
|
||||
await expect(panel.getByRole("button", { name: "Stop Watch the build" })).toBeVisible();
|
||||
await page.screenshot({
|
||||
path: path.resolve("../../.artifacts/refinement-monitor-wide.png"),
|
||||
animations: "disabled",
|
||||
});
|
||||
} finally {
|
||||
await cleanupRewindFlow({ handle, cwd });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -60,15 +60,6 @@ async function verifyChronologicalFiltering(page: Page): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
async function verifyHistorySurvivesReload(page: Page): Promise<void> {
|
||||
await test.step("reloads History without visiting a workspace to reconnect", async () => {
|
||||
await expectVisibleTitles(page, [TITLES.unbilled, TITLES.billing, TITLES.terminal]);
|
||||
await page.reload();
|
||||
await expectVisibleTitles(page, [TITLES.unbilled, TITLES.billing, TITLES.terminal]);
|
||||
await expect(page.getByTestId("sessions-host-errors")).toBeHidden();
|
||||
});
|
||||
}
|
||||
|
||||
async function verifyRecencyBeforeMatchStrength(page: Page): Promise<void> {
|
||||
await test.step("keeps newer partial matches before older stronger matches", async () => {
|
||||
await search(page, `${NONCE} bill`);
|
||||
@@ -160,8 +151,6 @@ test.describe("History search", () => {
|
||||
|
||||
await verifyChronologicalFiltering(page);
|
||||
|
||||
await verifyHistorySurvivesReload(page);
|
||||
|
||||
await verifyRecencyBeforeMatchStrength(page);
|
||||
|
||||
await verifyExactAndTypoHighlights(page);
|
||||
|
||||
@@ -95,63 +95,66 @@ test.describe("Settings sidebar scroll position", () => {
|
||||
await expectSettingsSidebarScrollOffset(page, offset);
|
||||
});
|
||||
|
||||
test("keeps sidebar and detail scrolling independent through section changes", async ({
|
||||
test("scrolls the sidebar and detail pane independently", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
const sidebarOffset = await scrollSettingsSidebarToEnd(page);
|
||||
await openSettingsHostSection(page, getServerId(), "providers");
|
||||
await expect(page.getByTestId("host-page-providers-card")).toBeVisible();
|
||||
const detailOffset = await scrollSettingsDetailDown(page);
|
||||
await expectSettingsSidebarScrollOffset(page, sidebarOffset);
|
||||
|
||||
await scrollSettingsSidebarUp(page, 60);
|
||||
await expectSettingsSidebarScrollOffset(page, sidebarOffset - 60);
|
||||
await expectSettingsDetailScrollOffset(page, detailOffset);
|
||||
});
|
||||
|
||||
test("keeps the sidebar position after rapid consecutive selections", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
|
||||
const scrollTop = await scrollSettingsSidebarToEnd(page);
|
||||
|
||||
await selectSettingsHostSectionsRapidly(page);
|
||||
await expectSettingsSidebarScrollOffset(page, scrollTop);
|
||||
|
||||
await expectSettingsSidebarScrollSettled(page, scrollTop);
|
||||
});
|
||||
|
||||
test("keeps the sidebar position when switching between app and host sections", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
|
||||
await test.step("scrolls the sidebar and detail pane independently", async () => {
|
||||
const sidebarOffset = await scrollSettingsSidebarToEnd(page);
|
||||
await openSettingsHostSection(page, getServerId(), "providers");
|
||||
await expect(page.getByTestId("host-page-providers-card")).toBeVisible();
|
||||
const detailOffset = await scrollSettingsDetailDown(page);
|
||||
await expectSettingsSidebarScrollOffset(page, sidebarOffset);
|
||||
const hostScrollTop = await scrollSettingsSidebarToEnd(page);
|
||||
await expect(page.getByTestId("settings-host-section-providers")).toBeInViewport();
|
||||
|
||||
await scrollSettingsSidebarUp(page, 60);
|
||||
await expectSettingsSidebarScrollOffset(page, sidebarOffset - 60);
|
||||
await expectSettingsDetailScrollOffset(page, detailOffset);
|
||||
});
|
||||
for (const section of [
|
||||
"providers",
|
||||
"terminals",
|
||||
"providers",
|
||||
"terminals",
|
||||
"providers",
|
||||
] as const) {
|
||||
await openSettingsHostSection(page, getServerId(), section);
|
||||
await expect(page.getByTestId("page-title")).toHaveText(
|
||||
section === "providers" ? "Providers" : "Terminals",
|
||||
);
|
||||
await expectSettingsSidebarScrollOffset(page, hostScrollTop);
|
||||
}
|
||||
|
||||
await test.step("keeps the sidebar position after rapid consecutive selections", async () => {
|
||||
const scrollTop = await scrollSettingsSidebarToEnd(page);
|
||||
const sidebar = page.getByTestId("settings-sidebar");
|
||||
const appScrollTop = await revealSettingsSidebarSection(page, "diagnostics");
|
||||
await expect(sidebar.getByRole("button", { name: "About", exact: true })).toBeInViewport();
|
||||
|
||||
await selectSettingsHostSectionsRapidly(page);
|
||||
await expectSettingsSidebarScrollOffset(page, scrollTop);
|
||||
await openSettingsSection(page, "about");
|
||||
await expectAboutContent(page);
|
||||
await expectSettingsSidebarScrollOffset(page, appScrollTop);
|
||||
|
||||
await expectSettingsSidebarScrollSettled(page, scrollTop);
|
||||
});
|
||||
|
||||
await test.step("keeps the sidebar position when switching between app and host sections", async () => {
|
||||
const hostScrollTop = await scrollSettingsSidebarToEnd(page);
|
||||
await expect(page.getByTestId("settings-host-section-providers")).toBeInViewport();
|
||||
|
||||
for (const section of [
|
||||
"providers",
|
||||
"terminals",
|
||||
"providers",
|
||||
"terminals",
|
||||
"providers",
|
||||
] as const) {
|
||||
await openSettingsHostSection(page, getServerId(), section);
|
||||
await expect(page.getByTestId("page-title")).toHaveText(
|
||||
section === "providers" ? "Providers" : "Terminals",
|
||||
);
|
||||
await expectSettingsSidebarScrollOffset(page, hostScrollTop);
|
||||
}
|
||||
|
||||
const sidebar = page.getByTestId("settings-sidebar");
|
||||
const appScrollTop = await revealSettingsSidebarSection(page, "diagnostics");
|
||||
await expect(sidebar.getByRole("button", { name: "About", exact: true })).toBeInViewport();
|
||||
|
||||
await openSettingsSection(page, "about");
|
||||
await expectAboutContent(page);
|
||||
await expectSettingsSidebarScrollOffset(page, appScrollTop);
|
||||
|
||||
await openSettingsSection(page, "diagnostics");
|
||||
await expectDiagnosticsContent(page);
|
||||
await expectSettingsSidebarScrollOffset(page, appScrollTop);
|
||||
});
|
||||
await openSettingsSection(page, "diagnostics");
|
||||
await expectDiagnosticsContent(page);
|
||||
await expectSettingsSidebarScrollOffset(page, appScrollTop);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import { withSidebarViews } from "../support/helpers/sidebar-views";
|
||||
|
||||
test("sidebar views replace the content, preserve chrome, and recover to the default", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(150_000);
|
||||
await withSidebarViews(page, async (views) => {
|
||||
await views.expectDefault();
|
||||
await views.chooseWorkspaces();
|
||||
await views.exerciseExamples();
|
||||
await views.exerciseTerminals();
|
||||
await views.restoreDefault();
|
||||
await views.recoverFromFailure();
|
||||
await views.chooseWorkspaces();
|
||||
await views.disableChosenView();
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,12 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { test as base, expect } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import {
|
||||
openAgentRoute,
|
||||
seedMockAgentWorkspace,
|
||||
type MockAgentWorkspace,
|
||||
} from "../support/helpers/mock-agent";
|
||||
import { expectOpenTabCount } from "../support/helpers/sheet";
|
||||
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||
import { expectReconnectingToastGone } from "../support/helpers/workspace-ui";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { closeMobileAgentSidebar, openMobileAgentSidebar } from "../support/helpers/sidebar";
|
||||
|
||||
@@ -102,13 +101,11 @@ async function leaveMarkedWorkspaceAndReopen(page: Page, { subject, other }: Fin
|
||||
}
|
||||
|
||||
async function completeTurnAndLeave(page: Page, { subject, other }: FinishedWorkspaces) {
|
||||
await test.step("ordinary completion stays unread on departure and clears on return", async () => {
|
||||
await test.step("ordinary completion still clears on departure", async () => {
|
||||
await subject.client.sendAgentMessage(subject.agentId, "Finish another turn.");
|
||||
await subject.client.waitForFinish(subject.agentId, 20_000);
|
||||
await expectStatus(page, subject.workspaceId, "attention");
|
||||
await openWorkspace(page, other.workspaceId);
|
||||
await expectStatus(page, subject.workspaceId, "attention");
|
||||
await openWorkspace(page, subject.workspaceId);
|
||||
await expectStatus(page, subject.workspaceId, "done");
|
||||
});
|
||||
}
|
||||
@@ -144,15 +141,13 @@ async function leaveMarkedWorkspaceAndReopenOnCompact(
|
||||
}
|
||||
|
||||
async function completeTurnAndLeaveOnCompact(page: Page, { subject, other }: FinishedWorkspaces) {
|
||||
await test.step("ordinary completion stays unread on departure and clears on return", async () => {
|
||||
await test.step("ordinary completion still clears on departure", async () => {
|
||||
await closeMobileAgentSidebar(page);
|
||||
await subject.client.sendAgentMessage(subject.agentId, "Finish another turn.");
|
||||
await subject.client.waitForFinish(subject.agentId, 20_000);
|
||||
await openMobileAgentSidebar(page);
|
||||
await expectStatus(page, subject.workspaceId, "attention");
|
||||
await openWorkspaceOnCompact(page, other.workspaceId);
|
||||
await expectStatus(page, subject.workspaceId, "attention");
|
||||
await openWorkspaceOnCompact(page, subject.workspaceId);
|
||||
await expectStatus(page, subject.workspaceId, "done");
|
||||
});
|
||||
}
|
||||
@@ -199,12 +194,11 @@ async function markUnreadThenResumeChat(page: Page, workspaceId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
test("manual unread survives departure and clears on reopening and ordinary completion survives departure", async ({
|
||||
test("manual unread survives departure and clears on reopening without changing normal completions", async ({
|
||||
page,
|
||||
workspaces,
|
||||
}) => {
|
||||
await openAgentRoute(page, workspaces.subject);
|
||||
await expect(page.getByText("Finish this turn.", { exact: true })).toBeVisible();
|
||||
await gotoAppShell(page);
|
||||
await openWorkspace(page, workspaces.other.workspaceId);
|
||||
await markBackgroundWorkspaceAndReopen(page, workspaces.subject.workspaceId);
|
||||
await leaveMarkedWorkspaceAndRead(page, workspaces);
|
||||
@@ -213,19 +207,16 @@ test("manual unread survives departure and clears on reopening and ordinary comp
|
||||
await markBackgroundWorkspaceAndRead(page, workspaces.subject.workspaceId);
|
||||
});
|
||||
|
||||
test("clicking a multi-agent workspace preserves its selected agent and leaves the other unread", async ({
|
||||
test("clicking a multi-agent workspace reveals and clears its marked agent", async ({
|
||||
page,
|
||||
workspaces,
|
||||
}) => {
|
||||
await openAgentRoute(page, workspaces.subject);
|
||||
await expect(page.getByText("Finish this turn.", { exact: true })).toBeVisible();
|
||||
await expectSelectedAgent(page, workspaces.subject.agentId);
|
||||
await openWorkspace(page, workspaces.other.workspaceId);
|
||||
await addFinishedAgent(workspaces.subject);
|
||||
await markAsUnread(page, workspaces.subject.workspaceId);
|
||||
await openWorkspace(page, workspaces.subject.workspaceId);
|
||||
await expectSelectedAgent(page, workspaces.subject.agentId);
|
||||
await expectStatus(page, workspaces.subject.workspaceId, "attention");
|
||||
const newest = await addFinishedAgent(workspaces.subject);
|
||||
await markBackgroundWorkspaceAndReopen(page, workspaces.subject.workspaceId);
|
||||
await expectSelectedAgent(page, newest.id);
|
||||
});
|
||||
|
||||
test("manual unread survives leaving the current workspace on compact layout", async ({
|
||||
@@ -239,7 +230,7 @@ test("manual unread survives leaving the current workspace on compact layout", a
|
||||
await completeTurnAndLeaveOnCompact(page, workspaces);
|
||||
});
|
||||
|
||||
async function reopenWorkspaceAfterBackgroundTurn(page: Page, workspace: MockAgentWorkspace) {
|
||||
async function reopenMarkedWorkspaceAfterBackgroundTurn(page: Page, workspace: MockAgentWorkspace) {
|
||||
const route = page.url();
|
||||
// End this app session before the turn, so its cached transcript cannot contain the new prompt.
|
||||
await page.goto("about:blank");
|
||||
@@ -274,72 +265,9 @@ test("reopening a marked unread workspace restores only its agent tab and curren
|
||||
await test.step("mark the current workspace unread, then close and reopen the app", async () => {
|
||||
await openMobileAgentSidebar(page);
|
||||
await markAsUnread(page, workspaces.subject.workspaceId);
|
||||
await reopenWorkspaceAfterBackgroundTurn(page, workspaces.subject);
|
||||
await reopenMarkedWorkspaceAfterBackgroundTurn(page, workspaces.subject);
|
||||
});
|
||||
await test.step("restore the main tab without Explorer entries and load its latest history", async () => {
|
||||
await expectRestoredCompactChat(page);
|
||||
});
|
||||
});
|
||||
|
||||
test("clicking the already focused timeline acknowledges a new completion", async ({
|
||||
page,
|
||||
workspaces,
|
||||
}) => {
|
||||
await openAgentRoute(page, workspaces.subject);
|
||||
await expect(page.getByText("Finish this turn.", { exact: true })).toBeVisible();
|
||||
await workspaces.subject.client.sendAgentMessage(
|
||||
workspaces.subject.agentId,
|
||||
"Finish another turn.",
|
||||
);
|
||||
await workspaces.subject.client.waitForFinish(workspaces.subject.agentId, 20_000);
|
||||
await expectStatus(page, workspaces.subject.workspaceId, "attention");
|
||||
await page.getByText("Finish another turn.", { exact: true }).click();
|
||||
await expectStatus(page, workspaces.subject.workspaceId, "done");
|
||||
});
|
||||
|
||||
async function reconnectWithoutInteracting(
|
||||
page: Page,
|
||||
gate: Awaited<ReturnType<typeof installDaemonWebSocketGate>>,
|
||||
agentId: string,
|
||||
) {
|
||||
await gate.drop();
|
||||
await gate.waitForBlockedConnection();
|
||||
gate.holdTimelineResponses(agentId);
|
||||
gate.restore();
|
||||
await gate.waitForHeldTimelineResponse();
|
||||
await expectReconnectingToastGone(page);
|
||||
gate.releaseHeldTimelineResponses();
|
||||
await expect(page.getByRole("alert").filter({ hasText: "Updating messages" })).toHaveCount(0);
|
||||
}
|
||||
|
||||
test("reconnecting a focused chat preserves its unread completion until another click", async ({
|
||||
page,
|
||||
workspaces,
|
||||
}) => {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
await openAgentRoute(page, workspaces.subject);
|
||||
await expect(page.getByText("Finish this turn.", { exact: true })).toBeVisible();
|
||||
await workspaces.subject.client.sendAgentMessage(
|
||||
workspaces.subject.agentId,
|
||||
"Unread across reconnect.",
|
||||
);
|
||||
await workspaces.subject.client.waitForFinish(workspaces.subject.agentId, 20_000);
|
||||
await expectStatus(page, workspaces.subject.workspaceId, "attention");
|
||||
await reconnectWithoutInteracting(page, gate, workspaces.subject.agentId);
|
||||
await expectStatus(page, workspaces.subject.workspaceId, "attention");
|
||||
await page.getByText("Unread across reconnect.", { exact: true }).click();
|
||||
await expectStatus(page, workspaces.subject.workspaceId, "done");
|
||||
});
|
||||
|
||||
test("a cached read chat acknowledges a completion that arrived while the app was closed", async ({
|
||||
page,
|
||||
workspaces,
|
||||
}) => {
|
||||
await openCompactWorkspace(page, workspaces.subject);
|
||||
await expectStatus(page, workspaces.subject.workspaceId, "done");
|
||||
await closeMobileAgentSidebar(page);
|
||||
await reopenWorkspaceAfterBackgroundTurn(page, workspaces.subject);
|
||||
await expectRestoredCompactChat(page);
|
||||
await openMobileAgentSidebar(page);
|
||||
await expectStatus(page, workspaces.subject.workspaceId, "done");
|
||||
});
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
closeMobileAgentSidebar,
|
||||
expectMobileAgentSidebarHidden,
|
||||
expectMobileAgentSidebarVisible,
|
||||
expectOnlyWorkspaceHoverCardFor,
|
||||
expectWorkspaceHoverCardClosed,
|
||||
expectWorkspaceHoverCardOpen,
|
||||
expectWorkspaceHoverCardStaysOpen,
|
||||
@@ -15,7 +14,6 @@ import {
|
||||
openMobileAgentSidebar,
|
||||
pinWorkspaceFromSidebar,
|
||||
scrollWorkspaceList,
|
||||
sweepPointerAcrossWorkspaceRows,
|
||||
} from "../support/helpers/sidebar";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { expectWorkspaceHeader } from "../support/helpers/workspace-ui";
|
||||
@@ -82,22 +80,6 @@ async function openWorkspaceHoverCard(page: import("@playwright/test").Page, wor
|
||||
return hoverCard;
|
||||
}
|
||||
|
||||
async function sortWorkspacesBySidebarOrder<T extends { workspaceId: string }>(
|
||||
page: Page,
|
||||
workspaces: T[],
|
||||
): Promise<T[]> {
|
||||
const positioned = await Promise.all(
|
||||
workspaces.map(async (workspace) => {
|
||||
const bounds = await page
|
||||
.getByTestId(getWorkspaceRowTestId(workspace.workspaceId))
|
||||
.boundingBox();
|
||||
if (!bounds) throw new Error(`Could not measure the row of ${workspace.workspaceId}`);
|
||||
return { workspace, y: bounds.y };
|
||||
}),
|
||||
);
|
||||
return positioned.sort((left, right) => left.y - right.y).map(({ workspace }) => workspace);
|
||||
}
|
||||
|
||||
interface PaseoOwnedWorktree {
|
||||
projectName: string;
|
||||
workspaceId: string;
|
||||
@@ -448,38 +430,6 @@ test.describe("Sidebar workspace list", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("scanning up the workspace rows leaves only the resting row's hover card open", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspaces = await Promise.all(
|
||||
[1, 2, 3, 4, 5].map((index) =>
|
||||
seedWorkspace({
|
||||
repoPrefix: `sidebar-hover-scan-${index}-`,
|
||||
title: `Hover scan ${index}`,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
for (const workspace of workspaces) {
|
||||
await waitForSidebarWorkspace(page, workspace.workspaceId);
|
||||
}
|
||||
const rowsTopToBottom = await sortWorkspacesBySidebarOrder(page, workspaces);
|
||||
const top = rowsTopToBottom[0]!;
|
||||
|
||||
for (let scan = 0; scan < 3; scan += 1) {
|
||||
await sweepPointerAcrossWorkspaceRows(
|
||||
page,
|
||||
rowsTopToBottom.map((workspace) => workspace.workspaceId).toReversed(),
|
||||
);
|
||||
await expectOnlyWorkspaceHoverCardFor(page, top.workspaceName);
|
||||
}
|
||||
} finally {
|
||||
await Promise.all(workspaces.map((workspace) => workspace.cleanup()));
|
||||
}
|
||||
});
|
||||
|
||||
test("workspace hover card follows keyboard focus on a row", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-hover-keyboard-" });
|
||||
|
||||
|
||||
@@ -45,11 +45,6 @@ process.stdin.on("data", (chunk) => {
|
||||
for (;;) {
|
||||
const paste = /\x1b\[200~([^]*?)\x1b\[201~/.exec(buffer);
|
||||
if (!paste) break;
|
||||
const pasted = JSON.stringify(paste[1]).replace(
|
||||
/\s/g,
|
||||
(space) => "\\u" + space.charCodeAt(0).toString(16).padStart(4, "0"),
|
||||
);
|
||||
process.stdout.write("PASTED:" + pasted + "\r\n");
|
||||
for (const [, path] of paste[1].matchAll(/'([^']*)'/g)) {
|
||||
const digest = crypto.createHash("sha256").update(fs.readFileSync(path)).digest("hex");
|
||||
const location = /[\\/]uploads[\\/]upload_/.test(path) ? "upload" : "local";
|
||||
@@ -207,21 +202,6 @@ test.describe("Terminal protocol queries", () => {
|
||||
await expect.poll(() => terminalOutput(terminalId), { timeout: 10_000 }).toContain(text);
|
||||
}
|
||||
|
||||
// Every bracketed paste body the receiver consumed, in order and joined: what a
|
||||
// shell would read as one command line.
|
||||
async function pastedText(terminalId: string): Promise<string> {
|
||||
const output = await terminalOutput(terminalId);
|
||||
return [...output.matchAll(/PASTED:("(?:[^"\\]|\\.)*")/g)]
|
||||
.map(([, json]) => JSON.parse(json) as string)
|
||||
.join("");
|
||||
}
|
||||
|
||||
// Exactly one ASCII space after each quoted path and nothing else between them,
|
||||
// so the paths stay separate shell words.
|
||||
async function expectSeparatePastedPaths(terminalId: string): Promise<void> {
|
||||
await expect.poll(() => pastedText(terminalId)).toMatch(/^'[^']+' '[^']+' $/);
|
||||
}
|
||||
|
||||
test("pastes images from two panes into their own terminals as uploaded host files", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -258,23 +238,6 @@ test.describe("Terminal protocol queries", () => {
|
||||
|
||||
await expectTerminalOutput(terminal.id, fileRead(pngBytes(3), "upload"));
|
||||
await expectTerminalOutput(terminal.id, fileRead(Buffer.from("remote notes"), "upload"));
|
||||
await expectSeparatePastedPaths(terminal.id);
|
||||
} finally {
|
||||
await harness.killTerminal(terminal.id);
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the paths of two consecutive drops to another machine apart", async ({ page }) => {
|
||||
const terminal = await openFileReceiver(page, "file-drop-remote-twice");
|
||||
const first = { name: "first.png", type: "image/png", bytes: pngBytes(10) };
|
||||
const second = { name: "second.txt", type: "text/plain", bytes: Buffer.from("second drop") };
|
||||
try {
|
||||
await dropFiles(page, [first]);
|
||||
await expectTerminalOutput(terminal.id, fileRead(first.bytes, "upload"));
|
||||
await dropFiles(page, [second]);
|
||||
await expectTerminalOutput(terminal.id, fileRead(second.bytes, "upload"));
|
||||
|
||||
await expectSeparatePastedPaths(terminal.id);
|
||||
} finally {
|
||||
await harness.killTerminal(terminal.id);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import { getActiveTabTestId } from "../support/helpers/launcher";
|
||||
import type { Page, Locator } from "@playwright/test";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { expectComposerVisible } from "../support/helpers/composer";
|
||||
import { withDirectoryPlugin } from "../support/helpers/plugin-timeline";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
test.use({
|
||||
@@ -51,36 +50,15 @@ async function seedAnsweredAgent(title: string) {
|
||||
}
|
||||
|
||||
/** Chat rows between the latest turn's summary line and its reply, in document order. */
|
||||
async function rowsBetweenLatestSummaryAndReply(
|
||||
page: Page,
|
||||
replyTestId = "assistant-message",
|
||||
): Promise<number> {
|
||||
return page.evaluate((testId) => {
|
||||
async function rowsBetweenLatestSummaryAndReply(page: Page): Promise<number> {
|
||||
return page.evaluate(() => {
|
||||
const rows = [...document.querySelectorAll("[data-message-id]")];
|
||||
const summaryRow = rows.findLastIndex((row) => row.querySelector("[data-testid=turn-summary]"));
|
||||
const replyRow = rows.findLastIndex((row) => row.querySelector(`[data-testid="${testId}"]`));
|
||||
const replyRow = rows.findLastIndex((row) =>
|
||||
row.querySelector("[data-testid=assistant-message]"),
|
||||
);
|
||||
return replyRow - summaryRow - 1;
|
||||
}, replyTestId);
|
||||
}
|
||||
|
||||
/** The latest turn's summary line is a chat row of its own, sitting apart from its prompt. */
|
||||
async function expectSummaryLineSitsApartFromPrompt(page: Page) {
|
||||
const summaryRow = () =>
|
||||
page.evaluate(() => {
|
||||
const latest = (testId: string) =>
|
||||
[...document.querySelectorAll(`[data-testid="${testId}"]`)].at(-1)!;
|
||||
const summary = latest("turn-summary");
|
||||
const row = summary.closest("[data-message-id]")!;
|
||||
return {
|
||||
sharesRowWithPrompt: row.querySelector("[data-testid=user-message]") !== null,
|
||||
gapBelowPrompt: Math.round(
|
||||
summary.getBoundingClientRect().top -
|
||||
latest("user-message").getBoundingClientRect().bottom,
|
||||
),
|
||||
};
|
||||
});
|
||||
await expect.poll(summaryRow).toMatchObject({ sharesRowWithPrompt: false });
|
||||
expect((await summaryRow()).gapBelowPrompt).toBeGreaterThan(0);
|
||||
});
|
||||
}
|
||||
|
||||
async function groupedToolsInsideLatestTurn(page: Page): Promise<string[]> {
|
||||
@@ -148,67 +126,6 @@ async function expandAndCollapseLongPrompt(page: Page) {
|
||||
}
|
||||
|
||||
test.describe("Turn collapse", () => {
|
||||
test("folds work before a plugin-rendered reply and reopens it", async ({ page }, info) => {
|
||||
test.setTimeout(120_000);
|
||||
await useChatDisplay(page, "collapseTurns");
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "plugin-reply-fold-",
|
||||
title: "Plugin reply folding",
|
||||
model: "ten-second-stream",
|
||||
initialPrompt: "Make the fix and run the burst script.",
|
||||
});
|
||||
try {
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
await withDirectoryPlugin({
|
||||
pluginId: "reply-fold-e2e",
|
||||
source: `import React from "react";
|
||||
import { Text } from "react-native";
|
||||
import { z } from "zod";
|
||||
|
||||
export default function contribute(client) {
|
||||
client.addTimelineTransformer({
|
||||
id: "reply", query: { itemType: "assistant_message" },
|
||||
transform({ item, phase }) {
|
||||
if (phase === "streaming") return undefined;
|
||||
return { items: [{ type: "plugin", kind: "reply", version: 1, data: { text: item.text } }] };
|
||||
},
|
||||
});
|
||||
client.addTimelineRenderer({
|
||||
kind: "reply", version: 1, schema: z.object({ text: z.string() }),
|
||||
Component: ({ item }) => <Text testID="plugin-reply">{item.data.text}</Text>,
|
||||
});
|
||||
return () => {};
|
||||
}`,
|
||||
run: async () => {
|
||||
await page.setViewportSize({ width: 1280, height: 1000 });
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
for (const width of [1280, 390]) {
|
||||
await page.setViewportSize({ width, height: 1000 });
|
||||
const summary = page.getByTestId("turn-summary").last().getByRole("button");
|
||||
const reply = page.getByTestId("plugin-reply").last();
|
||||
await expect(summary).toBeVisible();
|
||||
await expect(reply).toContainText("(end of synthetic stream)");
|
||||
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page, "plugin-reply")).toBe(0);
|
||||
await page.screenshot({ path: info.outputPath(`plugin-reply-folded-${width}.png`) });
|
||||
await summary.click();
|
||||
await expect
|
||||
.poll(() => rowsBetweenLatestSummaryAndReply(page, "plugin-reply"))
|
||||
.toBeGreaterThan(0);
|
||||
await expect(reply).toContainText("(end of synthetic stream)");
|
||||
await summary.click();
|
||||
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page, "plugin-reply")).toBe(0);
|
||||
}
|
||||
await page.reload();
|
||||
await expect(page.getByTestId("turn-summary").last()).toBeVisible();
|
||||
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page, "plugin-reply")).toBe(0);
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("folds each answered turn behind a summary line that opens it", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.setViewportSize({ width: 1280, height: 1000 });
|
||||
@@ -228,7 +145,6 @@ export default function contribute(client) {
|
||||
/\(end of synthetic stream\)$/,
|
||||
);
|
||||
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
|
||||
await expectSummaryLineSitsApartFromPrompt(page);
|
||||
|
||||
await summary.click();
|
||||
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBeGreaterThan(0);
|
||||
@@ -428,6 +344,7 @@ test("opening a turn keeps the received message above it clipped on every frame"
|
||||
.getByTestId("user-message")
|
||||
.filter({ has: page.getByRole("button", { name: "Sent by Reviewer", exact: true }) });
|
||||
await expect(bubble.getByRole("button", { name: "Show more", exact: true })).toBeVisible();
|
||||
await bubble.scrollIntoViewIfNeeded();
|
||||
|
||||
await watchReceivedMessagePreview(page, "Sent by Reviewer");
|
||||
await page
|
||||
@@ -491,87 +408,34 @@ async function seedWholeTurnHistory() {
|
||||
return agent;
|
||||
}
|
||||
|
||||
async function expectInitialFillIsConcealed(page: Page) {
|
||||
await expect(page.getByTestId("agent-initial-fill")).toBeVisible();
|
||||
await expect(page.getByTestId("agent-chat-scroll")).toHaveCSS("opacity", "0");
|
||||
}
|
||||
|
||||
async function expectFilledChatAtBottom(page: Page) {
|
||||
await expect(page.getByTestId("agent-initial-fill")).toBeHidden();
|
||||
const chat = page.getByTestId("agent-chat-scroll");
|
||||
await expect(chat).toHaveCSS("opacity", "1");
|
||||
await expect
|
||||
.poll(() =>
|
||||
chat.evaluate((element) => ({
|
||||
hasOverflow: element.scrollHeight > element.clientHeight,
|
||||
atBottom: Math.abs(element.scrollHeight - element.clientHeight - element.scrollTop) <= 2,
|
||||
})),
|
||||
)
|
||||
.toEqual({ hasOverflow: true, atBottom: true });
|
||||
}
|
||||
|
||||
test("fills collapsed history before revealing a fresh chat", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.setViewportSize({ width: 1280, height: 1000 });
|
||||
await useChatDisplay(page, "collapseTurns");
|
||||
const agent = await seedWholeTurnHistory();
|
||||
try {
|
||||
const history = await holdOlderHistoryPages(page, agent);
|
||||
await openAgentRoute(page, agent);
|
||||
await history.expectRequestedPages(1);
|
||||
await expectInitialFillIsConcealed(page);
|
||||
history.releasePage(1);
|
||||
await history.expectRequestedPages(2);
|
||||
await expectInitialFillIsConcealed(page);
|
||||
history.releasePage(2);
|
||||
await expectFilledChatAtBottom(page);
|
||||
await page.setViewportSize({ width: 1280, height: 8000 });
|
||||
await history.expectRequestedPages(3);
|
||||
await expect(page.getByTestId("agent-chat-scroll")).toHaveCSS("opacity", "1");
|
||||
await expect(page.getByTestId("agent-initial-fill")).toBeHidden();
|
||||
history.releasePage(3);
|
||||
await history.expectSettledWithRequestedPages(3);
|
||||
history.expectNoRepeatedEntries();
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("loads complete long turns and preserves expansion while revealing older history", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.setViewportSize({ width: 1280, height: 1000 });
|
||||
await useChatDisplay(page, "collapseTurns");
|
||||
const agent = await seedWholeTurnHistory();
|
||||
try {
|
||||
const history = await holdOlderHistoryPages(page, agent);
|
||||
await openAgentRoute(page, agent);
|
||||
await history.expectRequestedPages(1);
|
||||
history.releasePage(1);
|
||||
await history.expectRequestedPages(2);
|
||||
history.releasePage(2);
|
||||
await expectFilledChatAtBottom(page);
|
||||
await expectComposerVisible(page);
|
||||
const latest = page
|
||||
.getByTestId("turn-summary")
|
||||
.getByRole("button", { name: "Ran 120 commands", exact: true });
|
||||
await expect(latest).toBeVisible();
|
||||
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
|
||||
await latest.click();
|
||||
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(120);
|
||||
await userScrollsTimelineToHistoryStart(page);
|
||||
await history.expectRequestedPages(1);
|
||||
history.releasePage(1);
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("turn-summary")
|
||||
.getByRole("button", { name: "Ran 60 commands", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
|
||||
await latest.click();
|
||||
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(120);
|
||||
await userScrollsTimelineToHistoryStart(page);
|
||||
await history.expectRequestedPages(3);
|
||||
history.releasePage(3);
|
||||
await page.getByTestId("scroll-to-bottom-button").click();
|
||||
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(120);
|
||||
history.expectNoRepeatedEntries();
|
||||
await scrollTimelineRowIntoView(page, latest);
|
||||
await latest.scrollIntoViewIfNeeded();
|
||||
await latest.click();
|
||||
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
|
||||
} finally {
|
||||
|
||||
@@ -1,187 +0,0 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import {
|
||||
archiveWorkspaceFromDaemon,
|
||||
connectNewWorkspaceDaemonClient,
|
||||
createWorktreeViaDaemon,
|
||||
openProjectViaDaemon,
|
||||
} from "../support/helpers/new-workspace";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { expectWorkspaceAbsentFromSidebar } from "../support/helpers/sidebar";
|
||||
import { createMockIdleAgent, openWorkspaceWithAgents } from "../support/helpers/archive-tab";
|
||||
import { connectSeedClient } from "../support/helpers/seed-client";
|
||||
import { createTempGitRepo } from "../support/helpers/workspace";
|
||||
import { getWorkspaceTabTestIds } from "../support/helpers/workspace-tabs";
|
||||
import {
|
||||
archiveFromRowButton,
|
||||
createGatedTeardown,
|
||||
expectDirectoryPresence,
|
||||
expectHostWorkspaceSettledActive,
|
||||
outlastUndoWindow,
|
||||
pressUndoShortcut,
|
||||
rowArchiveButton,
|
||||
rowKebab,
|
||||
undoButton,
|
||||
workspaceRow,
|
||||
workspaceTitleWidth,
|
||||
} from "../support/helpers/workspace-archive-undo";
|
||||
import {
|
||||
waitForSidebarHydration,
|
||||
waitForWorkspaceInSidebar,
|
||||
} from "../support/helpers/workspace-ui";
|
||||
|
||||
test.describe("Workspace archive undo", () => {
|
||||
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
||||
const worktreeDirectories = new Set<string>();
|
||||
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test.beforeEach(async () => {
|
||||
client = await connectNewWorkspaceDaemonClient();
|
||||
tempRepo = await createTempGitRepo("archive-undo-");
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
for (const directory of worktreeDirectories) {
|
||||
await archiveWorkspaceFromDaemon(client, directory).catch(() => undefined);
|
||||
}
|
||||
worktreeDirectories.clear();
|
||||
await client?.close().catch(() => undefined);
|
||||
await tempRepo?.cleanup().catch(() => undefined);
|
||||
});
|
||||
|
||||
async function seedWorktree(page: Page, slug: string) {
|
||||
await openProjectViaDaemon(client, tempRepo.path);
|
||||
const worktree = await createWorktreeViaDaemon(client, {
|
||||
cwd: tempRepo.path,
|
||||
slug: `${slug}-${Date.now()}`,
|
||||
});
|
||||
worktreeDirectories.add(worktree.workspaceDirectory);
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await waitForWorkspaceInSidebar(page, {
|
||||
serverId: getServerId(),
|
||||
workspaceId: worktree.workspaceId,
|
||||
});
|
||||
return worktree;
|
||||
}
|
||||
|
||||
test("the hover archive button overlays the title instead of narrowing it", async ({ page }) => {
|
||||
const worktree = await seedWorktree(page, "archive-button-overlay");
|
||||
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(rowArchiveButton(page, worktree.workspaceId)).toHaveCount(0);
|
||||
const restingWidth = await workspaceTitleWidth(page, worktree.workspaceId);
|
||||
|
||||
await workspaceRow(page, worktree.workspaceId).hover();
|
||||
await expect(rowArchiveButton(page, worktree.workspaceId)).toBeVisible();
|
||||
await expect(page.getByTestId("sidebar-workspace-trailing-scrim")).toBeVisible();
|
||||
expect(await workspaceTitleWidth(page, worktree.workspaceId)).toBe(restingWidth);
|
||||
|
||||
const [buttonBox, kebabBox] = await Promise.all([
|
||||
rowArchiveButton(page, worktree.workspaceId).boundingBox(),
|
||||
rowKebab(page, worktree.workspaceId).boundingBox(),
|
||||
]);
|
||||
expect(buttonBox && kebabBox && buttonBox.x + buttonBox.width <= kebabBox.x).toBe(true);
|
||||
});
|
||||
|
||||
test("Cmd/Ctrl+Z right after archiving keeps the worktree", async ({ page }) => {
|
||||
const worktree = await seedWorktree(page, "archive-undo-shortcut");
|
||||
|
||||
await archiveFromRowButton(page, worktree.workspaceId);
|
||||
await pressUndoShortcut(page);
|
||||
|
||||
await expect(workspaceRow(page, worktree.workspaceId)).toBeVisible();
|
||||
await expect(undoButton(page)).toHaveCount(0);
|
||||
await expectHostWorkspaceSettledActive(client, worktree.workspaceId);
|
||||
// Once the host settled, any remove the canceled archive sent has already arrived.
|
||||
await expect(workspaceRow(page, worktree.workspaceId)).toBeVisible();
|
||||
await expectDirectoryPresence(worktree.workspaceDirectory, true);
|
||||
});
|
||||
|
||||
test("Undo after the worktree was removed recreates it", async ({ page }) => {
|
||||
const worktree = await seedWorktree(page, "archive-undo-recreate");
|
||||
|
||||
await archiveFromRowButton(page, worktree.workspaceId);
|
||||
// Hovering the toast holds it, and with it the undo, open while the archive finishes.
|
||||
await undoButton(page).hover();
|
||||
await expectDirectoryPresence(worktree.workspaceDirectory, false);
|
||||
await undoButton(page).click();
|
||||
|
||||
await expect(workspaceRow(page, worktree.workspaceId)).toBeVisible({ timeout: 30_000 });
|
||||
await expectDirectoryPresence(worktree.workspaceDirectory, true);
|
||||
await expectHostWorkspaceSettledActive(client, worktree.workspaceId);
|
||||
});
|
||||
|
||||
test("hovering Undo keeps the toast open past its deadline, and its close ends the undo", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.clock.install({ time: Date.now() });
|
||||
const worktree = await seedWorktree(page, "archive-undo-hold");
|
||||
|
||||
await archiveFromRowButton(page, worktree.workspaceId);
|
||||
await page.getByTestId("workspace-archived-toast").hover({ position: { x: 8, y: 8 } });
|
||||
await undoButton(page).hover();
|
||||
await outlastUndoWindow(page);
|
||||
await expect(undoButton(page)).toBeVisible();
|
||||
|
||||
await page.mouse.move(0, 0);
|
||||
await outlastUndoWindow(page);
|
||||
await expect(undoButton(page)).toHaveCount(0);
|
||||
|
||||
// The toast took the undo with it.
|
||||
await pressUndoShortcut(page);
|
||||
await expectWorkspaceAbsentFromSidebar(page, worktree.workspaceId);
|
||||
});
|
||||
|
||||
test("opening the workspace while its undo is still running keeps its tabs", async ({ page }) => {
|
||||
const teardown = createGatedTeardown();
|
||||
const repo = await createTempGitRepo("archive-undo-tabs-", {
|
||||
paseoConfig: teardown.paseoConfig,
|
||||
});
|
||||
const seedClient = await connectSeedClient();
|
||||
try {
|
||||
await openProjectViaDaemon(client, repo.path);
|
||||
const worktree = await createWorktreeViaDaemon(client, {
|
||||
cwd: repo.path,
|
||||
slug: `archive-undo-tabs-${Date.now()}`,
|
||||
});
|
||||
worktreeDirectories.add(worktree.workspaceDirectory);
|
||||
const target = { cwd: worktree.workspaceDirectory, workspaceId: worktree.workspaceId };
|
||||
const agents = [
|
||||
await createMockIdleAgent(seedClient, { ...target, title: "first" }),
|
||||
await createMockIdleAgent(seedClient, { ...target, title: "second" }),
|
||||
] as const;
|
||||
await openWorkspaceWithAgents(page, [agents[0], agents[1]]);
|
||||
const tabsBefore = await getWorkspaceTabTestIds(page);
|
||||
|
||||
await archiveFromRowButton(page, worktree.workspaceId);
|
||||
// The archive has taken its agents down and now parks in teardown.
|
||||
await teardown.expectStarted();
|
||||
await undoButton(page).click();
|
||||
await workspaceRow(page, worktree.workspaceId).click();
|
||||
|
||||
for (const agent of agents) {
|
||||
await expect(page.getByTestId(`workspace-tab-agent_${agent.id}`).first()).toBeVisible();
|
||||
}
|
||||
teardown.release();
|
||||
await expectHostWorkspaceSettledActive(client, worktree.workspaceId);
|
||||
await expect.poll(() => getWorkspaceTabTestIds(page)).toEqual(tabsBefore);
|
||||
} finally {
|
||||
teardown.release();
|
||||
await seedClient.close().catch(() => undefined);
|
||||
await repo.cleanup().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test("Cmd/Ctrl+Z without an archive to undo leaves the sidebar alone", async ({ page }) => {
|
||||
const worktree = await seedWorktree(page, "archive-undo-none");
|
||||
|
||||
await pressUndoShortcut(page);
|
||||
|
||||
await expect(undoButton(page)).toHaveCount(0);
|
||||
await expect(workspaceRow(page, worktree.workspaceId)).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,131 +0,0 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
import { test } from "../support/fixtures";
|
||||
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
|
||||
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||
|
||||
function visible(page: Page, testId: string): Locator {
|
||||
return page.getByTestId(testId).filter({ visible: true });
|
||||
}
|
||||
|
||||
function panes(page: Page): Locator {
|
||||
return page.locator('[data-testid^="workspace-pane-"]').filter({ visible: true });
|
||||
}
|
||||
|
||||
function splitDividers(page: Page): Locator {
|
||||
return visible(page, "workspace-split-resize-handle");
|
||||
}
|
||||
|
||||
async function openSplitWorkspace(
|
||||
page: Page,
|
||||
repoPrefix: string,
|
||||
split: "Split pane right" | "Split pane down",
|
||||
): Promise<SeededWorkspace> {
|
||||
await page.setViewportSize({ width: 1600, height: 900 });
|
||||
const workspace = await seedWorkspace({ repoPrefix });
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await clickNewChat(page);
|
||||
await expect(visible(page, "workspace-header-title")).toHaveCount(1);
|
||||
await runWorkspaceActionFromCommandCenter(page, split);
|
||||
await expect(splitDividers(page)).toHaveCount(1);
|
||||
await expect(panes(page)).toHaveCount(2);
|
||||
return workspace;
|
||||
}
|
||||
|
||||
async function expectOneSharedWorkspaceHeader(page: Page): Promise<void> {
|
||||
await expect(visible(page, "workspace-header-title")).toHaveCount(1);
|
||||
await expect(visible(page, "workspace-header-menu-trigger")).toHaveCount(1);
|
||||
await expect(panes(page).getByTestId("workspace-header-title")).toHaveCount(0);
|
||||
await expect(panes(page).getByTestId("workspace-header-menu-trigger")).toHaveCount(0);
|
||||
}
|
||||
|
||||
/** The tab counter belongs to the single-pane layout: split panes always show their rails. */
|
||||
async function expectPanesKeepTabRails(page: Page): Promise<void> {
|
||||
for (const pane of await panes(page).all()) {
|
||||
await expect(pane.getByTestId("workspace-tabs-row")).toBeVisible();
|
||||
}
|
||||
await expect(visible(page, "workspace-tab-switcher-trigger")).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function dragDivider(divider: Locator, deltaX: number): Promise<void> {
|
||||
const page = divider.page();
|
||||
const box = await divider.boundingBox();
|
||||
if (!box) throw new Error("Divider has no bounding box");
|
||||
const x = box.x + box.width / 2;
|
||||
const y = box.y + box.height / 2;
|
||||
await page.mouse.move(x, y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(x + deltaX, y, { steps: 8 });
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
test.describe("Workspace header with split panes", () => {
|
||||
test("wide side-by-side panes share one header and keep their tab rails", async ({ page }) => {
|
||||
const workspace = await openSplitWorkspace(page, "split-header-wide-", "Split pane right");
|
||||
try {
|
||||
await expectOneSharedWorkspaceHeader(page);
|
||||
await expectPanesKeepTabRails(page);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("stacked panes share one header", async ({ page }) => {
|
||||
const workspace = await openSplitWorkspace(page, "split-header-stacked-", "Split pane down");
|
||||
try {
|
||||
await expectOneSharedWorkspaceHeader(page);
|
||||
await expectPanesKeepTabRails(page);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("a narrow workspace shows one compact header and every pane keeps its tab rail", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await openSplitWorkspace(page, "split-header-narrow-", "Split pane right");
|
||||
try {
|
||||
await page.setViewportSize({ width: 760, height: 900 });
|
||||
await expectPanesKeepTabRails(page);
|
||||
await expectOneSharedWorkspaceHeader(page);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("a narrow pane beside a wide one keeps its tab rail", async ({ page }) => {
|
||||
const workspace = await openSplitWorkspace(page, "split-header-mixed-", "Split pane right");
|
||||
try {
|
||||
await dragDivider(splitDividers(page), -300);
|
||||
const [left, right] = await panes(page).all();
|
||||
const leftBox = await left.boundingBox();
|
||||
const rightBox = await right.boundingBox();
|
||||
expect(leftBox!.width).toBeLessThan(500);
|
||||
expect(rightBox!.width).toBeGreaterThan(500);
|
||||
await expectPanesKeepTabRails(page);
|
||||
await expectOneSharedWorkspaceHeader(page);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("closing back to one pane returns the merged compact header", async ({ page }) => {
|
||||
const workspace = await openSplitWorkspace(page, "split-header-close-", "Split pane right");
|
||||
try {
|
||||
const [, emptyPane] = await panes(page).all();
|
||||
const emptyPaneRail = emptyPane.getByTestId("workspace-tabs-row");
|
||||
await emptyPaneRail.locator('[data-testid^="workspace-tab-"]').first().hover();
|
||||
await emptyPaneRail.locator('[data-testid*="-close-"]').first().click();
|
||||
await expect(splitDividers(page)).toHaveCount(0);
|
||||
await expect(visible(page, "workspace-header-title")).toHaveCount(1);
|
||||
await page.setViewportSize({ width: 760, height: 900 });
|
||||
await expect(visible(page, "workspace-tabs-row")).toHaveCount(0);
|
||||
await expect(visible(page, "workspace-tab-switcher-trigger")).toHaveCount(1);
|
||||
await expect(visible(page, "workspace-header-title")).toHaveCount(1);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -509,8 +509,6 @@ check_autocomplete() {
|
||||
fi
|
||||
fi
|
||||
ad wait 'id="composer-autocomplete-popover"' 10000
|
||||
# Rows replace the popover's loading row once the host lists them; a cold Claude list is slow.
|
||||
ad wait "text=\"${expected}\"" 30000
|
||||
# The popover remeasures once keyboard motion settles.
|
||||
sleep 1
|
||||
capture_screen "${prefix}.png"
|
||||
|
||||
@@ -69,23 +69,17 @@ async function waitFor(name, predicate, timeoutMs = 10_000) {
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard motion has settled once two snapshots agree on the composer position. The
|
||||
// popover shows a loading row until the host lists its commands; a cold Claude list is slow.
|
||||
// Keyboard motion has settled once two snapshots agree on the composer position.
|
||||
async function settledPlacement(name, keyboard) {
|
||||
let previousTop;
|
||||
return waitFor(
|
||||
name,
|
||||
(snap) => {
|
||||
const composer = nodeById(snap, "message-input-root");
|
||||
if (!nodeById(snap, "composer-autocomplete-popover") || !composer) return false;
|
||||
if (nodeById(snap, "composer-autocomplete-loading")) return false;
|
||||
if (keyboardShown(snap) !== keyboard) return false;
|
||||
const stable = composer.rect.y === previousTop;
|
||||
previousTop = composer.rect.y;
|
||||
return stable;
|
||||
},
|
||||
30_000,
|
||||
);
|
||||
return waitFor(name, (snap) => {
|
||||
const composer = nodeById(snap, "message-input-root");
|
||||
if (!nodeById(snap, "composer-autocomplete-popover") || !composer) return false;
|
||||
if (keyboardShown(snap) !== keyboard) return false;
|
||||
const stable = composer.rect.y === previousTop;
|
||||
previousTop = composer.rect.y;
|
||||
return stable;
|
||||
});
|
||||
}
|
||||
|
||||
function assertPlacement(name, snap, expected) {
|
||||
|
||||
@@ -219,11 +219,6 @@ export async function expectWorkspaceArchiveOutcome(
|
||||
await expectWorkspaceTabVisible(page, input.survivingAgentId);
|
||||
}
|
||||
|
||||
export async function closeActiveAgentWithShortcut(page: Page, agentId: string): Promise<void> {
|
||||
await page.keyboard.press("Alt+Shift+w");
|
||||
await expectWorkspaceTabHidden(page, agentId);
|
||||
}
|
||||
|
||||
export async function closeWorkspaceAgentTab(page: Page, agentId: string): Promise<void> {
|
||||
const closeButton = page.getByTestId(`workspace-agent-close-${agentId}`).filter({
|
||||
visible: true,
|
||||
@@ -242,10 +237,6 @@ export async function expectArchivedAgentFocused(page: Page, agentId: string): P
|
||||
});
|
||||
}
|
||||
|
||||
export async function unarchiveAgentFromConversation(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Unarchive", exact: true }).click();
|
||||
}
|
||||
|
||||
export async function reloadWorkspace(page: Page, workspaceId: string): Promise<void> {
|
||||
const serverId = getServerId();
|
||||
await page.goto(buildHostWorkspaceRoute(serverId, workspaceId));
|
||||
|
||||
@@ -99,7 +99,6 @@ export async function startCommandCenterLayoutObservation(page: Page): Promise<v
|
||||
const observedElements = new WeakSet<Element>();
|
||||
const resizeObserver = new ResizeObserver(record);
|
||||
const mutationObserver = new MutationObserver(discover);
|
||||
let performanceObserver: PerformanceObserver | undefined;
|
||||
|
||||
function record(): void {
|
||||
if (!observation.active) return;
|
||||
@@ -133,7 +132,6 @@ export async function startCommandCenterLayoutObservation(page: Page): Promise<v
|
||||
if (!observation.active) {
|
||||
mutationObserver.disconnect();
|
||||
resizeObserver.disconnect();
|
||||
performanceObserver?.disconnect();
|
||||
return;
|
||||
}
|
||||
record();
|
||||
@@ -143,16 +141,9 @@ export async function startCommandCenterLayoutObservation(page: Page): Promise<v
|
||||
|
||||
if (typeof PerformanceObserver !== "undefined") {
|
||||
try {
|
||||
performanceObserver = new PerformanceObserver((list) => {
|
||||
const panel = document.querySelector(selectors.panel);
|
||||
const performanceObserver = new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) {
|
||||
const shift = entry as PerformanceEntry & {
|
||||
value: number;
|
||||
sources: Array<{ node: Node | null }>;
|
||||
};
|
||||
if (shift.sources.some((source) => source.node && panel?.contains(source.node))) {
|
||||
observation.layoutShift += shift.value;
|
||||
}
|
||||
observation.layoutShift += (entry as PerformanceEntry & { value?: number }).value ?? 0;
|
||||
}
|
||||
});
|
||||
performanceObserver.observe({ type: "layout-shift", buffered: false });
|
||||
@@ -183,54 +174,7 @@ export async function expectStableCommandCenterLayout(page: Page): Promise<void>
|
||||
).toBeLessThanOrEqual(1);
|
||||
}
|
||||
}
|
||||
expect(
|
||||
observation.layoutShift,
|
||||
`command center layoutShift: ${JSON.stringify(observation)}`,
|
||||
).toBeLessThanOrEqual(0.001);
|
||||
}
|
||||
|
||||
/** Exercise an unrelated page shift while the real search panel is being observed. */
|
||||
export async function shiftOutsideCommandCenter(page: Page): Promise<void> {
|
||||
await shiftLayoutProbe(page.locator("body"));
|
||||
}
|
||||
|
||||
export async function expectLayoutObservationToCatchPanelShift(page: Page): Promise<void> {
|
||||
await startCommandCenterLayoutObservation(page);
|
||||
await shiftLayoutProbe(page.getByTestId("command-center-panel"));
|
||||
await expect(expectStableCommandCenterLayout(page)).rejects.toThrow("command center layoutShift");
|
||||
}
|
||||
|
||||
async function shiftLayoutProbe(parent: Locator): Promise<void> {
|
||||
const recordedShift = await parent.evaluate(async (container) => {
|
||||
const sibling = document.createElement("div");
|
||||
sibling.textContent = "Layout probe";
|
||||
sibling.style.cssText =
|
||||
"position:fixed;left:0;top:0;width:300px;height:100px;background:white;z-index:99999";
|
||||
container.append(sibling);
|
||||
await new Promise<void>((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
|
||||
);
|
||||
const value = await new Promise<number>((resolve) => {
|
||||
const observer = new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) {
|
||||
const shift = entry as PerformanceEntry & {
|
||||
value: number;
|
||||
sources: Array<{ node: Node | null }>;
|
||||
};
|
||||
if (shift.sources.some((source) => source.node === sibling)) {
|
||||
observer.disconnect();
|
||||
resolve(shift.value);
|
||||
}
|
||||
}
|
||||
});
|
||||
observer.observe({ type: "layout-shift", buffered: false });
|
||||
sibling.style.left = "400px";
|
||||
});
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
||||
sibling.remove();
|
||||
return value;
|
||||
});
|
||||
expect(recordedShift, "the browser recorded a substantial probe shift").toBeGreaterThan(0.001);
|
||||
expect(observation.layoutShift, JSON.stringify(observation)).toBeLessThanOrEqual(0.001);
|
||||
}
|
||||
|
||||
export async function expectOneLineTruncatedFileResult(
|
||||
|
||||
@@ -53,192 +53,3 @@ export async function expectNoCollapsedComposerToolbarFrame(page: Page): Promise
|
||||
const collapsed = frames.filter((frame) => frame.labels.some((label) => label === ""));
|
||||
expect(collapsed, `collapsed toolbar frames: ${JSON.stringify(collapsed)}`).toEqual([]);
|
||||
}
|
||||
|
||||
/** What the folded model control's labels show, read from the DOM. */
|
||||
export interface ModelTriggerLabelState {
|
||||
thinkingShown: boolean;
|
||||
modelShown: boolean;
|
||||
modelText: string;
|
||||
modelTruncated: boolean;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__modelTriggerFrames?: ModelTriggerLabelState[];
|
||||
}
|
||||
}
|
||||
|
||||
function readModelTriggerLabelStateInPage(): ModelTriggerLabelState {
|
||||
const visible = (node: Element) => node.getClientRects().length > 0;
|
||||
const model = Array.from(
|
||||
document.querySelectorAll('[data-testid="combined-model-selector-model-label"]'),
|
||||
).find(visible);
|
||||
const thinking = Array.from(
|
||||
document.querySelectorAll('[data-testid="combined-model-selector-thinking-label"]'),
|
||||
).find(visible);
|
||||
const shown = (node: Element | null): boolean => {
|
||||
for (let current = node; current; current = current.parentElement) {
|
||||
if (Number(getComputedStyle(current).opacity) === 0) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
const modelShown = model !== undefined && shown(model);
|
||||
const modelText = model?.textContent ?? "";
|
||||
return {
|
||||
modelShown,
|
||||
modelText,
|
||||
thinkingShown: modelShown && thinking !== undefined,
|
||||
modelTruncated:
|
||||
model !== undefined && (modelText.endsWith("…") || model.scrollWidth > model.clientWidth),
|
||||
};
|
||||
}
|
||||
|
||||
/** Records the folded model control's labels on every painted frame from page load. */
|
||||
export async function recordModelTriggerFramesFromLoad(page: Page): Promise<void> {
|
||||
await page.addInitScript(`(() => {
|
||||
const read = ${readModelTriggerLabelStateInPage.toString()};
|
||||
const frames = [];
|
||||
window.__modelTriggerFrames = frames;
|
||||
const tick = () => {
|
||||
frames.push(read());
|
||||
requestAnimationFrame(tick);
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
})()`);
|
||||
}
|
||||
|
||||
/** The model control's labels once two reads 100ms apart agree. */
|
||||
export async function readSettledModelTriggerLabels(page: Page): Promise<ModelTriggerLabelState> {
|
||||
let previous = await page.evaluate(readModelTriggerLabelStateInPage);
|
||||
for (;;) {
|
||||
await page.waitForTimeout(100);
|
||||
const next = await page.evaluate(readModelTriggerLabelStateInPage);
|
||||
if (JSON.stringify(next) === JSON.stringify(previous)) return next;
|
||||
previous = next;
|
||||
}
|
||||
}
|
||||
|
||||
/** The labels at the widths where the folded model control changes what it shows. */
|
||||
export interface ModelTriggerLabelSweep {
|
||||
/** The labels at the widest width. */
|
||||
widest: ModelTriggerLabelState & { width: number };
|
||||
/** The first width at which the thinking label is hidden. */
|
||||
thinkingHidden: ModelTriggerLabelState & { width: number };
|
||||
/** The first width at which the model label is truncated. */
|
||||
modelTruncated: ModelTriggerLabelState & { width: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrows the viewport step by step and records where the thinking label disappears and where the
|
||||
* model label first truncates.
|
||||
*/
|
||||
export async function sweepModelTriggerLabels(
|
||||
page: Page,
|
||||
widths: { from: number; to: number; step: number; height: number },
|
||||
): Promise<ModelTriggerLabelSweep> {
|
||||
const steps: (ModelTriggerLabelState & { width: number })[] = [];
|
||||
for (let width = widths.from; width >= widths.to; width -= widths.step) {
|
||||
await page.setViewportSize({ width, height: widths.height });
|
||||
const labels = await readSettledModelTriggerLabels(page);
|
||||
steps.push({ ...labels, width });
|
||||
if (labels.modelTruncated || !labels.modelShown) break;
|
||||
}
|
||||
const thinkingHidden = steps.find((step) => !step.thinkingShown);
|
||||
const modelTruncated = steps.find((step) => step.modelTruncated);
|
||||
if (!thinkingHidden || !modelTruncated) {
|
||||
throw new Error(`the sweep never hid the thinking label and truncated the model label`);
|
||||
}
|
||||
return { widest: steps[0], thinkingHidden, modelTruncated };
|
||||
}
|
||||
|
||||
/** No frame recorded so far painted a truncated model label beside the thinking label. */
|
||||
export async function expectNoFrameTruncatesModelBesideThinking(page: Page): Promise<void> {
|
||||
const frames = await page.evaluate(() => window.__modelTriggerFrames ?? []);
|
||||
expect(frames.some((frame) => frame.modelShown)).toBe(true);
|
||||
const wrong = frames
|
||||
.map((frame, index) => ({ ...frame, index }))
|
||||
.filter((frame) => frame.thinkingShown && frame.modelTruncated);
|
||||
expect(wrong, `of ${frames.length} frames`).toEqual([]);
|
||||
}
|
||||
|
||||
/** Where the composer toolbar's ends sit against the composer, in CSS pixels. */
|
||||
export interface ComposerToolbarRails {
|
||||
/** Where the text column starts. */
|
||||
textLeft: number;
|
||||
/** Where the toolbar's leftmost ink starts. */
|
||||
inkLeft: number;
|
||||
/** How far the trailing disc sits from the composer's right edge. */
|
||||
discRightInset: number;
|
||||
/** How far the trailing disc sits from the composer's bottom edge. */
|
||||
discBottomInset: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads where the toolbar's ink starts against the text column and where the trailing disc sits
|
||||
* in the composer's corner. A control's ink is its filled disc when it has one, otherwise its
|
||||
* glyph's stroked geometry.
|
||||
*/
|
||||
export async function readComposerToolbarRails(page: Page): Promise<ComposerToolbarRails> {
|
||||
const root = page.locator('[data-testid="message-input-root"]:visible').first();
|
||||
return root.evaluate((element) => {
|
||||
const textarea = element.querySelector("textarea");
|
||||
if (!textarea) throw new Error("composer has no text input");
|
||||
const composerRect = element.getBoundingClientRect();
|
||||
const textLeft =
|
||||
textarea.getBoundingClientRect().left +
|
||||
Number.parseFloat(getComputedStyle(textarea).paddingLeft);
|
||||
|
||||
const isFilled = (node: Element) => {
|
||||
const color = getComputedStyle(node).backgroundColor;
|
||||
return color !== "" && color !== "transparent" && !/rgba\(.*,\s*0\)$/.test(color);
|
||||
};
|
||||
const glyphInkLeft = (svg: SVGSVGElement) => {
|
||||
let left = Number.POSITIVE_INFINITY;
|
||||
for (const shape of Array.from(
|
||||
svg.querySelectorAll<SVGGraphicsElement>("path, circle, rect, line, polyline"),
|
||||
)) {
|
||||
const matrix = shape.getScreenCTM();
|
||||
if (!matrix) continue;
|
||||
const box = shape.getBBox();
|
||||
const stroke = Number.parseFloat(getComputedStyle(shape).strokeWidth || "0");
|
||||
left = Math.min(left, matrix.a * (box.x - stroke / 2) + matrix.e);
|
||||
}
|
||||
return left;
|
||||
};
|
||||
|
||||
const band = composerRect.bottom - 64;
|
||||
let inkLeft = Number.POSITIVE_INFINITY;
|
||||
let trailingDisc: DOMRect | null = null;
|
||||
for (const control of Array.from(element.querySelectorAll('[role="button"]'))) {
|
||||
const rect = control.getBoundingClientRect();
|
||||
if (rect.width === 0 || rect.top < band) continue;
|
||||
const disc = [control, ...Array.from(control.querySelectorAll("div"))].find(isFilled);
|
||||
const svg = control.querySelector("svg");
|
||||
if (disc) {
|
||||
const discRect = disc.getBoundingClientRect();
|
||||
inkLeft = Math.min(inkLeft, discRect.left);
|
||||
if (!trailingDisc || discRect.right > trailingDisc.right) trailingDisc = discRect;
|
||||
} else if (svg) {
|
||||
inkLeft = Math.min(inkLeft, glyphInkLeft(svg));
|
||||
}
|
||||
}
|
||||
if (!trailingDisc) throw new Error("composer toolbar has no trailing disc");
|
||||
return {
|
||||
textLeft,
|
||||
inkLeft,
|
||||
discRightInset: composerRect.right - trailingDisc.right,
|
||||
discBottomInset: composerRect.bottom - trailingDisc.bottom,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The toolbar's left ink sits on the text column's edge, and its trailing disc nests into the
|
||||
* corner, as far from the right edge as from the bottom edge. Within half a pixel.
|
||||
*/
|
||||
export async function expectComposerToolbarOnTextRails(page: Page): Promise<void> {
|
||||
const rails = await readComposerToolbarRails(page);
|
||||
// A precision of 0 digits passes within half a pixel.
|
||||
expect(rails.inkLeft).toBeCloseTo(rails.textLeft, 0);
|
||||
expect(rails.discRightInset).toBeCloseTo(rails.discBottomInset, 0);
|
||||
}
|
||||
|
||||
@@ -31,9 +31,7 @@ export async function expectComposerVisible(
|
||||
page: Page,
|
||||
options?: { timeout?: number },
|
||||
): Promise<void> {
|
||||
await expect(composerInput(page)).toBeVisible({
|
||||
timeout: options?.timeout ?? 15_000,
|
||||
});
|
||||
await expect(composerInput(page)).toBeVisible({ timeout: options?.timeout ?? 15_000 });
|
||||
}
|
||||
|
||||
export async function expectComposerDisabled(page: Page): Promise<void> {
|
||||
@@ -158,9 +156,7 @@ export async function attachImageFromMenu(
|
||||
}
|
||||
|
||||
export async function expectAttachmentPill(page: Page, testID: string): Promise<void> {
|
||||
await expect(page.getByTestId(testID).first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(page.getByTestId(testID).first()).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
export async function attachFileFromMenu(
|
||||
@@ -222,24 +218,18 @@ export async function expectGithubAttachmentPill(
|
||||
|
||||
export async function openImageLightbox(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Open image attachment" }).first().click();
|
||||
await expect(page.getByTestId("attachment-lightbox-close")).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await expect(page.getByTestId("attachment-lightbox-close")).toBeVisible({ timeout: 5_000 });
|
||||
}
|
||||
|
||||
export async function closeImageLightbox(page: Page): Promise<void> {
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByTestId("attachment-lightbox-close")).not.toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await expect(page.getByTestId("attachment-lightbox-close")).not.toBeVisible({ timeout: 5_000 });
|
||||
}
|
||||
|
||||
export async function openGithubPickerFromMenu(page: Page): Promise<void> {
|
||||
await openAttachmentMenu(page);
|
||||
await page.getByTestId("message-input-attachment-menu-item-github").click();
|
||||
await expect(page.getByTestId("combobox-desktop-container")).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await expect(page.getByTestId("combobox-desktop-container")).toBeVisible({ timeout: 5_000 });
|
||||
}
|
||||
|
||||
/** Open picker, type a query, wait for the matching option by id (e.g. "issue:3", "change_request:1"), and click it. */
|
||||
@@ -293,10 +283,7 @@ export async function startRunningMockAgent(
|
||||
model: opts.model,
|
||||
featureValues: opts.featureValues,
|
||||
});
|
||||
const agentUrl = `${buildHostWorkspaceRoute(
|
||||
serverId,
|
||||
workspace.id,
|
||||
)}?open=${encodeURIComponent(`agent:${agent.id}`)}`;
|
||||
const agentUrl = `${buildHostWorkspaceRoute(serverId, workspace.id)}?open=${encodeURIComponent(`agent:${agent.id}`)}`;
|
||||
await page.goto(agentUrl);
|
||||
await expectComposerVisible(page);
|
||||
await client.sendAgentMessage(agent.id, opts.prompt);
|
||||
@@ -360,11 +347,7 @@ export async function pasteComposerTestImage(page: Page): Promise<boolean> {
|
||||
const bytes = Uint8Array.from(atob(base64), (character) => character.charCodeAt(0));
|
||||
const clipboardData = new DataTransfer();
|
||||
clipboardData.items.add(new File([bytes], "pasted.png", { type: "image/png" }));
|
||||
const event = new ClipboardEvent("paste", {
|
||||
clipboardData,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
const event = new ClipboardEvent("paste", { clipboardData, bubbles: true, cancelable: true });
|
||||
input.dispatchEvent(event);
|
||||
return event.defaultPrevented;
|
||||
}, COMPOSER_TEST_IMAGE_BASE64);
|
||||
@@ -522,10 +505,7 @@ export async function recordVisibleComposerGeometry(page: Page): Promise<void> {
|
||||
export async function expectNoCollapsedVisibleComposer(page: Page): Promise<void> {
|
||||
const frames = await page.evaluate(() => {
|
||||
Reflect.get(window, "__stopComposerGeometry")?.();
|
||||
return Reflect.get(window, "__composerGeometryFrames") as {
|
||||
input: number;
|
||||
root: number;
|
||||
}[];
|
||||
return Reflect.get(window, "__composerGeometryFrames") as { input: number; root: number }[];
|
||||
});
|
||||
expect(frames.length).toBeGreaterThan(0);
|
||||
expect(frames.filter((frame) => frame.root < 42 || frame.input < 42)).toEqual([]);
|
||||
@@ -536,10 +516,7 @@ export async function roundTripComposerTabBeforePaint(
|
||||
page: Page,
|
||||
activeChatTitle: string,
|
||||
): Promise<void> {
|
||||
const activeTab = page.getByRole("button", {
|
||||
name: activeChatTitle,
|
||||
exact: true,
|
||||
});
|
||||
const activeTab = page.getByRole("button", { name: activeChatTitle, exact: true });
|
||||
await expect(activeTab).toHaveAttribute("aria-selected", "true");
|
||||
const result = await activeTab.evaluate((tab) => {
|
||||
const editor = document.activeElement;
|
||||
@@ -579,10 +556,7 @@ export async function roundTripComposerTabBeforePaint(
|
||||
});
|
||||
}
|
||||
});
|
||||
observer.observe(tab, {
|
||||
attributes: true,
|
||||
attributeFilter: ["aria-selected"],
|
||||
});
|
||||
observer.observe(tab, { attributes: true, attributeFilter: ["aria-selected"] });
|
||||
shortcut("BracketLeft");
|
||||
});
|
||||
});
|
||||
@@ -592,44 +566,3 @@ export async function roundTripComposerTabBeforePaint(
|
||||
).toBe(true);
|
||||
expect(result.focused, "returning on touch must leave the composer unfocused").toBe(false);
|
||||
}
|
||||
|
||||
/** Paste both clipboard representations through the browser's normal paste command. */
|
||||
export async function pasteRichTextIntoComposer({
|
||||
page,
|
||||
html,
|
||||
text,
|
||||
}: {
|
||||
page: Page;
|
||||
html: string;
|
||||
text: string;
|
||||
}): Promise<void> {
|
||||
await page.context().grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
await page.evaluate(
|
||||
async ({ html: richHtml, text: plainText }) => {
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({
|
||||
"text/html": new Blob([richHtml], { type: "text/html" }),
|
||||
"text/plain": new Blob([plainText], { type: "text/plain" }),
|
||||
}),
|
||||
]);
|
||||
},
|
||||
{ html, text },
|
||||
);
|
||||
await composerInput(page).press("ControlOrMeta+v");
|
||||
}
|
||||
|
||||
export async function undoComposerEdit(page: Page): Promise<void> {
|
||||
await composerInput(page).press("ControlOrMeta+z");
|
||||
}
|
||||
export async function redoComposerEdit(page: Page): Promise<void> {
|
||||
await composerInput(page).press("ControlOrMeta+Shift+z");
|
||||
}
|
||||
|
||||
/** Reuse the actual response Copy action and the browser's native paste. */
|
||||
export async function copyLatestTurnIntoComposer(page: Page): Promise<void> {
|
||||
await page.context().grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
await page.getByRole("button", { name: "Copy turn", exact: true }).last().click();
|
||||
await expect(page.getByRole("button", { name: "Copied", exact: true })).toBeVisible();
|
||||
await composerInput(page).fill("");
|
||||
await composerInput(page).press("ControlOrMeta+v");
|
||||
}
|
||||
|
||||
@@ -148,59 +148,3 @@ export async function expectFullscreenDiagram(
|
||||
export async function expectFullscreenDiagramClosed(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId(FULLSCREEN_VIEWPORT)).toHaveCount(0);
|
||||
}
|
||||
|
||||
/** Tall diagrams stay within the screen so their controls remain reachable. */
|
||||
export async function expectInlineDiagramFitsViewport(page: Page): Promise<void> {
|
||||
const viewport = page.getByTestId("mermaid-viewport").last();
|
||||
const box = await viewport.boundingBox();
|
||||
expect(box, "the inline diagram has measurable layout").not.toBeNull();
|
||||
const screenHeight = page.viewportSize()!.height;
|
||||
expect(box!.height, "the inline diagram fits within the screen").toBeLessThan(screenHeight);
|
||||
const contentHeight = await diagramSvg(page, "mermaid-viewport").evaluate(
|
||||
(svg) => (svg as SVGSVGElement).viewBox.baseVal.height,
|
||||
);
|
||||
expect(box!.height, "a tall diagram is bounded rather than expanding the chat").toBeLessThan(
|
||||
contentHeight,
|
||||
);
|
||||
}
|
||||
|
||||
/** Exercise pointer toggles and the controls encountered along that same journey. */
|
||||
export async function toggleDiagramSourceAndVerifyControls(page: Page): Promise<void> {
|
||||
const viewport = page.getByTestId("mermaid-viewport").last();
|
||||
const iframe = await viewport.locator("iframe").elementHandle();
|
||||
for (let iteration = 0; iteration < 3; iteration++) {
|
||||
await viewport.hover();
|
||||
await page.getByRole("button", { name: "View source" }).last().click();
|
||||
const viewDiagram = page.getByRole("button", { name: "View diagram" }).last();
|
||||
await viewDiagram.click({ timeout: 10_000 });
|
||||
await expect(viewDiagram).toHaveCount(0);
|
||||
await expectFullscreenDiagramClosed(page);
|
||||
expect(
|
||||
await viewport.locator("iframe").evaluate((node, original) => node === original, iframe),
|
||||
"switching views preserves the rendered diagram",
|
||||
).toBe(true);
|
||||
}
|
||||
await viewport.hover();
|
||||
await page.getByRole("button", { name: "View source" }).last().click();
|
||||
const hiddenFullscreen = viewport.getByRole("button", {
|
||||
name: "Fullscreen",
|
||||
includeHidden: true,
|
||||
});
|
||||
await hiddenFullscreen.focus();
|
||||
await expect(hiddenFullscreen).not.toBeFocused();
|
||||
await page.getByRole("button", { name: "View diagram" }).last().click();
|
||||
await viewport.hover();
|
||||
const fullscreen = viewport.getByRole("button", { name: "Fullscreen", exact: true });
|
||||
const box = await viewport.boundingBox();
|
||||
const control = await fullscreen.boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
expect(control).not.toBeNull();
|
||||
expect(control!.y, "the toolbar starts inside the preview").toBeGreaterThanOrEqual(box!.y);
|
||||
expect(control!.y + control!.height, "the toolbar fits inside the preview").toBeLessThanOrEqual(
|
||||
box!.y + box!.height,
|
||||
);
|
||||
await viewport.getByRole("button", { name: "Zoom in" }).click();
|
||||
await expect(viewport.getByRole("button", { name: "Reset view" })).toBeEnabled();
|
||||
await viewport.getByRole("button", { name: "Reset view" }).click();
|
||||
await expect(viewport.getByRole("button", { name: "Reset view" })).toBeDisabled();
|
||||
}
|
||||
|
||||
@@ -17,8 +17,6 @@ type NewWorkspaceDaemonClient = Pick<
|
||||
| "connect"
|
||||
| "createPaseoWorktree"
|
||||
| "createWorkspace"
|
||||
| "createTerminal"
|
||||
| "killTerminal"
|
||||
| "fetchAgents"
|
||||
| "fetchWorkspaces"
|
||||
| "getPaseoWorktreeList"
|
||||
@@ -36,7 +34,6 @@ type NewWorkspaceDaemonClient = Pick<
|
||||
| "removePlugin"
|
||||
| "reloadPlugin"
|
||||
| "setWorkspaceTitle"
|
||||
| "setWorkspaceLabel"
|
||||
>;
|
||||
|
||||
type CreateWorkspacePayload = Awaited<ReturnType<NewWorkspaceDaemonClient["createWorkspace"]>>;
|
||||
|
||||
@@ -134,7 +134,7 @@ export default function contribute(client) {
|
||||
}`;
|
||||
}
|
||||
|
||||
export async function withDirectoryPlugin(options: {
|
||||
async function withDirectoryPlugin(options: {
|
||||
pluginId: string;
|
||||
source: string;
|
||||
run: () => Promise<void>;
|
||||
|
||||
@@ -157,11 +157,8 @@ export interface SeedDaemonClient {
|
||||
sendAgentMessage(
|
||||
agentId: string,
|
||||
text: string,
|
||||
options?: { sourceAgentId?: string; activeTurnBehavior?: "interrupt" | "steer" | "queue" },
|
||||
options?: { sourceAgentId?: string },
|
||||
): Promise<void>;
|
||||
listAgentBackgroundWork(
|
||||
agentId: string,
|
||||
): Promise<import("@getpaseo/client").PaseoAgentBackgroundWorkItem[]>;
|
||||
waitForFinish(
|
||||
agentId: string,
|
||||
timeout?: number,
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { copyPluginExample } from "./plugin-fixture";
|
||||
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
|
||||
import { seedWorkspace } from "./seed-client";
|
||||
import { gotoWorkspace } from "./launcher";
|
||||
import { openSidebarNavSettings, leaveSettings } from "./sidebar-nav-settings";
|
||||
import { visibleTestId } from "./plugin-sidebar-items";
|
||||
import { getServerId } from "./server-id";
|
||||
|
||||
export async function withSidebarViews(
|
||||
page: Page,
|
||||
run: (views: ReturnType<typeof sidebarViews>) => Promise<void>,
|
||||
) {
|
||||
const example = await copyPluginExample("sidebar-views");
|
||||
const entry = path.join(example.directory, "index.client.tsx");
|
||||
const original = await readFile(entry, "utf8");
|
||||
await writeFile(
|
||||
entry,
|
||||
original.replace(
|
||||
"const cleanups = [",
|
||||
'client.addSidebarView({ id: "broken", title: "Broken example", Component() { throw new Error("View failed"); } });\n const cleanups = [',
|
||||
),
|
||||
);
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const workspace = await seedWorkspace({
|
||||
repoPrefix: "sidebar-views-",
|
||||
title: "Sidebar view workspace",
|
||||
});
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(example.directory);
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await run(sidebarViews(page, workspace.workspaceId, client));
|
||||
} finally {
|
||||
await client.removePlugin("sidebar-views");
|
||||
await client.close();
|
||||
await workspace.cleanup();
|
||||
await example.cleanup();
|
||||
}
|
||||
}
|
||||
function sidebarViews(
|
||||
page: Page,
|
||||
workspaceId: string,
|
||||
client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>,
|
||||
) {
|
||||
const builtinRow = () =>
|
||||
visibleTestId(page, `sidebar-workspace-row-${getServerId()}:${workspaceId}`);
|
||||
async function choose(title: string) {
|
||||
await openSidebarNavSettings(page);
|
||||
await page.getByRole("button", { name: /^View:/ }).click();
|
||||
await page.getByRole("menuitem", { name: title, exact: true }).click();
|
||||
await expect(page.getByRole("button", { name: `View: ${title}`, exact: true })).toBeVisible();
|
||||
await leaveSettings(page);
|
||||
}
|
||||
async function expectDefault() {
|
||||
await expect(builtinRow()).toBeVisible();
|
||||
}
|
||||
async function expectPlugin() {
|
||||
await expect(
|
||||
visibleTestId(page, "plugin-sidebar-view").getByText("Sidebar view workspace", {
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(builtinRow()).toHaveCount(0);
|
||||
await expect(visibleTestId(page, "sidebar-global-new-workspace")).toBeVisible();
|
||||
await expect(visibleTestId(page, "sidebar-settings")).toBeVisible();
|
||||
}
|
||||
return {
|
||||
expectDefault,
|
||||
async chooseWorkspaces() {
|
||||
await choose("Example workspaces");
|
||||
await expectPlugin();
|
||||
},
|
||||
async exerciseExamples() {
|
||||
await choose("Example agents");
|
||||
await expect(
|
||||
visibleTestId(page, "plugin-sidebar-view").getByText("Respond", { exact: true }),
|
||||
).toBeVisible();
|
||||
await choose("Example nested agents");
|
||||
await expectPlugin();
|
||||
await choose("Example label folders");
|
||||
const slot = visibleTestId(page, "plugin-sidebar-view");
|
||||
await slot
|
||||
.getByRole("button", { name: `Move workspace ${workspaceId}`, exact: true })
|
||||
.click();
|
||||
await slot.getByRole("button", { name: "Move to Inbox", exact: true }).click();
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await client.fetchWorkspaces()).entries.find((item) => item.id === workspaceId)
|
||||
?.labels,
|
||||
)
|
||||
.toContain("Inbox");
|
||||
await slot
|
||||
.getByRole("button", { name: `Move workspace ${workspaceId}`, exact: true })
|
||||
.click();
|
||||
await slot.getByRole("button", { name: "Move to Later", exact: true }).click();
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await client.fetchWorkspaces()).entries.find((item) => item.id === workspaceId)
|
||||
?.labels,
|
||||
)
|
||||
.toEqual(["Later"]);
|
||||
await client.setWorkspaceLabel({
|
||||
workspaceId,
|
||||
label: { name: "Unlabelled", color: "red" },
|
||||
assigned: true,
|
||||
});
|
||||
await client.setWorkspaceLabel({
|
||||
workspaceId,
|
||||
label: { name: "Later", color: "red" },
|
||||
assigned: false,
|
||||
});
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await client.fetchWorkspaces()).entries.find((item) => item.id === workspaceId)
|
||||
?.labels,
|
||||
)
|
||||
.toEqual(["Unlabelled"]);
|
||||
await slot
|
||||
.getByRole("button", { name: `Move workspace ${workspaceId}`, exact: true })
|
||||
.click();
|
||||
await slot.getByRole("button", { name: "Move to Inbox", exact: true }).click();
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await client.fetchWorkspaces()).entries.find((item) => item.id === workspaceId)
|
||||
?.labels,
|
||||
)
|
||||
.toEqual(["Inbox"]);
|
||||
},
|
||||
async exerciseTerminals() {
|
||||
await choose("Example terminals");
|
||||
const slot = visibleTestId(page, "plugin-sidebar-view");
|
||||
await slot
|
||||
.getByRole("button", { name: `Terminals on ${getServerId()}`, exact: true })
|
||||
.click();
|
||||
await expect(slot.getByText("No terminals", { exact: true })).toBeVisible();
|
||||
const workspace = (await client.fetchWorkspaces()).entries.find(
|
||||
(item) => item.id === workspaceId,
|
||||
)!;
|
||||
const created = await client.createTerminal(
|
||||
workspace.workspaceDirectory,
|
||||
"Sidebar terminal",
|
||||
undefined,
|
||||
{ workspaceId },
|
||||
);
|
||||
if (!created.terminal) throw new Error(created.error ?? "Terminal creation failed");
|
||||
try {
|
||||
await expect(
|
||||
slot.getByTestId(`sidebar-terminal-open-${created.terminal.id}`),
|
||||
).toBeVisible();
|
||||
await slot.getByTestId(`sidebar-terminal-open-${created.terminal.id}`).click();
|
||||
await expect(page.getByTestId("terminal-surface").first()).toBeVisible();
|
||||
await slot.getByTestId(`sidebar-terminal-kill-${created.terminal.id}`).click();
|
||||
await expect
|
||||
.poll(async () => (await client.listTerminals()).terminals.map((item) => item.id))
|
||||
.not.toContain(created.terminal!.id);
|
||||
await expect(slot.getByText("No terminals", { exact: true })).toBeVisible();
|
||||
} finally {
|
||||
await client.killTerminal(created.terminal.id);
|
||||
}
|
||||
},
|
||||
async restoreDefault() {
|
||||
await choose("Default view");
|
||||
await expectDefault();
|
||||
},
|
||||
async recoverFromFailure() {
|
||||
await choose("Broken example");
|
||||
await expect(page.getByText("Sidebar view failed to load.", { exact: true })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Use default view", exact: true }).click();
|
||||
await expectDefault();
|
||||
},
|
||||
async disableChosenView() {
|
||||
await client.disablePlugin("sidebar-views");
|
||||
await expectDefault();
|
||||
await openSidebarNavSettings(page);
|
||||
await expect(
|
||||
page.getByText("The selected view is unavailable or disabled. Showing Default view.", {
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
await leaveSettings(page);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -96,35 +96,6 @@ export async function expectWorkspaceHoverCardStaysOpen(page: Page): Promise<voi
|
||||
await expectWorkspaceHoverCardOpen(page);
|
||||
}
|
||||
|
||||
/** Scans the rows in the given order at about a user's pace, then rests on the last one. */
|
||||
export async function sweepPointerAcrossWorkspaceRows(
|
||||
page: Page,
|
||||
workspaceIds: string[],
|
||||
): Promise<void> {
|
||||
const centers: Array<{ x: number; y: number }> = [];
|
||||
for (const workspaceId of workspaceIds) {
|
||||
const row = page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
const bounds = await row.boundingBox();
|
||||
if (!bounds) throw new Error(`Could not measure the row of ${workspaceId}`);
|
||||
centers.push({ x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 });
|
||||
}
|
||||
const [first, ...rest] = centers;
|
||||
await page.mouse.move(first!.x, first!.y);
|
||||
for (const center of rest) {
|
||||
await page.mouse.move(center.x, center.y, { steps: 3 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function expectOnlyWorkspaceHoverCardFor(
|
||||
page: Page,
|
||||
workspaceName: string,
|
||||
): Promise<void> {
|
||||
const cards = page.getByTestId("workspace-hover-card");
|
||||
await expect(cards).toHaveCount(1);
|
||||
await expect(cards.getByTestId("hover-card-workspace-name")).toHaveText(workspaceName);
|
||||
}
|
||||
|
||||
export async function expectWorkspaceHoverCardClosed(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("workspace-hover-card")).toHaveCount(0);
|
||||
}
|
||||
|
||||
@@ -575,10 +575,8 @@ export async function expectFoldedTurnAtReadingPosition(page: Page, prompt: stri
|
||||
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||
const promptRow = timeline.locator("[data-history-row-id]").filter({ hasText: prompt });
|
||||
await expect(promptRow).toBeVisible({ timeout: 15_000 });
|
||||
// The folded turn's summary line is the row under its prompt.
|
||||
const hostId = await promptRow.getAttribute("data-history-row-id");
|
||||
const summaryRow = timeline.locator(`[data-history-row-id="turn-summary:${hostId}"]`);
|
||||
await expect(summaryRow.getByTestId("turn-summary")).toBeVisible();
|
||||
// The host row carries the folded turn's summary line.
|
||||
await expect(promptRow.getByTestId("turn-summary")).toBeVisible();
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
import { existsSync, mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect } from "../fixtures";
|
||||
import type { connectNewWorkspaceDaemonClient } from "./new-workspace";
|
||||
import { getServerId } from "./server-id";
|
||||
import { expectWorkspaceAbsentFromSidebar } from "./sidebar";
|
||||
|
||||
type DaemonClient = Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
|
||||
const UNDO_SHORTCUT = process.platform === "darwin" ? "Meta+Z" : "Control+Z";
|
||||
/** The archived toast's lifetime, from `WORKSPACE_ARCHIVE_UNDO_WINDOW_MS`. */
|
||||
const UNDO_WINDOW_MS = 6000;
|
||||
|
||||
export function workspaceRow(page: Page, workspaceId: string) {
|
||||
return page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`);
|
||||
}
|
||||
|
||||
export function rowArchiveButton(page: Page, workspaceId: string) {
|
||||
return page.getByTestId(`sidebar-workspace-archive-button-${getServerId()}:${workspaceId}`);
|
||||
}
|
||||
|
||||
export function rowKebab(page: Page, workspaceId: string) {
|
||||
return page.getByTestId(`sidebar-workspace-kebab-${getServerId()}:${workspaceId}`);
|
||||
}
|
||||
|
||||
export function undoButton(page: Page) {
|
||||
return page.getByRole("button", { name: /^Undo/ });
|
||||
}
|
||||
|
||||
export async function workspaceTitleWidth(page: Page, workspaceId: string): Promise<number> {
|
||||
const box = await page
|
||||
.getByTestId(`sidebar-workspace-title-${getServerId()}:${workspaceId}`)
|
||||
.boundingBox();
|
||||
if (!box) throw new Error(`Title of ${workspaceId} is not laid out`);
|
||||
return box.width;
|
||||
}
|
||||
|
||||
/** Archives a workspace with its row's hover button and waits for the undo toast. */
|
||||
export async function archiveFromRowButton(page: Page, workspaceId: string): Promise<void> {
|
||||
page.once("dialog", (dialog) => void dialog.accept());
|
||||
await workspaceRow(page, workspaceId).hover();
|
||||
await rowArchiveButton(page, workspaceId).click();
|
||||
await expectWorkspaceAbsentFromSidebar(page, workspaceId);
|
||||
await expect(undoButton(page)).toBeVisible();
|
||||
}
|
||||
|
||||
export async function pressUndoShortcut(page: Page): Promise<void> {
|
||||
await page.keyboard.press(UNDO_SHORTCUT);
|
||||
}
|
||||
|
||||
/** Moves the page clock past the moment an unheld archived toast would have closed. */
|
||||
export async function outlastUndoWindow(page: Page): Promise<void> {
|
||||
await page.clock.fastForward(UNDO_WINDOW_MS + 1000);
|
||||
}
|
||||
|
||||
/** Waits until the host lists the workspace as active with no archive running on it. */
|
||||
export async function expectHostWorkspaceSettledActive(
|
||||
client: DaemonClient,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { entries } = await client.fetchWorkspaces();
|
||||
const entry = entries.find((candidate) => candidate.id === workspaceId);
|
||||
return entry ? { active: true, archiving: Boolean(entry.archivingAt) } : null;
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toEqual({ active: true, archiving: false });
|
||||
}
|
||||
|
||||
export async function expectDirectoryPresence(directory: string, present: boolean): Promise<void> {
|
||||
await expect.poll(() => existsSync(directory), { timeout: 30_000 }).toBe(present);
|
||||
}
|
||||
|
||||
export interface GatedTeardown {
|
||||
/** The `paseo.json` config whose worktree teardown parks until released. */
|
||||
paseoConfig: Record<string, unknown>;
|
||||
expectStarted(): Promise<void>;
|
||||
release(): void;
|
||||
}
|
||||
|
||||
/** A worktree teardown that parks an archive mid-flight, so an undo lands while it runs. */
|
||||
export function createGatedTeardown(): GatedTeardown {
|
||||
const gateDir = mkdtempSync(path.join(tmpdir(), "archive-undo-gate-"));
|
||||
const startedPath = path.join(gateDir, "started");
|
||||
const releasePath = path.join(gateDir, "release");
|
||||
const teardown = `node -e "const fs=require('fs');fs.writeFileSync('${startedPath}','started');const timer=setInterval(()=>{if(fs.existsSync('${releasePath}'))clearInterval(timer)},20)"`;
|
||||
return {
|
||||
paseoConfig: { worktree: { teardown: [teardown] } },
|
||||
expectStarted: () => expectDirectoryPresence(startedPath, true),
|
||||
release: () => writeFileSync(releasePath, "release\n"),
|
||||
};
|
||||
}
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
package sh.paseo.scroll
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.os.SystemClock
|
||||
import android.view.View
|
||||
import android.widget.FrameLayout
|
||||
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.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/** Forward-order parent, as used by the current Android timeline, and a nested panel at its edge. */
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class PanelEdgeTest {
|
||||
private val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
private var activity: Activity? = null
|
||||
private lateinit var manager: ReactScrollViewManager
|
||||
|
||||
@After fun finish() {
|
||||
instrumentation.runOnMainSync { activity?.finish() }
|
||||
}
|
||||
|
||||
@Test fun panelBottomEdgeLeavesTimelineStill() {
|
||||
val (parent, child) = openPanel()
|
||||
instrumentation.runOnMainSync {
|
||||
child.scrollTo(0, 600)
|
||||
assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL))
|
||||
child.dispatchNestedScroll(0, 0, 0, 100, null)
|
||||
child.stopNestedScroll()
|
||||
assertEquals("Panel stays at its bottom", 600, child.scrollY)
|
||||
assertEquals("Panel overshoot must leave timeline still", 400, parent.scrollY)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun panelTopEdgeLeavesTimelineStill() {
|
||||
val (parent, child) = openPanel()
|
||||
instrumentation.runOnMainSync {
|
||||
child.scrollTo(0, 0)
|
||||
assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL))
|
||||
child.dispatchNestedScroll(0, 0, 0, -100, null)
|
||||
child.stopNestedScroll()
|
||||
assertEquals("Panel stays at its top", 0, child.scrollY)
|
||||
assertEquals("Panel overshoot must leave timeline still", 400, parent.scrollY)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun panelFlingLeavesTimelineStill() {
|
||||
val (parent, child) = openPanel()
|
||||
instrumentation.runOnMainSync {
|
||||
child.scrollTo(0, 600)
|
||||
assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL))
|
||||
child.dispatchNestedFling(0f, 1500f, false)
|
||||
child.stopNestedScroll()
|
||||
}
|
||||
SystemClock.sleep(500)
|
||||
instrumentation.runOnMainSync {
|
||||
assertEquals("Panel fling must leave timeline still", 400, parent.scrollY)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun disablingChildNestedScrollPreventsHandoff() {
|
||||
val (parent, child) = openPanel()
|
||||
instrumentation.runOnMainSync {
|
||||
child.isNestedScrollingEnabled = false
|
||||
assertEquals(false, child.startNestedScroll(View.SCROLL_AXIS_VERTICAL))
|
||||
child.dispatchNestedScroll(0, 0, 0, 100, null)
|
||||
assertEquals(400, parent.scrollY)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun ordinaryParentStillConsumesNestedOvershoot() {
|
||||
val (parent, child) = openPanel()
|
||||
instrumentation.runOnMainSync {
|
||||
manager.setNativeId(parent, "ordinary-scroll-parent")
|
||||
assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL))
|
||||
child.dispatchNestedScroll(0, 0, 0, 100, null)
|
||||
child.stopNestedScroll()
|
||||
assertEquals("Ordinary parent retains framework handoff", 500, parent.scrollY)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun ordinaryParentStillConsumesUnconsumedFling() {
|
||||
val (parent, child) = openPanel()
|
||||
instrumentation.runOnMainSync {
|
||||
manager.setNativeId(parent, "ordinary-scroll-parent")
|
||||
child.scrollTo(0, 600)
|
||||
assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL))
|
||||
child.dispatchNestedFling(0f, 1500f, false)
|
||||
child.stopNestedScroll()
|
||||
}
|
||||
SystemClock.sleep(500)
|
||||
instrumentation.runOnMainSync { assertTrue(parent.scrollY > 400) }
|
||||
}
|
||||
|
||||
@Test fun consumedPanelFlingDoesNotStartParentFling() {
|
||||
val (parent, child) = openPanel()
|
||||
instrumentation.runOnMainSync {
|
||||
assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL))
|
||||
assertEquals(false, child.dispatchNestedFling(0f, 1500f, true))
|
||||
child.stopNestedScroll()
|
||||
assertEquals(400, parent.scrollY)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun panelInteriorScrollingLeavesTimelineStill() {
|
||||
val (parent, child) = openPanel()
|
||||
instrumentation.runOnMainSync {
|
||||
child.scrollTo(0, 100)
|
||||
assertEquals(100, child.scrollY)
|
||||
assertEquals(400, parent.scrollY)
|
||||
parent.scrollTo(0, 500)
|
||||
assertEquals("Direct timeline scrolling remains available", 500, parent.scrollY)
|
||||
}
|
||||
}
|
||||
|
||||
private fun openPanel(): Pair<ReactScrollView, ReactScrollView> {
|
||||
val started = instrumentation.startActivitySync(
|
||||
Intent(instrumentation.targetContext, Activity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
)
|
||||
activity = started
|
||||
lateinit var parent: ReactScrollView
|
||||
lateinit var child: ReactScrollView
|
||||
instrumentation.runOnMainSync {
|
||||
DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(started)
|
||||
SoLoader.init(started, OpenSourceMergedSoMapping)
|
||||
val react = ScrollTestContext(started.applicationContext)
|
||||
val context = ThemedReactContext(react, started)
|
||||
manager = PaseoScrollPackage().createViewManager(react, ReactScrollViewManager.REACT_CLASS) as ReactScrollViewManager
|
||||
parent = manager.createViewInstance(context)
|
||||
manager.setNativeId(parent, "agent-chat-scroll-native-virtualized")
|
||||
child = manager.createViewInstance(context)
|
||||
child.isNestedScrollingEnabled = true
|
||||
val content = ReactViewGroup(context)
|
||||
content.addView(child, FrameLayout.LayoutParams(300, 200).apply { topMargin = 400 })
|
||||
parent.addView(content)
|
||||
val childContent = ReactViewGroup(context)
|
||||
child.addView(childContent)
|
||||
started.setContentView(FrameLayout(started).apply { addView(parent, FrameLayout.LayoutParams(300, 400)) })
|
||||
parent.layout(0, 0, 300, 400)
|
||||
content.layout(0, 0, 300, 2000)
|
||||
child.layout(0, 400, 300, 600)
|
||||
childContent.layout(0, 0, 300, 800)
|
||||
parent.scrollTo(0, 400)
|
||||
}
|
||||
return Pair(parent, child)
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package sh.paseo.scroll;
|
||||
|
||||
import android.content.Context;
|
||||
import com.facebook.react.bridge.BridgeReactContext;
|
||||
import com.facebook.react.uimanager.events.BlackHoleEventDispatcher;
|
||||
import com.facebook.react.uimanager.events.EventDispatcher;
|
||||
import com.facebook.react.uimanager.events.EventDispatcherProvider;
|
||||
|
||||
/** Native-only tests use RN's own event sink for a context without JavaScript. */
|
||||
final class ScrollTestContext extends BridgeReactContext implements EventDispatcherProvider {
|
||||
ScrollTestContext(Context context) {
|
||||
super(context);
|
||||
}
|
||||
|
||||
@Override
|
||||
public EventDispatcher getEventDispatcher() {
|
||||
return BlackHoleEventDispatcher.INSTANCE;
|
||||
}
|
||||
}
|
||||
+24
-1
@@ -12,10 +12,13 @@ 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 timeline corrections. Java, because the content-position
|
||||
* config type is internal to React Native's Kotlin module.
|
||||
*/
|
||||
final class PaseoScrollView extends ReactScrollView {
|
||||
// The chat timeline, packages/app/src/agent-stream/strategy-native.tsx.
|
||||
private static final String TIMELINE_NATIVE_ID = "agent-chat-scroll-native-virtualized";
|
||||
|
||||
// The chat timeline's end pin, packages/app/src/agent-stream/native-end-pin.tsx.
|
||||
private static final String END_PIN_NATIVE_ID = "paseo-end-pin";
|
||||
|
||||
@@ -32,6 +35,26 @@ final class PaseoScrollView extends ReactScrollView {
|
||||
super(context);
|
||||
}
|
||||
|
||||
// A panel owns its gesture through its edges; remaining child motion must not move the chat
|
||||
// behind it. The timeline's native identity stays present when its end pin is removed for
|
||||
// reading history. Ordinary parents retain Android's nested-scroll handoff.
|
||||
@Override
|
||||
public void onNestedScroll(
|
||||
View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed) {
|
||||
if (isTimeline()) return;
|
||||
super.onNestedScroll(target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onNestedFling(View target, float velocityX, float velocityY, boolean consumed) {
|
||||
if (isTimeline() && !consumed) return true;
|
||||
return super.onNestedFling(target, velocityX, velocityY, consumed);
|
||||
}
|
||||
|
||||
private boolean isTimeline() {
|
||||
return TIMELINE_NATIVE_ID.equals(getTag(R.id.view_tag_native_id));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setMaintainVisibleContentPosition(
|
||||
@Nullable MaintainVisibleScrollPositionHelper.Config config) {
|
||||
|
||||
@@ -54,28 +54,6 @@ using namespace facebook::react;
|
||||
[[RCTComponentViewFactory currentComponentViewFactory] registerComponentViewClass:self];
|
||||
}
|
||||
|
||||
// iOS 26 blurs a scroll view's edges and places the blur in the scroll view's own coordinates,
|
||||
// ignoring transforms. The keyboard lifts the chat with a translateY, so on iPhones the blur lands
|
||||
// over the readable chat and stays until the next scroll (#7883). React Native hides it for
|
||||
// inverted lists for the same reason (https://github.com/react/react-native/pull/58684). The app
|
||||
// draws its own header fade, so every edge is hidden; hiding only top and bottom leaves the side
|
||||
// effects, which also cover a transformed list on device.
|
||||
- (instancetype)initWithFrame:(CGRect)frame
|
||||
{
|
||||
if (self = [super initWithFrame:frame]) {
|
||||
#if defined(__IPHONE_26_0) && __IPHONE_OS_VERSION_MAX_ALLOWED >= __IPHONE_26_0
|
||||
if (@available(iOS 26.0, *)) {
|
||||
UIScrollView *scrollView = self.scrollView;
|
||||
scrollView.topEdgeEffect.hidden = YES;
|
||||
scrollView.bottomEdgeEffect.hidden = YES;
|
||||
scrollView.leftEdgeEffect.hidden = YES;
|
||||
scrollView.rightEdgeEffect.hidden = YES;
|
||||
}
|
||||
#endif
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)mountingTransactionWillMount:(const MountingTransaction &)transaction
|
||||
withSurfaceTelemetry:(const SurfaceTelemetry &)surfaceTelemetry
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.15.3",
|
||||
"version": "0.15.2",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { useCallback } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useFetchQuery } from "@/data/query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
@@ -77,7 +75,6 @@ export function useAgentCommandsQuery({
|
||||
}),
|
||||
);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const query = useFetchQuery({
|
||||
queryKey: agentCommandsQueryKey({ serverId, agentId, draftConfig }),
|
||||
queryFn: async () => {
|
||||
@@ -104,20 +101,10 @@ export function useAgentCommandsQuery({
|
||||
// isLoading is true when fetching and no data yet
|
||||
const isLoading = query.isPending || query.isLoading;
|
||||
|
||||
// Reset rather than refetch: a refetch keeps the error status while it runs, and the menu
|
||||
// shows loading for the retry.
|
||||
const retry = useCallback(() => {
|
||||
void queryClient.resetQueries({
|
||||
queryKey: agentCommandsQueryKey({ serverId, agentId, draftConfig }),
|
||||
exact: true,
|
||||
});
|
||||
}, [agentId, draftConfig, queryClient, serverId]);
|
||||
|
||||
return {
|
||||
commands: query.data ?? [],
|
||||
isLoading,
|
||||
isError: query.isError && query.data === undefined,
|
||||
error: query.error,
|
||||
retry,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ import { getModeVisuals, type AgentProviderDefinition } from "@getpaseo/protocol
|
||||
export interface AgentControlIconProps {
|
||||
size: number;
|
||||
color: string;
|
||||
strokeWidth?: number;
|
||||
}
|
||||
|
||||
export type AgentControlIcon = ComponentType<AgentControlIconProps>;
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { useShallow } from "zustand/shallow";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import { useProviderIcon } from "@/components/provider-icons";
|
||||
import type { PanelDescriptor } from "@/panels/panel-registry";
|
||||
import { useSessionStore, selectAgentTurnPresentation, type Agent } from "@/stores/session-store";
|
||||
@@ -35,7 +33,6 @@ export function useAgentDescriptor(
|
||||
target: { kind: "agent"; agentId: string },
|
||||
context: { serverId: string },
|
||||
): PanelDescriptor {
|
||||
useTranslation();
|
||||
const descriptorState = useSessionStore(
|
||||
useShallow((state) => {
|
||||
const session = state.sessions[context.serverId];
|
||||
@@ -45,7 +42,6 @@ export function useAgentDescriptor(
|
||||
provider: agent?.provider ?? "codex",
|
||||
title: agent?.title ?? null,
|
||||
status: agent?.status ?? null,
|
||||
backgroundWork: agent?.backgroundWork,
|
||||
pendingPermissionCount: agent?.pendingPermissions.length ?? 0,
|
||||
requiresAttention: agent?.requiresAttention ?? false,
|
||||
attentionReason: agent?.attentionReason ?? null,
|
||||
@@ -61,7 +57,6 @@ export function buildAgentDescriptor(input: {
|
||||
provider: Agent["provider"];
|
||||
title: string | null;
|
||||
status: Agent["status"] | null;
|
||||
backgroundWork?: Agent["backgroundWork"];
|
||||
pendingPermissionCount: number;
|
||||
requiresAttention: boolean;
|
||||
attentionReason: Agent["attentionReason"];
|
||||
@@ -70,25 +65,20 @@ export function buildAgentDescriptor(input: {
|
||||
}): PanelDescriptor {
|
||||
const provider = input.provider;
|
||||
const label = resolveAgentLabel(input.title);
|
||||
const statusBucket = input.status
|
||||
? deriveSidebarStateBucket({
|
||||
status: input.isTurnActive ? "running" : input.status,
|
||||
backgroundWork: input.backgroundWork,
|
||||
pendingPermissionCount: input.pendingPermissionCount,
|
||||
requiresAttention: input.requiresAttention,
|
||||
attentionReason: input.attentionReason,
|
||||
})
|
||||
: null;
|
||||
|
||||
return {
|
||||
label: label ?? "",
|
||||
subtitle:
|
||||
statusBucket === "running" && !input.isTurnActive && input.status !== "running"
|
||||
? i18n.t("message.backgroundWork.running")
|
||||
: `${formatProviderLabel(provider)} agent`,
|
||||
subtitle: `${formatProviderLabel(provider)} agent`,
|
||||
tooltip: label ?? `${formatProviderLabel(provider)} agent`,
|
||||
titleState: label ? "ready" : "loading",
|
||||
icon: input.icon,
|
||||
statusBucket,
|
||||
statusBucket: input.status
|
||||
? deriveSidebarStateBucket({
|
||||
status: input.isTurnActive ? "running" : input.status,
|
||||
pendingPermissionCount: input.pendingPermissionCount,
|
||||
requiresAttention: input.requiresAttention,
|
||||
attentionReason: input.attentionReason,
|
||||
})
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
import React, { act, createRef, type ReactNode } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { within } from "@testing-library/dom";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { i18n as testI18n } from "@/i18n/i18next";
|
||||
import { PlanCard } from "@/components/plan-card";
|
||||
import { MessagePreview } from "@/components/user-message";
|
||||
import { DisclosureRow } from "./disclosure";
|
||||
import type { DisclosureTriggerEdge, StreamViewportHandle } from "./strategy";
|
||||
|
||||
// Load translations so the toggles expose their real accessible names.
|
||||
void testI18n;
|
||||
|
||||
// App sources compile against the classic JSX runtime, which expects React on the global.
|
||||
beforeEach(() => vi.stubGlobal("React", React));
|
||||
|
||||
let root: Root;
|
||||
let container: HTMLDivElement;
|
||||
// The plan body renders through the app's Markdown renderer, which reads through React Query.
|
||||
let queryClient: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
queryClient = new QueryClient();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
queryClient.clear();
|
||||
container.remove();
|
||||
});
|
||||
|
||||
interface Hold {
|
||||
rowId: string;
|
||||
triggerEdge: DisclosureTriggerEdge;
|
||||
/** Whether the expanded content was already on screen when the strategy received the hold. */
|
||||
appliedBefore: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A timeline row whose viewport records each hold. Like the strategies, it runs `apply` itself, so
|
||||
* a toggle that skips the hold still changes state but leaves no record.
|
||||
*/
|
||||
function mountRow(rowId: string, isApplied: () => boolean, children: ReactNode) {
|
||||
const holds: Hold[] = [];
|
||||
const viewport: StreamViewportHandle = {
|
||||
scrollToBottom: vi.fn(),
|
||||
prepareForViewportChange: vi.fn(),
|
||||
holdRow: (heldRowId, apply, triggerEdge) => {
|
||||
holds.push({ rowId: heldRowId, triggerEdge, appliedBefore: isApplied() });
|
||||
apply();
|
||||
},
|
||||
};
|
||||
const viewportRef = createRef<StreamViewportHandle>();
|
||||
viewportRef.current = viewport;
|
||||
act(() =>
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<DisclosureRow rowId={rowId} viewportRef={viewportRef}>
|
||||
{children}
|
||||
</DisclosureRow>
|
||||
</QueryClientProvider>,
|
||||
),
|
||||
);
|
||||
return holds;
|
||||
}
|
||||
|
||||
const view = () => within(container);
|
||||
|
||||
const preLine = { whiteSpace: "pre-line" } as const;
|
||||
|
||||
async function waitForLayout() {
|
||||
await act(async () => {
|
||||
for (let frame = 0; frame < 2; frame += 1) {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
describe("timeline disclosures hold their row", () => {
|
||||
it("routes user message show more and show less through the row's hold", async () => {
|
||||
const longText = Array.from({ length: 60 }, (_, line) => `Line ${line + 1}`).join("\n");
|
||||
const holds = mountRow(
|
||||
"message-1",
|
||||
() => view().queryByRole("button", { name: "Show less" }) !== null,
|
||||
<MessagePreview>
|
||||
<span style={preLine}>{longText}</span>
|
||||
</MessagePreview>,
|
||||
);
|
||||
// The preview learns it overflows from its first layout.
|
||||
await waitForLayout();
|
||||
|
||||
act(() => view().getByRole("button", { name: "Show more" }).click());
|
||||
expect(view().getByRole("button", { name: "Show less" })).toBeTruthy();
|
||||
|
||||
act(() => view().getByRole("button", { name: "Show less" }).click());
|
||||
expect(view().getByRole("button", { name: "Show more" })).toBeTruthy();
|
||||
|
||||
expect(holds).toEqual([
|
||||
// Show more sits under the text it opens.
|
||||
{ rowId: "message-1", triggerEdge: "bottom", appliedBefore: false },
|
||||
{ rowId: "message-1", triggerEdge: "bottom", appliedBefore: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("routes plan card collapse and expand through the row's hold", () => {
|
||||
const header = () => view().getByRole("button", { name: "Approved plan" });
|
||||
const isExpanded = () => header().getAttribute("aria-expanded") === "true";
|
||||
const holds = mountRow(
|
||||
"plan-1",
|
||||
isExpanded,
|
||||
<PlanCard title="Plan" text="Step one" outcome="approved" />,
|
||||
);
|
||||
|
||||
act(() => header().click());
|
||||
expect(isExpanded()).toBe(false);
|
||||
|
||||
act(() => header().click());
|
||||
expect(isExpanded()).toBe(true);
|
||||
|
||||
expect(holds).toEqual([
|
||||
{ rowId: "plan-1", triggerEdge: "top", appliedBefore: true },
|
||||
{ rowId: "plan-1", triggerEdge: "top", appliedBefore: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("toggles a plan card outside a timeline row, as the pending permission card renders", () => {
|
||||
act(() =>
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<PlanCard title="Plan" text="Step one" outcome="pending" />
|
||||
</QueryClientProvider>,
|
||||
),
|
||||
);
|
||||
const header = () => view().getByRole("button", { name: "Plan" });
|
||||
const isExpanded = () => header().getAttribute("aria-expanded") === "true";
|
||||
|
||||
act(() => header().click());
|
||||
expect(isExpanded()).toBe(false);
|
||||
|
||||
act(() => header().click());
|
||||
expect(isExpanded()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,11 @@
|
||||
import { createContext, useContext, useMemo, type ReactNode, type RefObject } from "react";
|
||||
import type { DisclosureTriggerEdge, StreamViewportHandle } from "./strategy";
|
||||
import type { StreamViewportHandle } from "./strategy";
|
||||
|
||||
/**
|
||||
* Runs a disclosure's state change. Inside a timeline row, the trigger keeps its screen
|
||||
* position through the change; elsewhere `apply` runs as is. A trigger below the content it
|
||||
* opens passes `triggerEdge: "bottom"`.
|
||||
* Runs a disclosure's state change. Inside a timeline row, the row that owns the
|
||||
* trigger keeps its screen position through the change; elsewhere `apply` runs as is.
|
||||
*/
|
||||
export type DisclosureHold = (
|
||||
apply: () => void,
|
||||
options?: { triggerEdge?: DisclosureTriggerEdge },
|
||||
) => void;
|
||||
export type DisclosureHold = (apply: () => void) => void;
|
||||
|
||||
const applyInPlace: DisclosureHold = (apply) => apply();
|
||||
|
||||
@@ -25,10 +21,10 @@ export function DisclosureRow({
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const hold = useMemo<DisclosureHold>(
|
||||
() => (apply, options) => {
|
||||
() => (apply) => {
|
||||
const viewport = viewportRef.current;
|
||||
if (viewport) {
|
||||
viewport.holdRow(rowId, apply, options?.triggerEdge ?? "top");
|
||||
viewport.holdRow(rowId, apply);
|
||||
} else {
|
||||
apply();
|
||||
}
|
||||
|
||||
@@ -1,310 +1,197 @@
|
||||
import { nativeHistoryStartGeometry } from "./native-history-start-geometry";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createHistoryStartPagination,
|
||||
abandonHistoryStartPaginationRequest,
|
||||
createHistoryStartPaginationState,
|
||||
evaluateHistoryStartPagination,
|
||||
isHistoryStartLoadingOperation,
|
||||
rearmHistoryStartPagination,
|
||||
settleHistoryStartPagination,
|
||||
type HistoryStartPaginationInput,
|
||||
} from "./history-start-pagination";
|
||||
|
||||
const initial: HistoryStartPaginationInput = {
|
||||
function createArmedHistoryStartPaginationState() {
|
||||
return rearmHistoryStartPagination(createHistoryStartPaginationState());
|
||||
}
|
||||
|
||||
const visibleHistoryStart: HistoryStartPaginationInput = {
|
||||
distanceFromHistoryStart: 0,
|
||||
isUnderfilled: true,
|
||||
hasOlderHistory: true,
|
||||
isLoadingOlderHistory: false,
|
||||
isReady: true,
|
||||
progressKey: "20",
|
||||
progressKey: "epoch-1:20",
|
||||
};
|
||||
|
||||
function harness(initiallyVisible = false) {
|
||||
let geometry = { ...initial };
|
||||
const requests: Array<(value: boolean) => void> = [];
|
||||
let settles = 0;
|
||||
const pagination = createHistoryStartPagination({
|
||||
initiallyVisible,
|
||||
read: () => geometry,
|
||||
load: () => new Promise<boolean>((resolve) => requests.push(resolve)),
|
||||
changed: () => {},
|
||||
scheduleSettle: () => {
|
||||
settles++;
|
||||
},
|
||||
});
|
||||
return {
|
||||
pagination,
|
||||
requests,
|
||||
get settles() {
|
||||
return settles;
|
||||
},
|
||||
set(patch: Partial<HistoryStartPaginationInput>) {
|
||||
geometry = { ...geometry, ...patch };
|
||||
pagination.update();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("history start pagination", () => {
|
||||
it("waits for a delayed request and its committed page before settling", async () => {
|
||||
const h = harness();
|
||||
h.pagination.update();
|
||||
h.set({ progressKey: "10" });
|
||||
expect(h.settles).toBe(0);
|
||||
h.requests[0](true);
|
||||
await Promise.resolve();
|
||||
expect(h.settles).toBe(1);
|
||||
expect(h.pagination.state.visible).toBe(false);
|
||||
h.set({ isUnderfilled: false });
|
||||
h.pagination.settle();
|
||||
expect(h.pagination.state).toEqual({ status: "dormant", visible: true });
|
||||
h.pagination.dispose();
|
||||
});
|
||||
it("waits for the committed page after a request completes", async () => {
|
||||
const h = harness();
|
||||
h.pagination.update();
|
||||
h.requests[0](true);
|
||||
await Promise.resolve();
|
||||
expect(h.settles).toBe(0);
|
||||
h.set({ progressKey: "10" });
|
||||
expect(h.settles).toBe(1);
|
||||
h.pagination.dispose();
|
||||
});
|
||||
it("continues short pages and reveals on exhaustion", async () => {
|
||||
const h = harness();
|
||||
h.pagination.update();
|
||||
h.set({ progressKey: "10" });
|
||||
h.requests[0](true);
|
||||
await Promise.resolve();
|
||||
h.pagination.settle();
|
||||
expect(h.requests).toHaveLength(2);
|
||||
h.set({ hasOlderHistory: false });
|
||||
h.requests[1](true);
|
||||
await Promise.resolve();
|
||||
expect(h.pagination.state).toEqual({ status: "dormant", visible: true });
|
||||
h.pagination.dispose();
|
||||
});
|
||||
it("releases a failed or stalled request and allows retry", async () => {
|
||||
vi.useFakeTimers();
|
||||
const h = harness();
|
||||
h.pagination.update();
|
||||
vi.advanceTimersByTime(15_000);
|
||||
expect(h.pagination.state).toEqual({ status: "failed", visible: true });
|
||||
h.pagination.rearm();
|
||||
expect(h.requests).toHaveLength(2);
|
||||
h.requests[1](false);
|
||||
await Promise.resolve();
|
||||
expect(h.pagination.state.status).toBe("failed");
|
||||
h.pagination.dispose();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
it("keeps visible history visible while filling", () => {
|
||||
const h = harness(true);
|
||||
h.pagination.update();
|
||||
expect(h.pagination.state.visible).toBe(true);
|
||||
h.pagination.dispose();
|
||||
});
|
||||
it("does not load a full viewport until user intent and respects inactive views", () => {
|
||||
const h = harness();
|
||||
h.set({ isUnderfilled: false });
|
||||
expect(h.requests).toHaveLength(0);
|
||||
h.set({ isReady: false });
|
||||
h.pagination.rearm();
|
||||
expect(h.requests).toHaveLength(0);
|
||||
h.set({ isReady: true });
|
||||
expect(h.requests).toHaveLength(1);
|
||||
h.pagination.dispose();
|
||||
});
|
||||
it("preserves the reading position after an anchored page moves away from the start", async () => {
|
||||
const h = harness(true);
|
||||
h.set({ isUnderfilled: false });
|
||||
h.pagination.rearm();
|
||||
h.set({ progressKey: "10", distanceFromHistoryStart: 300 });
|
||||
h.requests[0](true);
|
||||
await Promise.resolve();
|
||||
h.pagination.settle();
|
||||
expect(h.requests).toHaveLength(1);
|
||||
h.set({ distanceFromHistoryStart: 0 });
|
||||
expect(h.requests).toHaveLength(2);
|
||||
h.pagination.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores a timed-out response after the reader retries", async () => {
|
||||
vi.useFakeTimers();
|
||||
const h = harness();
|
||||
h.pagination.update();
|
||||
vi.advanceTimersByTime(15_000);
|
||||
h.pagination.rearm();
|
||||
h.requests[0](true);
|
||||
await Promise.resolve();
|
||||
expect(h.pagination.state.status).toBe("loading");
|
||||
expect(h.settles).toBe(0);
|
||||
h.requests[1](false);
|
||||
await Promise.resolve();
|
||||
expect(h.pagination.state.status).toBe("failed");
|
||||
h.pagination.dispose();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("does not repeat a successful page that made no progress", async () => {
|
||||
vi.useFakeTimers();
|
||||
const h = harness();
|
||||
h.pagination.update();
|
||||
h.requests[0](true);
|
||||
await Promise.resolve();
|
||||
h.pagination.update();
|
||||
expect(h.requests).toHaveLength(1);
|
||||
vi.advanceTimersByTime(15_000);
|
||||
expect(h.pagination.state).toEqual({ status: "failed", visible: true });
|
||||
h.pagination.dispose();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("waits until the cursor and active history are available", () => {
|
||||
const h = harness();
|
||||
h.set({ isReady: false, progressKey: null });
|
||||
h.set({ isReady: true, isLoadingOlderHistory: true });
|
||||
h.set({ isLoadingOlderHistory: false });
|
||||
expect(h.requests).toHaveLength(0);
|
||||
h.set({ progressKey: "20" });
|
||||
expect(h.requests).toHaveLength(1);
|
||||
h.pagination.dispose();
|
||||
});
|
||||
|
||||
it("reveals immediately on exhausted history without offering retry", () => {
|
||||
const h = harness();
|
||||
h.set({ hasOlderHistory: false });
|
||||
expect(h.requests).toHaveLength(0);
|
||||
expect(h.pagination.state).toEqual({ status: "dormant", visible: true });
|
||||
h.pagination.dispose();
|
||||
});
|
||||
|
||||
it("keeps a settled page pending while its view is inactive", async () => {
|
||||
const h = harness();
|
||||
h.pagination.update();
|
||||
h.set({ progressKey: "10" });
|
||||
h.requests[0](true);
|
||||
await Promise.resolve();
|
||||
h.set({ isReady: false });
|
||||
h.pagination.settle();
|
||||
expect(h.pagination.state.status).toBe("settling");
|
||||
expect(h.requests).toHaveLength(1);
|
||||
h.set({ isReady: true, isUnderfilled: false });
|
||||
h.pagination.settle();
|
||||
expect(h.pagination.state).toEqual({ status: "dormant", visible: true });
|
||||
h.pagination.dispose();
|
||||
});
|
||||
|
||||
it("keeps cached history visible after synchronization and subsequent pagination", async () => {
|
||||
const h = harness();
|
||||
h.pagination.reveal();
|
||||
h.pagination.update();
|
||||
h.set({ progressKey: "10" });
|
||||
h.requests[0](true);
|
||||
await Promise.resolve();
|
||||
h.pagination.settle();
|
||||
expect(h.pagination.state.visible).toBe(true);
|
||||
expect(h.requests).toHaveLength(2);
|
||||
h.pagination.dispose();
|
||||
});
|
||||
|
||||
it.each([true, false])(
|
||||
"handles native padding when short history has older=%s",
|
||||
(hasOlderHistory) => {
|
||||
const geometry = nativeHistoryStartGeometry({
|
||||
contentHeight: 825,
|
||||
viewportHeight: 700,
|
||||
offsetY: 125,
|
||||
topOverlayInset: 123,
|
||||
it("treats locally revealed history as progress before remote pagination", () => {
|
||||
const first = evaluateHistoryStartPagination(
|
||||
createArmedHistoryStartPaginationState(),
|
||||
visibleHistoryStart,
|
||||
);
|
||||
const localReveal = evaluateHistoryStartPagination(first.state, {
|
||||
...visibleHistoryStart,
|
||||
progressKey: "epoch-1:20:local-60",
|
||||
});
|
||||
let requests = 0;
|
||||
const pagination = createHistoryStartPagination({
|
||||
initiallyVisible: false,
|
||||
read: () => ({ ...initial, ...geometry, hasOlderHistory }),
|
||||
load: () => {
|
||||
requests++;
|
||||
return new Promise<boolean>(() => {});
|
||||
},
|
||||
changed: () => {},
|
||||
scheduleSettle: () => {},
|
||||
});
|
||||
pagination.update();
|
||||
expect(requests).toBe(hasOlderHistory ? 1 : 0);
|
||||
expect(pagination.state.visible).toBe(!hasOlderHistory);
|
||||
pagination.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
it("uses the forward native list's top edge to request older history", () => {
|
||||
let offsetY = 1300;
|
||||
let requests = 0;
|
||||
const pagination = createHistoryStartPagination({
|
||||
initiallyVisible: true,
|
||||
read: () => ({
|
||||
...initial,
|
||||
...nativeHistoryStartGeometry({
|
||||
contentHeight: 2000,
|
||||
viewportHeight: 700,
|
||||
offsetY,
|
||||
topOverlayInset: 123,
|
||||
}),
|
||||
}),
|
||||
load: () => {
|
||||
requests++;
|
||||
return new Promise<boolean>(() => {});
|
||||
},
|
||||
changed: () => {},
|
||||
scheduleSettle: () => {},
|
||||
expect(first.shouldLoad).toBe(true);
|
||||
expect(localReveal.state).toEqual({
|
||||
status: "settling",
|
||||
loadedProgressKey: "epoch-1:20:local-60",
|
||||
});
|
||||
});
|
||||
pagination.rearm();
|
||||
expect(requests).toBe(0);
|
||||
offsetY = 155;
|
||||
pagination.update();
|
||||
expect(requests).toBe(1);
|
||||
pagination.dispose();
|
||||
});
|
||||
it("waits for user scroll intent before loading", () => {
|
||||
const dormant = createHistoryStartPaginationState();
|
||||
const evaluated = evaluateHistoryStartPagination(dormant, visibleHistoryStart);
|
||||
|
||||
it.each([null, { ...initial, isReady: false }])(
|
||||
"resumes a completed page after its view stays inactive beyond the deadline (%j)",
|
||||
async (inactive) => {
|
||||
vi.useFakeTimers();
|
||||
let geometry: HistoryStartPaginationInput | null = { ...initial };
|
||||
let complete!: (applied: boolean) => void;
|
||||
let requests = 0;
|
||||
const pagination = createHistoryStartPagination({
|
||||
initiallyVisible: false,
|
||||
read: () => geometry,
|
||||
load: () => {
|
||||
requests++;
|
||||
return new Promise<boolean>((resolve) => {
|
||||
complete = resolve;
|
||||
});
|
||||
},
|
||||
changed: () => {},
|
||||
scheduleSettle: () => {},
|
||||
expect(evaluated).toEqual({ state: { status: "dormant" }, shouldLoad: false });
|
||||
});
|
||||
|
||||
it("waits for anchored page geometry before authorizing another page", () => {
|
||||
const first = evaluateHistoryStartPagination(
|
||||
createArmedHistoryStartPaginationState(),
|
||||
visibleHistoryStart,
|
||||
);
|
||||
const inFlight = evaluateHistoryStartPagination(first.state, {
|
||||
...visibleHistoryStart,
|
||||
isLoadingOlderHistory: true,
|
||||
});
|
||||
try {
|
||||
pagination.update();
|
||||
geometry = inactive;
|
||||
pagination.update();
|
||||
complete(true);
|
||||
await Promise.resolve();
|
||||
vi.advanceTimersByTime(30_000);
|
||||
expect(pagination.state.status).toBe("loading");
|
||||
geometry = { ...initial, progressKey: "10" };
|
||||
pagination.update();
|
||||
expect(pagination.state.status).toBe("settling");
|
||||
geometry = inactive;
|
||||
pagination.update();
|
||||
pagination.settle();
|
||||
vi.advanceTimersByTime(30_000);
|
||||
expect(pagination.state.status).toBe("settling");
|
||||
geometry = { ...initial, progressKey: "10" };
|
||||
pagination.update();
|
||||
pagination.settle();
|
||||
expect(requests).toBe(2);
|
||||
expect(pagination.state.status).toBe("loading");
|
||||
vi.advanceTimersByTime(15_000);
|
||||
expect(pagination.state.status).toBe("failed");
|
||||
} finally {
|
||||
pagination.dispose();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
},
|
||||
);
|
||||
const pageApplied = evaluateHistoryStartPagination(inFlight.state, {
|
||||
...visibleHistoryStart,
|
||||
isLoadingOlderHistory: true,
|
||||
progressKey: "epoch-1:10",
|
||||
});
|
||||
|
||||
expect([first.shouldLoad, inFlight.shouldLoad, pageApplied.shouldLoad]).toEqual([
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
]);
|
||||
expect(pageApplied.state).toEqual({ status: "settling", loadedProgressKey: "epoch-1:10" });
|
||||
});
|
||||
|
||||
it("loads one page each time anchored geometry leaves and returns to history start", () => {
|
||||
const first = evaluateHistoryStartPagination(
|
||||
createArmedHistoryStartPaginationState(),
|
||||
visibleHistoryStart,
|
||||
);
|
||||
const pageApplied = evaluateHistoryStartPagination(first.state, {
|
||||
...visibleHistoryStart,
|
||||
progressKey: "epoch-1:10",
|
||||
});
|
||||
const settledAway = settleHistoryStartPagination(pageApplied.state, {
|
||||
...visibleHistoryStart,
|
||||
distanceFromHistoryStart: 300,
|
||||
progressKey: "epoch-1:10",
|
||||
});
|
||||
const returned = evaluateHistoryStartPagination(settledAway.state, {
|
||||
...visibleHistoryStart,
|
||||
progressKey: "epoch-1:10",
|
||||
});
|
||||
|
||||
expect([
|
||||
first.shouldLoad,
|
||||
pageApplied.shouldLoad,
|
||||
settledAway.shouldLoad,
|
||||
returned.shouldLoad,
|
||||
]).toEqual([true, false, false, true]);
|
||||
});
|
||||
|
||||
it("continues the same loading operation when anchored geometry remains at history start", () => {
|
||||
const first = evaluateHistoryStartPagination(
|
||||
createArmedHistoryStartPaginationState(),
|
||||
visibleHistoryStart,
|
||||
);
|
||||
const pageApplied = evaluateHistoryStartPagination(first.state, {
|
||||
...visibleHistoryStart,
|
||||
progressKey: "epoch-1:10",
|
||||
});
|
||||
const continued = settleHistoryStartPagination(pageApplied.state, {
|
||||
...visibleHistoryStart,
|
||||
progressKey: "epoch-1:10",
|
||||
});
|
||||
|
||||
expect(continued.shouldLoad).toBe(true);
|
||||
expect(isHistoryStartLoadingOperation(first.state)).toBe(true);
|
||||
expect(isHistoryStartLoadingOperation(pageApplied.state)).toBe(true);
|
||||
expect(isHistoryStartLoadingOperation(continued.state)).toBe(true);
|
||||
});
|
||||
|
||||
it("latches a request that finishes without cursor progress", () => {
|
||||
const first = evaluateHistoryStartPagination(
|
||||
createArmedHistoryStartPaginationState(),
|
||||
visibleHistoryStart,
|
||||
);
|
||||
const inFlight = evaluateHistoryStartPagination(first.state, {
|
||||
...visibleHistoryStart,
|
||||
isLoadingOlderHistory: true,
|
||||
});
|
||||
const finished = evaluateHistoryStartPagination(inFlight.state, visibleHistoryStart);
|
||||
const duplicate = evaluateHistoryStartPagination(finished.state, visibleHistoryStart);
|
||||
const away = evaluateHistoryStartPagination(duplicate.state, {
|
||||
...visibleHistoryStart,
|
||||
distanceFromHistoryStart: 300,
|
||||
});
|
||||
|
||||
expect(finished.state).toEqual({ status: "latched" });
|
||||
expect([finished.shouldLoad, duplicate.shouldLoad, away.shouldLoad]).toEqual([
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
]);
|
||||
expect(away.state).toEqual({ status: "ready" });
|
||||
});
|
||||
|
||||
it("allows another user attempt after a request finishes without progress", () => {
|
||||
const first = evaluateHistoryStartPagination(
|
||||
createArmedHistoryStartPaginationState(),
|
||||
visibleHistoryStart,
|
||||
);
|
||||
const inFlight = evaluateHistoryStartPagination(first.state, {
|
||||
...visibleHistoryStart,
|
||||
isLoadingOlderHistory: true,
|
||||
});
|
||||
const failed = evaluateHistoryStartPagination(inFlight.state, visibleHistoryStart);
|
||||
const retried = evaluateHistoryStartPagination(
|
||||
rearmHistoryStartPagination(failed.state),
|
||||
visibleHistoryStart,
|
||||
);
|
||||
|
||||
expect(failed.state).toEqual({ status: "latched" });
|
||||
expect(retried.shouldLoad).toBe(true);
|
||||
});
|
||||
|
||||
it("latches an attempt that becomes invalid before entering flight", () => {
|
||||
const requested = evaluateHistoryStartPagination(
|
||||
createArmedHistoryStartPaginationState(),
|
||||
visibleHistoryStart,
|
||||
);
|
||||
|
||||
expect(abandonHistoryStartPaginationRequest(requested.state, "epoch-1:20")).toEqual({
|
||||
status: "latched",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not mistake repeated edge observations for a finished request", () => {
|
||||
const first = evaluateHistoryStartPagination(
|
||||
createArmedHistoryStartPaginationState(),
|
||||
visibleHistoryStart,
|
||||
);
|
||||
const repeated = evaluateHistoryStartPagination(first.state, visibleHistoryStart);
|
||||
|
||||
expect(repeated.state).toBe(first.state);
|
||||
expect(repeated.shouldLoad).toBe(false);
|
||||
});
|
||||
|
||||
it("waits while history loading is unavailable or already active", () => {
|
||||
const state = createArmedHistoryStartPaginationState();
|
||||
|
||||
expect([
|
||||
evaluateHistoryStartPagination(state, { ...visibleHistoryStart, isReady: false }).shouldLoad,
|
||||
evaluateHistoryStartPagination(state, { ...visibleHistoryStart, hasOlderHistory: false })
|
||||
.shouldLoad,
|
||||
evaluateHistoryStartPagination(state, { ...visibleHistoryStart, isLoadingOlderHistory: true })
|
||||
.shouldLoad,
|
||||
evaluateHistoryStartPagination(state, { ...visibleHistoryStart, progressKey: null })
|
||||
.shouldLoad,
|
||||
]).toEqual([false, false, false, false]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,149 +1,138 @@
|
||||
export const HISTORY_START_THRESHOLD_PX = 96;
|
||||
|
||||
export type HistoryStartPaginationState =
|
||||
| { status: "dormant" }
|
||||
| { status: "ready" }
|
||||
| { status: "loading"; requestedProgressKey: string; requestObserved: boolean }
|
||||
| { status: "settling"; loadedProgressKey: string }
|
||||
| { status: "latched" };
|
||||
|
||||
export interface HistoryStartPaginationInput {
|
||||
distanceFromHistoryStart: number;
|
||||
isUnderfilled: boolean;
|
||||
hasOlderHistory: boolean;
|
||||
isLoadingOlderHistory: boolean;
|
||||
isReady: boolean;
|
||||
progressKey: string | null;
|
||||
}
|
||||
|
||||
export interface HistoryStartPaginationState {
|
||||
status: "dormant" | "ready" | "loading" | "settling" | "failed";
|
||||
visible: boolean;
|
||||
export interface HistoryStartPaginationTransition {
|
||||
state: HistoryStartPaginationState;
|
||||
shouldLoad: boolean;
|
||||
}
|
||||
|
||||
export function createHistoryStartPaginationState(): HistoryStartPaginationState {
|
||||
return { status: "dormant" };
|
||||
}
|
||||
|
||||
export function isHistoryStartLoadingOperation(state: HistoryStartPaginationState): boolean {
|
||||
return state.status === "loading" || state.status === "settling";
|
||||
}
|
||||
|
||||
/** Owns one pagination operation, including the initial viewport reveal. */
|
||||
export function createHistoryStartPagination(input: {
|
||||
initiallyVisible: boolean;
|
||||
read: () => HistoryStartPaginationInput | null;
|
||||
load: () => Promise<boolean>;
|
||||
changed: (state: HistoryStartPaginationState) => void;
|
||||
scheduleSettle: () => void;
|
||||
}) {
|
||||
let state: HistoryStartPaginationState = { status: "dormant", visible: input.initiallyVisible };
|
||||
let requestedKey: string | null = null;
|
||||
let completed = false;
|
||||
let fill = false;
|
||||
let generation = 0;
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
export function rearmHistoryStartPagination(
|
||||
state: HistoryStartPaginationState,
|
||||
): HistoryStartPaginationState {
|
||||
return state.status === "dormant" || state.status === "latched" ? { status: "ready" } : state;
|
||||
}
|
||||
|
||||
const publish = (status: HistoryStartPaginationState["status"], visible = state.visible) => {
|
||||
if (status === state.status && visible === state.visible) return;
|
||||
state = { status, visible };
|
||||
input.changed(state);
|
||||
};
|
||||
const clear = () => {
|
||||
generation++;
|
||||
clearTimeout(timeout);
|
||||
timeout = undefined;
|
||||
};
|
||||
const finish = (status: HistoryStartPaginationState["status"]) => {
|
||||
clear();
|
||||
publish(status, true);
|
||||
};
|
||||
const fail = () => finish("failed");
|
||||
export function abandonHistoryStartPaginationRequest(
|
||||
state: HistoryStartPaginationState,
|
||||
requestedProgressKey: string,
|
||||
): HistoryStartPaginationState {
|
||||
if (
|
||||
state.status !== "loading" ||
|
||||
state.requestObserved ||
|
||||
state.requestedProgressKey !== requestedProgressKey
|
||||
) {
|
||||
return state;
|
||||
}
|
||||
return { status: "latched" };
|
||||
}
|
||||
|
||||
const watchProgress = (geometry: HistoryStartPaginationInput | null) => {
|
||||
if (completed && !geometry?.isReady) {
|
||||
clearTimeout(timeout);
|
||||
timeout = undefined;
|
||||
} else if (timeout === undefined) {
|
||||
const request = generation;
|
||||
timeout = setTimeout(() => {
|
||||
timeout = undefined;
|
||||
// A completed request waits for its retained view to become measurable again.
|
||||
// Unfinished requests still time out even while the view is inactive.
|
||||
if (request === generation && (!completed || input.read()?.isReady)) fail();
|
||||
}, 15_000);
|
||||
export function evaluateHistoryStartPagination(
|
||||
state: HistoryStartPaginationState,
|
||||
input: HistoryStartPaginationInput,
|
||||
): HistoryStartPaginationTransition {
|
||||
if (state.status === "dormant") {
|
||||
return { state, shouldLoad: false };
|
||||
}
|
||||
if (state.status === "loading") {
|
||||
if (input.progressKey !== null && input.progressKey !== state.requestedProgressKey) {
|
||||
return {
|
||||
state: { status: "settling", loadedProgressKey: input.progressKey },
|
||||
shouldLoad: false,
|
||||
};
|
||||
}
|
||||
};
|
||||
if (input.isLoadingOlderHistory && !state.requestObserved) {
|
||||
return { state: { ...state, requestObserved: true }, shouldLoad: false };
|
||||
}
|
||||
if (!input.isLoadingOlderHistory && !input.hasOlderHistory) {
|
||||
return { state: { status: "latched" }, shouldLoad: false };
|
||||
}
|
||||
if (
|
||||
state.requestObserved &&
|
||||
!input.isLoadingOlderHistory &&
|
||||
input.progressKey === state.requestedProgressKey
|
||||
) {
|
||||
return { state: { status: "latched" }, shouldLoad: false };
|
||||
}
|
||||
return { state, shouldLoad: false };
|
||||
}
|
||||
|
||||
const update = () => {
|
||||
const geometry = input.read();
|
||||
if (isHistoryStartLoadingOperation(state)) watchProgress(geometry);
|
||||
if (!geometry) return;
|
||||
if (state.status === "loading") {
|
||||
if (!completed) return;
|
||||
if (geometry.progressKey !== null && geometry.progressKey !== requestedKey) {
|
||||
publish("settling");
|
||||
input.scheduleSettle();
|
||||
} else if (!geometry.hasOlderHistory) {
|
||||
finish("dormant");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (state.status === "settling" || state.status === "failed" || !geometry.isReady) return;
|
||||
const atStart = geometry.distanceFromHistoryStart <= HISTORY_START_THRESHOLD_PX;
|
||||
if (state.status === "dormant" && !geometry.isUnderfilled) {
|
||||
publish("dormant", true);
|
||||
return;
|
||||
}
|
||||
if (!atStart || (!geometry.isUnderfilled && state.status !== "ready")) return;
|
||||
if (!geometry.hasOlderHistory) {
|
||||
finish("dormant");
|
||||
return;
|
||||
}
|
||||
if (geometry.isLoadingOlderHistory || geometry.progressKey === null) return;
|
||||
fill = geometry.isUnderfilled;
|
||||
requestedKey = geometry.progressKey;
|
||||
completed = false;
|
||||
publish("loading");
|
||||
const request = ++generation;
|
||||
watchProgress(geometry);
|
||||
void input.load().then(
|
||||
(applied) => {
|
||||
if (request !== generation) return undefined;
|
||||
if (!applied) {
|
||||
fail();
|
||||
return undefined;
|
||||
}
|
||||
completed = true;
|
||||
update();
|
||||
return undefined;
|
||||
},
|
||||
() => {
|
||||
if (request === generation) fail();
|
||||
},
|
||||
);
|
||||
};
|
||||
if (state.status === "settling") {
|
||||
return { state, shouldLoad: false };
|
||||
}
|
||||
|
||||
const isAtHistoryStart = input.distanceFromHistoryStart <= HISTORY_START_THRESHOLD_PX;
|
||||
if (!isAtHistoryStart) {
|
||||
return state.status === "ready"
|
||||
? { state, shouldLoad: false }
|
||||
: { state: { status: "ready" }, shouldLoad: false };
|
||||
}
|
||||
if (
|
||||
state.status === "latched" ||
|
||||
!input.isReady ||
|
||||
!input.hasOlderHistory ||
|
||||
input.isLoadingOlderHistory ||
|
||||
input.progressKey === null
|
||||
) {
|
||||
return { state, shouldLoad: false };
|
||||
}
|
||||
return {
|
||||
get state() {
|
||||
return state;
|
||||
state: {
|
||||
status: "loading",
|
||||
requestedProgressKey: input.progressKey,
|
||||
requestObserved: false,
|
||||
},
|
||||
update,
|
||||
settle() {
|
||||
if (state.status !== "settling") return;
|
||||
const geometry = input.read();
|
||||
if (!geometry?.isReady) return;
|
||||
clear();
|
||||
if (fill && !geometry.isUnderfilled) {
|
||||
publish("dormant", true);
|
||||
} else if (geometry.distanceFromHistoryStart > HISTORY_START_THRESHOLD_PX) {
|
||||
publish("ready", true);
|
||||
} else if (!geometry.hasOlderHistory) {
|
||||
publish("dormant", true);
|
||||
} else {
|
||||
publish("ready");
|
||||
update();
|
||||
}
|
||||
shouldLoad: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function settleHistoryStartPagination(
|
||||
state: HistoryStartPaginationState,
|
||||
input: HistoryStartPaginationInput,
|
||||
): HistoryStartPaginationTransition {
|
||||
if (state.status !== "settling") {
|
||||
return { state, shouldLoad: false };
|
||||
}
|
||||
const isAtHistoryStart = input.distanceFromHistoryStart <= HISTORY_START_THRESHOLD_PX;
|
||||
if (
|
||||
!isAtHistoryStart ||
|
||||
!input.isReady ||
|
||||
!input.hasOlderHistory ||
|
||||
input.isLoadingOlderHistory ||
|
||||
input.progressKey === null
|
||||
) {
|
||||
return {
|
||||
state: isAtHistoryStart ? { status: "latched" } : { status: "ready" },
|
||||
shouldLoad: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
state: {
|
||||
status: "loading",
|
||||
requestedProgressKey: input.progressKey,
|
||||
requestObserved: false,
|
||||
},
|
||||
rearm() {
|
||||
if (state.status === "dormant" || state.status === "failed") publish("ready", true);
|
||||
update();
|
||||
},
|
||||
reveal() {
|
||||
publish(state.status, true);
|
||||
},
|
||||
reset(visible: boolean) {
|
||||
clear();
|
||||
publish("dormant", visible);
|
||||
},
|
||||
dispose: clear,
|
||||
shouldLoad: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import React, { act, useSyncExternalStore } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { View } from "react-native";
|
||||
import { afterEach, beforeEach, expect, it } from "vitest";
|
||||
import { disclosureAnchor } from "./native-disclosure-hold";
|
||||
|
||||
// The native chat timeline runs on the patched LegendList (patches/@legendapp+list+3.7.1.patch).
|
||||
// A disclosure hold keeps only the held row in place: `shouldRestorePosition` accepts no other row.
|
||||
@@ -131,16 +130,7 @@ function mountList(heldRowId: string) {
|
||||
return position - state.scroll;
|
||||
};
|
||||
const rowSize = (rowId: string) => listRef.current?.getState().sizes.get(rowId);
|
||||
const rowBottom = (rowId: string) => {
|
||||
const state = listRef.current?.getState();
|
||||
const position = state?.positionByKey(rowId);
|
||||
const size = state?.sizes.get(rowId);
|
||||
if (state === undefined || position === undefined || size === undefined) {
|
||||
throw new Error(`Row ${rowId} is not laid out`);
|
||||
}
|
||||
return position + size - state.scroll;
|
||||
};
|
||||
return { render, scrollTo, heldRowTop, rowSize, rowBottom };
|
||||
return { render, scrollTo, heldRowTop, rowSize };
|
||||
}
|
||||
|
||||
it("anchors a data change on a held row whose top is above the viewport", async () => {
|
||||
@@ -181,72 +171,6 @@ it("anchors a size change on a held row whose top is above the viewport", async
|
||||
expect(list.heldRowTop()).toBe(-20);
|
||||
});
|
||||
|
||||
// A long message's show more sits under the text it opens, on the bottom edge of its row. The
|
||||
// strategy anchors the row below it, whose top is that bottom edge.
|
||||
const bottomTriggerRows: Row[] = Array.from({ length: 10 }, (_, index) => ({
|
||||
id: `row-${index}`,
|
||||
height: index === 3 ? 60 : 40,
|
||||
}));
|
||||
|
||||
function mountBottomTriggerHold(rowId: string) {
|
||||
const anchor = disclosureAnchor(bottomTriggerRows, rowId, "bottom");
|
||||
if (anchor.kind !== "row") throw new Error(`Row ${rowId} has no row below it`);
|
||||
return mountList(anchor.rowId);
|
||||
}
|
||||
|
||||
it("keeps the bottom of a row whose trigger sits under its growing content", async () => {
|
||||
const list = mountBottomTriggerHold("row-3");
|
||||
list.render(bottomTriggerRows);
|
||||
await wait(150);
|
||||
await list.scrollTo(100);
|
||||
const triggerBottom = list.rowBottom("row-3");
|
||||
expect(triggerBottom).toBe(80);
|
||||
|
||||
act(() => growRow("row-3", 260));
|
||||
await wait(150);
|
||||
expect(list.rowBottom("row-3")).toBe(triggerBottom);
|
||||
|
||||
act(() => growRow("row-3", 60));
|
||||
await wait(150);
|
||||
expect(list.rowBottom("row-3")).toBe(triggerBottom);
|
||||
});
|
||||
|
||||
it("keeps the bottom of a bottom-trigger row whose top is above the viewport", async () => {
|
||||
const list = mountBottomTriggerHold("row-3");
|
||||
list.render(bottomTriggerRows);
|
||||
await wait(150);
|
||||
await list.scrollTo(140);
|
||||
const triggerBottom = list.rowBottom("row-3");
|
||||
expect(triggerBottom).toBe(40);
|
||||
|
||||
act(() => growRow("row-3", 260));
|
||||
await wait(150);
|
||||
expect(list.rowBottom("row-3")).toBe(triggerBottom);
|
||||
|
||||
act(() => growRow("row-3", 60));
|
||||
await wait(150);
|
||||
expect(list.rowBottom("row-3")).toBe(triggerBottom);
|
||||
});
|
||||
|
||||
it("keeps the bottom of a bottom-trigger row that ends below the viewport", async () => {
|
||||
// In the app the row ends with the message's timestamp under the trigger, so a trigger near the
|
||||
// bottom of the viewport has its row's bottom, and the row below, out of view.
|
||||
const list = mountBottomTriggerHold("row-3");
|
||||
list.render(bottomTriggerRows);
|
||||
await wait(150);
|
||||
await list.scrollTo(40);
|
||||
const rowBottom = list.rowBottom("row-3");
|
||||
expect(rowBottom).toBe(140);
|
||||
|
||||
act(() => growRow("row-3", 260));
|
||||
await wait(150);
|
||||
expect(list.rowBottom("row-3")).toBe(rowBottom);
|
||||
|
||||
act(() => growRow("row-3", 60));
|
||||
await wait(150);
|
||||
expect(list.rowBottom("row-3")).toBe(rowBottom);
|
||||
});
|
||||
|
||||
it("keeps measured row sizes while the list is not laid out", async () => {
|
||||
// A hidden retained tab is display: none. Its rows still render, and a row measured there
|
||||
// reports 0 x 0. When the tab shows again, a row whose frame did not change gets no new layout
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createNativeDisclosureHold,
|
||||
disclosureAnchor,
|
||||
createEndHoldDistance,
|
||||
type DisclosureAnchor,
|
||||
} from "./native-disclosure-hold";
|
||||
import { createNativeDisclosureHold } from "./native-disclosure-hold";
|
||||
|
||||
function createFrames() {
|
||||
let nextId = 1;
|
||||
@@ -44,22 +39,22 @@ describe("native disclosure hold", () => {
|
||||
it("holds the row until two frames pass without an item size change", () => {
|
||||
const { frames, hold, releases } = createHarness();
|
||||
|
||||
hold.hold({ kind: "row", rowId: "turn-1" });
|
||||
hold.hold("turn-1");
|
||||
frames.runFrame();
|
||||
|
||||
expect(hold.anchorRowId()).toBe("turn-1");
|
||||
expect(hold.heldRowId()).toBe("turn-1");
|
||||
expect(releases).toEqual([]);
|
||||
|
||||
frames.runFrame();
|
||||
|
||||
expect(hold.anchorRowId()).toBeNull();
|
||||
expect(hold.heldRowId()).toBeNull();
|
||||
expect(releases).toEqual(["released"]);
|
||||
});
|
||||
|
||||
it("restarts the quiet frames on every item size change", () => {
|
||||
const { frames, hold, releases } = createHarness();
|
||||
|
||||
hold.hold({ kind: "row", rowId: "turn-1" });
|
||||
hold.hold("turn-1");
|
||||
frames.runFrame();
|
||||
hold.noteItemSizeChanged();
|
||||
frames.runFrame();
|
||||
@@ -84,11 +79,11 @@ describe("native disclosure hold", () => {
|
||||
it("moves the hold to the latest toggled row and releases once", () => {
|
||||
const { frames, hold, releases } = createHarness();
|
||||
|
||||
hold.hold({ kind: "row", rowId: "turn-1" });
|
||||
hold.hold("turn-1");
|
||||
frames.runFrame();
|
||||
hold.hold({ kind: "row", rowId: "turn-2" });
|
||||
hold.hold("turn-2");
|
||||
|
||||
expect(hold.anchorRowId()).toBe("turn-2");
|
||||
expect(hold.heldRowId()).toBe("turn-2");
|
||||
|
||||
frames.runFrame();
|
||||
frames.runFrame();
|
||||
@@ -99,137 +94,12 @@ describe("native disclosure hold", () => {
|
||||
it("drops the hold without releasing when cancelled", () => {
|
||||
const { frames, hold, releases } = createHarness();
|
||||
|
||||
hold.hold({ kind: "row", rowId: "turn-1" });
|
||||
hold.hold("turn-1");
|
||||
hold.cancel();
|
||||
frames.runFrame();
|
||||
frames.runFrame();
|
||||
|
||||
expect(hold.anchorRowId()).toBeNull();
|
||||
expect(hold.heldRowId()).toBeNull();
|
||||
expect(releases).toEqual([]);
|
||||
});
|
||||
|
||||
it("holds the content end until two quiet frames pass, with no row to anchor on", () => {
|
||||
const { frames, hold, releases } = createHarness();
|
||||
|
||||
hold.hold({ kind: "end" });
|
||||
frames.runFrame();
|
||||
hold.noteItemSizeChanged();
|
||||
frames.runFrame();
|
||||
|
||||
expect(hold.anchorRowId()).toBeNull();
|
||||
expect(releases).toEqual([]);
|
||||
|
||||
frames.runFrame();
|
||||
|
||||
expect(releases).toEqual(["released"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("next-frame disclosure hold", () => {
|
||||
it("anchors the row a reply landed as while the toggle waited for its frame", () => {
|
||||
const { frames, hold } = createHarness();
|
||||
let anchor: DisclosureAnchor = { kind: "end" };
|
||||
const applied: DisclosureAnchor[] = [];
|
||||
|
||||
hold.holdNextFrame(
|
||||
() => anchor,
|
||||
(resolved) => applied.push(resolved),
|
||||
);
|
||||
expect(applied).toEqual([]);
|
||||
|
||||
anchor = { kind: "row", rowId: "turn-2" };
|
||||
frames.runFrame();
|
||||
|
||||
expect(hold.anchorRowId()).toBe("turn-2");
|
||||
expect(applied).toEqual([{ kind: "row", rowId: "turn-2" }]);
|
||||
});
|
||||
|
||||
it("still lands the toggle when the hold is cancelled before its frame", () => {
|
||||
const { frames, hold, releases } = createHarness();
|
||||
const applied: DisclosureAnchor[] = [];
|
||||
|
||||
hold.holdNextFrame(
|
||||
() => ({ kind: "end" }),
|
||||
(resolved) => applied.push(resolved),
|
||||
);
|
||||
hold.cancel();
|
||||
frames.runFrame();
|
||||
frames.runFrame();
|
||||
|
||||
expect(applied).toEqual([{ kind: "end" }]);
|
||||
expect(releases).toEqual([]);
|
||||
});
|
||||
|
||||
it("lands a waiting toggle before the next hold starts", () => {
|
||||
const { frames, hold } = createHarness();
|
||||
const applied: DisclosureAnchor[] = [];
|
||||
|
||||
hold.holdNextFrame(
|
||||
() => ({ kind: "end" }),
|
||||
(resolved) => applied.push(resolved),
|
||||
);
|
||||
hold.hold({ kind: "row", rowId: "turn-1" });
|
||||
|
||||
expect(applied).toEqual([{ kind: "end" }]);
|
||||
frames.runFrame();
|
||||
expect(applied).toEqual([{ kind: "end" }]);
|
||||
expect(hold.anchorRowId()).toBe("turn-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("disclosure anchor", () => {
|
||||
const rows = [{ id: "message-1" }, { id: "turn-1" }, { id: "message-2" }];
|
||||
|
||||
it("keeps the top of a row whose trigger sits on its top edge", () => {
|
||||
expect(disclosureAnchor(rows, "turn-1", "top")).toEqual({ kind: "row", rowId: "turn-1" });
|
||||
});
|
||||
|
||||
it("keeps the top of the row below a row whose trigger sits on its bottom edge", () => {
|
||||
expect(disclosureAnchor(rows, "message-1", "bottom")).toEqual({
|
||||
kind: "row",
|
||||
rowId: "turn-1",
|
||||
});
|
||||
});
|
||||
|
||||
// A prompt the agent has not answered yet is the last row; the running indicator is the footer.
|
||||
it("keeps the content end for a bottom-edge trigger on the last row", () => {
|
||||
expect(disclosureAnchor(rows, "message-2", "bottom")).toEqual({ kind: "end" });
|
||||
});
|
||||
|
||||
// The strategy re-derives this each render, so a reply lets go of the end in the commit it lands.
|
||||
it("stops keeping the content end once a reply lands below the row", () => {
|
||||
expect(disclosureAnchor([...rows, { id: "turn-2" }], "message-2", "bottom")).toEqual({
|
||||
kind: "row",
|
||||
rowId: "turn-2",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("end hold distance", () => {
|
||||
const viewportHeight = 600;
|
||||
|
||||
it("keeps the distance from the end the toggle started at", () => {
|
||||
const distance = createEndHoldDistance(20);
|
||||
|
||||
distance.noteContentSize({ contentHeight: 2000, viewportHeight });
|
||||
|
||||
expect(distance.current()).toBe(20);
|
||||
});
|
||||
|
||||
it("shrinks to the scroll room a collapse leaves", () => {
|
||||
const distance = createEndHoldDistance(20);
|
||||
|
||||
distance.noteContentSize({ contentHeight: 610, viewportHeight });
|
||||
|
||||
expect(distance.current()).toBe(10);
|
||||
});
|
||||
|
||||
it("stays at the end when output grows the content after a collapse reached the end", () => {
|
||||
const distance = createEndHoldDistance(20);
|
||||
|
||||
distance.noteContentSize({ contentHeight: 600, viewportHeight });
|
||||
distance.noteContentSize({ contentHeight: 700, viewportHeight });
|
||||
|
||||
expect(distance.current()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,30 +1,13 @@
|
||||
import type { DisclosureTriggerEdge } from "./strategy";
|
||||
|
||||
/** What a disclosure hold keeps in place: the top of a row, or the end of the content. */
|
||||
export type DisclosureAnchor = { kind: "row"; rowId: string } | { kind: "end" };
|
||||
|
||||
/**
|
||||
* The native viewport's disclosure hold. While it holds a row anchor, that row is the only row the
|
||||
* list may anchor on (`disclosureAnchor`). The hold ends once item sizes stop changing for
|
||||
* `quietFrames` frames: the list measures the toggled row and any rows it inserted over one or more
|
||||
* frames, and end following must not resume between the change and the list's last layout
|
||||
* correction.
|
||||
* The native viewport's disclosure hold. While a row is held, it is the only row the list may
|
||||
* anchor on. The hold ends once item sizes stop changing for `quietFrames` frames: the list
|
||||
* measures the toggled row and any rows it inserted over one or more frames, and end following
|
||||
* must not resume between the change and the list's last layout correction.
|
||||
*/
|
||||
export interface NativeDisclosureHold {
|
||||
/** The row the list may anchor on; null outside a hold and while the hold keeps the end. */
|
||||
anchorRowId(): string | null;
|
||||
hold(anchor: DisclosureAnchor): void;
|
||||
/**
|
||||
* Holds on the next frame and runs `apply` there, for a toggle that has to land after a commit
|
||||
* already on its way. `resolveAnchor` runs in that frame, because rows may land while it waits.
|
||||
* A later hold or a cancel lands a waiting toggle right away.
|
||||
*/
|
||||
holdNextFrame(
|
||||
resolveAnchor: () => DisclosureAnchor,
|
||||
apply: (anchor: DisclosureAnchor) => void,
|
||||
): void;
|
||||
heldRowId(): string | null;
|
||||
hold(rowId: string): void;
|
||||
noteItemSizeChanged(): void;
|
||||
/** Drops the hold without releasing it. */
|
||||
cancel(): void;
|
||||
}
|
||||
|
||||
@@ -34,10 +17,9 @@ export function createNativeDisclosureHold(input: {
|
||||
cancelFrame: (id: number) => void;
|
||||
onRelease: () => void;
|
||||
}): NativeDisclosureHold {
|
||||
let anchor: DisclosureAnchor | null = null;
|
||||
let heldRowId: string | null = null;
|
||||
let frameId: number | null = null;
|
||||
let remainingFrames = 0;
|
||||
let waitingToggle: { frameId: number; land: () => void } | null = null;
|
||||
|
||||
const stopCountdown = () => {
|
||||
if (frameId !== null) {
|
||||
@@ -53,7 +35,7 @@ export function createNativeDisclosureHold(input: {
|
||||
return;
|
||||
}
|
||||
frameId = null;
|
||||
anchor = null;
|
||||
heldRowId = null;
|
||||
input.onRelease();
|
||||
};
|
||||
|
||||
@@ -63,80 +45,20 @@ export function createNativeDisclosureHold(input: {
|
||||
frameId = input.requestFrame(tick);
|
||||
};
|
||||
|
||||
const landWaitingToggle = () => {
|
||||
if (!waitingToggle) return;
|
||||
const { frameId: waitingFrameId, land } = waitingToggle;
|
||||
waitingToggle = null;
|
||||
input.cancelFrame(waitingFrameId);
|
||||
land();
|
||||
};
|
||||
|
||||
const startHold = (nextAnchor: DisclosureAnchor) => {
|
||||
anchor = nextAnchor;
|
||||
restartCountdown();
|
||||
};
|
||||
|
||||
return {
|
||||
anchorRowId: () => (anchor?.kind === "row" ? anchor.rowId : null),
|
||||
hold(nextAnchor) {
|
||||
landWaitingToggle();
|
||||
startHold(nextAnchor);
|
||||
},
|
||||
holdNextFrame(resolveAnchor, apply) {
|
||||
landWaitingToggle();
|
||||
const waitingFrameId = input.requestFrame(() => {
|
||||
waitingToggle = null;
|
||||
const nextAnchor = resolveAnchor();
|
||||
startHold(nextAnchor);
|
||||
apply(nextAnchor);
|
||||
});
|
||||
waitingToggle = { frameId: waitingFrameId, land: () => apply(resolveAnchor()) };
|
||||
heldRowId: () => heldRowId,
|
||||
hold(rowId) {
|
||||
heldRowId = rowId;
|
||||
restartCountdown();
|
||||
},
|
||||
noteItemSizeChanged() {
|
||||
if (anchor !== null) {
|
||||
if (heldRowId !== null) {
|
||||
restartCountdown();
|
||||
}
|
||||
},
|
||||
cancel() {
|
||||
landWaitingToggle();
|
||||
stopCountdown();
|
||||
anchor = null;
|
||||
heldRowId = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* What the list keeps in place while a disclosure toggles `rowId`. LegendList restores row tops, so
|
||||
* a trigger on the bottom edge of its row anchors the row below: that row's top is the toggled
|
||||
* row's bottom. The last row's bottom is the end of the content.
|
||||
*/
|
||||
export function disclosureAnchor(
|
||||
rows: readonly { id: string }[],
|
||||
rowId: string,
|
||||
triggerEdge: DisclosureTriggerEdge,
|
||||
): DisclosureAnchor {
|
||||
if (triggerEdge === "top") return { kind: "row", rowId };
|
||||
const index = rows.findIndex((row) => row.id === rowId);
|
||||
const below = rows[index + 1];
|
||||
return below ? { kind: "row", rowId: below.id } : { kind: "end" };
|
||||
}
|
||||
|
||||
/**
|
||||
* The list's distance from the end during an end hold. The end pin keeps the distance through every
|
||||
* content size change, and the scroll view clamps it to the scroll room the content has at that
|
||||
* moment. A clamp is permanent: content that grows afterwards does not bring the distance back.
|
||||
*/
|
||||
export function createEndHoldDistance(distanceAtToggle: number): EndHoldDistance {
|
||||
let distance = distanceAtToggle;
|
||||
return {
|
||||
noteContentSize({ contentHeight, viewportHeight }) {
|
||||
distance = Math.min(distance, Math.max(0, contentHeight - viewportHeight));
|
||||
},
|
||||
current: () => distance,
|
||||
};
|
||||
}
|
||||
|
||||
export interface EndHoldDistance {
|
||||
noteContentSize(input: { contentHeight: number; viewportHeight: number }): void;
|
||||
current(): number;
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
export function nativeHistoryStartGeometry(input: {
|
||||
contentHeight: number;
|
||||
viewportHeight: number;
|
||||
offsetY: number;
|
||||
topOverlayInset: number;
|
||||
}) {
|
||||
const contentHeight = input.contentHeight - 32 - input.topOverlayInset;
|
||||
return {
|
||||
distanceFromHistoryStart: Math.max(0, input.offsetY - input.topOverlayInset - 32),
|
||||
isUnderfilled: contentHeight < input.viewportHeight,
|
||||
};
|
||||
}
|
||||
@@ -593,98 +593,6 @@ describe("timeline presentation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("folds work before a plugin-rendered reply and leaves the reply visible", () => {
|
||||
const timestamp = new Date("2026-01-01T00:00:00.000Z");
|
||||
const tail: StreamItem[] = [
|
||||
{ kind: "user_message", id: "prompt", text: "Calculate", timestamp },
|
||||
toolCall("one", "Bash"),
|
||||
{ kind: "assistant_message", id: "reply", text: "$$x^2$$", timestamp },
|
||||
];
|
||||
const result = createStreamPresentation()({
|
||||
...presentationOptions,
|
||||
transform: installedTransform(installProbe("assistant_message", "phase === 'complete'")),
|
||||
tail,
|
||||
head: [],
|
||||
display: "collapseTurns",
|
||||
isTurnActive: false,
|
||||
});
|
||||
expect(result.tail.map((item) => item.id)).toEqual([
|
||||
"prompt",
|
||||
"turn-summary:prompt",
|
||||
"probe-plugin/reply/0",
|
||||
]);
|
||||
expect(pluginData(result.tail)).toEqual([{ text: "$$x^2$$", phase: "complete" }]);
|
||||
});
|
||||
|
||||
it("keeps streaming replies native, then folds work without losing manual expansion", () => {
|
||||
const timestamp = new Date("2026-01-01T00:00:00.000Z");
|
||||
const work: StreamItem[] = [
|
||||
{ kind: "user_message", id: "prompt", text: "Calculate", timestamp },
|
||||
toolCall("one", "Bash"),
|
||||
];
|
||||
const reply: StreamItem = {
|
||||
kind: "assistant_message",
|
||||
id: "reply",
|
||||
text: "$$x^2$$",
|
||||
timestamp,
|
||||
};
|
||||
const input = {
|
||||
...presentationOptions,
|
||||
transform: installedTransform(installProbe("assistant_message", "phase === 'complete'")),
|
||||
display: "collapseTurns" as const,
|
||||
};
|
||||
const present = createStreamPresentation();
|
||||
const live = present({ ...input, tail: work, head: [reply], isTurnActive: true });
|
||||
expect(rows(live).map((item) => item.id)).toEqual(["prompt", "tool-one", "reply"]);
|
||||
const completed = { ...input, tail: [...work, reply], head: [], isTurnActive: false };
|
||||
expect(present(completed).tail.map((item) => item.id)).toEqual([
|
||||
"prompt",
|
||||
"turn-summary:prompt",
|
||||
"probe-plugin/reply/0",
|
||||
]);
|
||||
const expandedTurnHostIds = new Set(["prompt"]);
|
||||
const opened = present({ ...completed, expandedTurnHostIds });
|
||||
expect(opened.tail.map((item) => item.id)).toEqual([
|
||||
"prompt",
|
||||
"turn-summary:prompt",
|
||||
"tool-one",
|
||||
"probe-plugin/reply/0",
|
||||
]);
|
||||
expect(opened.turns.nestedItemIds.has("probe-plugin/reply/0")).toBe(false);
|
||||
expect(present({ ...completed, expandedTurnHostIds }).tail).toEqual(opened.tail);
|
||||
expect(present(completed).tail.map((item) => item.id)).toEqual([
|
||||
"prompt",
|
||||
"turn-summary:prompt",
|
||||
"probe-plugin/reply/0",
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(["full", "collapseToolCalls"] as const)(
|
||||
"leaves plugin replies and individual work visible in %s",
|
||||
(display) => {
|
||||
const timestamp = new Date("2026-01-01T00:00:00.000Z");
|
||||
const tail: StreamItem[] = [
|
||||
{ kind: "user_message", id: "prompt", text: "Calculate", timestamp },
|
||||
toolCall("one", "Bash"),
|
||||
{ kind: "assistant_message", id: "reply", text: "$$x^2$$", timestamp },
|
||||
];
|
||||
const result = createStreamPresentation()({
|
||||
...presentationOptions,
|
||||
transform: installedTransform(installProbe("assistant_message")),
|
||||
tail,
|
||||
head: [],
|
||||
display,
|
||||
isTurnActive: false,
|
||||
});
|
||||
expect(result.tail.map((item) => item.id)).toEqual([
|
||||
"prompt",
|
||||
"tool-one",
|
||||
"probe-plugin/reply/0",
|
||||
]);
|
||||
expect(result.turns.summaryRowsByHostId.size).toBe(0);
|
||||
},
|
||||
);
|
||||
|
||||
it("opening a turn reveals individual calls rather than another tool group", () => {
|
||||
const present = createStreamPresentation();
|
||||
const timestamp = new Date();
|
||||
@@ -703,13 +611,7 @@ it("opening a turn reveals individual calls rather than another tool group", ()
|
||||
isTurnActive: false,
|
||||
expandedTurnHostIds: new Set(["prompt"]),
|
||||
});
|
||||
expect(result.tail.map((item) => item.id)).toEqual([
|
||||
"prompt",
|
||||
"turn-summary:prompt",
|
||||
"tool-one",
|
||||
"tool-two",
|
||||
"reply",
|
||||
]);
|
||||
expect(result.tail.map((item) => item.id)).toEqual(tail.map((item) => item.id));
|
||||
expect(result.groupsByHostId.size).toBe(0);
|
||||
expect(result.readingRowReplacements.get("tool-two")).toBeUndefined();
|
||||
});
|
||||
@@ -751,7 +653,7 @@ it.each(["collapseToolCalls", "collapseTurns"] as const)(
|
||||
{ kind: "assistant_message", id: "reply", text: "Done", timestamp },
|
||||
],
|
||||
});
|
||||
// Both the original partial group and its replacement resolve directly to the turn's host.
|
||||
// Both the original partial group and its replacement resolve directly to the turn.
|
||||
expect(folded.readingRowReplacements.get(calls[1]!.id)).toBe("prompt");
|
||||
expect(anchor.getRowId(folded.readingRowReplacements)).toBe("prompt");
|
||||
const opened = present({ ...input, tail: calls, display: "full" });
|
||||
@@ -800,7 +702,7 @@ it("does not reopen the previous turn while a new run waits for its first row",
|
||||
isTurnActive: true,
|
||||
activeTurnId: "new",
|
||||
});
|
||||
expect(result.tail.map((item) => item.id)).toEqual(["prompt", "turn-summary:prompt", "reply"]);
|
||||
expect(result.tail.map((item) => item.id)).toEqual(["prompt", "reply"]);
|
||||
});
|
||||
|
||||
// A sent prompt counts as activity before the daemon opens its turn, so the only known turn is
|
||||
@@ -822,7 +724,7 @@ it("keeps the previous turn folded while a sent prompt waits for its turn to ope
|
||||
isTurnActive: true,
|
||||
activeTurnId: null,
|
||||
});
|
||||
expect(result.tail.map((item) => item.id)).toEqual(["prompt", "turn-summary:prompt", "reply"]);
|
||||
expect(result.tail.map((item) => item.id)).toEqual(["prompt", "reply"]);
|
||||
});
|
||||
|
||||
it("groups reasoning with tools unless Expand thinking is enabled, including setting changes", () => {
|
||||
@@ -879,13 +781,7 @@ it.each([false, true])(
|
||||
const closed = present(input);
|
||||
expect(closed.tail.some((item) => item.id === "thought")).toBe(false);
|
||||
const opened = present({ ...input, expandedTurnHostIds: new Set(["prompt"]) });
|
||||
expect(opened.tail.map((item) => item.id)).toEqual([
|
||||
"prompt",
|
||||
"turn-summary:prompt",
|
||||
"thought",
|
||||
"tool-one",
|
||||
"reply",
|
||||
]);
|
||||
expect(opened.tail).toEqual(tail);
|
||||
expect(opened.turns.nestedItemIds.has("thought")).toBe(true);
|
||||
expect(opened.groupsByHostId.size).toBe(0);
|
||||
},
|
||||
|
||||
@@ -50,7 +50,7 @@ const NO_COLLAPSED_TURNS: StreamPresentation["turns"] = collapseTurns({
|
||||
isLatestTurnOpen: false,
|
||||
expandedHostIds: new Set(),
|
||||
getGroupedCalls: () => undefined,
|
||||
previousSummaryRowsByHostId: new Map(),
|
||||
previousTurnsByHostId: new Map(),
|
||||
});
|
||||
|
||||
/** Source messages reach plugins before display projection. */
|
||||
@@ -135,8 +135,7 @@ export function createStreamPresentation() {
|
||||
isLatestTurnOpen,
|
||||
expandedHostIds: input.expandedTurnHostIds,
|
||||
getGroupedCalls: (itemId) => projection.groupsByHostId.get(itemId)?.run.calls,
|
||||
previousSummaryRowsByHostId:
|
||||
collapsed?.summaryRowsByHostId ?? NO_COLLAPSED_TURNS.summaryRowsByHostId,
|
||||
previousTurnsByHostId: collapsed?.turnsByHostId ?? NO_COLLAPSED_TURNS.turnsByHostId,
|
||||
});
|
||||
collapsedSource = projection.tail;
|
||||
collapsedIsLatestTurnOpen = isLatestTurnOpen;
|
||||
|
||||
@@ -7,7 +7,7 @@ describe("reading anchor", () => {
|
||||
anchor.reconcile(0, [{ id: "tool", top: 48, height: 200 }]);
|
||||
const replacements = new Map([["tool", "prompt"]]);
|
||||
expect(anchor.getRowId(replacements)).toBe("prompt");
|
||||
expect(anchor.project(0, { id: "prompt", top: 1048, height: 100 }, replacements)).toBe(1000);
|
||||
expect(anchor.project(0, { id: "prompt", top: 1048 }, replacements)).toBe(1000);
|
||||
// Projection does not consume the correction; an unmounted host keeps the pin.
|
||||
expect(anchor.reconcile(0, [], false, replacements)).toBe(0);
|
||||
expect(anchor.getRowId()).toBe("tool");
|
||||
@@ -59,32 +59,13 @@ describe("reading anchor", () => {
|
||||
it("keeps a pinned row in place instead of the row under the reading line", () => {
|
||||
const anchor = createReadingAnchor();
|
||||
anchor.reconcile(600, rows);
|
||||
anchor.pin(rows[2]!, "top", 600);
|
||||
anchor.pin(rows[2]!);
|
||||
// The row being read grows; only the pinned row below it holds its position.
|
||||
expect(
|
||||
anchor.reconcile(600, [rows[0]!, { ...rows[1]!, height: 800 }, { ...rows[2]!, top: 1300 }]),
|
||||
).toBe(900);
|
||||
});
|
||||
|
||||
it("keeps the bottom of a row pinned by a trigger below its content", () => {
|
||||
const anchor = createReadingAnchor();
|
||||
anchor.reconcile(600, rows);
|
||||
anchor.pin(rows[2]!, "bottom", 600);
|
||||
// The pinned row grows downward; its bottom, where the trigger sits, holds its position.
|
||||
const grown = { ...rows[2]!, height: rows[2]!.height + 300 };
|
||||
expect(anchor.reconcile(600, [rows[0]!, rows[1]!, grown])).toBe(900);
|
||||
});
|
||||
|
||||
it("measures a pinned row's correction from the offset at the toggle", () => {
|
||||
const anchor = createReadingAnchor();
|
||||
anchor.reconcile(900, rows);
|
||||
anchor.pin(rows[2]!, "bottom", 900);
|
||||
// The row shrinks and the browser clamps the offset to the shorter content before the
|
||||
// reconcile reads it.
|
||||
const shrunk = { ...rows[2]!, height: rows[2]!.height - 300 };
|
||||
expect(anchor.reconcile(700, [rows[0]!, rows[1]!, shrunk])).toBe(600);
|
||||
});
|
||||
|
||||
it("leaves expansion of the row being read in place", () => {
|
||||
const anchor = createReadingAnchor();
|
||||
anchor.reconcile(600, rows);
|
||||
@@ -128,8 +109,8 @@ describe("reading anchor", () => {
|
||||
it("projects a prepend range without consuming the correction before layout commits", () => {
|
||||
const anchor = createReadingAnchor();
|
||||
anchor.reconcile(600, rows);
|
||||
expect(anchor.project(440, { id: "reading", top: 2500, height: 100 })).toBe(2440);
|
||||
expect(anchor.project(440, { id: "reading", top: 2500, height: 100 })).toBe(2440);
|
||||
expect(anchor.project(440, { id: "reading", top: 2500 })).toBe(2440);
|
||||
expect(anchor.project(440, { id: "reading", top: 2500 })).toBe(2440);
|
||||
expect(
|
||||
anchor.reconcile(
|
||||
440,
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import type { DisclosureTriggerEdge } from "./strategy";
|
||||
|
||||
interface RowGeometry {
|
||||
id: string;
|
||||
top: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
const edgeOf = (row: RowGeometry, edge: DisclosureTriggerEdge) =>
|
||||
edge === "top" ? row.top : row.top + row.height;
|
||||
|
||||
// A row must clear the reading line before the next row takes ownership.
|
||||
const READING_POSITION_OFFSET_PX = 8;
|
||||
export interface ReadingRowReplacements {
|
||||
@@ -19,16 +14,7 @@ const NO_REPLACEMENTS: ReadingRowReplacements = new Map();
|
||||
// Content coordinates survive user scrolling. Layout commits replace the geometry;
|
||||
// scroll events reuse it, without another DOM measurement pass.
|
||||
export function createReadingAnchor() {
|
||||
// The row being read keeps its top. A disclosure pins the edge of its row the trigger sits on,
|
||||
// with the offset at the toggle: a toggle that shrinks the content lets the browser clamp the
|
||||
// offset before the next reconcile reads it.
|
||||
let anchor: {
|
||||
id: string;
|
||||
edge: DisclosureTriggerEdge;
|
||||
position: number;
|
||||
pinnedScrollTop?: number;
|
||||
} | null = null;
|
||||
const anchorTop = (row: RowGeometry) => ({ id: row.id, edge: "top" as const, position: row.top });
|
||||
let anchor: { id: string; top: number } | null = null;
|
||||
let geometry: readonly RowGeometry[] = [];
|
||||
const readingRow = (scrollTop: number) =>
|
||||
geometry.find((row) => row.top + row.height > scrollTop + READING_POSITION_OFFSET_PX);
|
||||
@@ -36,12 +22,9 @@ export function createReadingAnchor() {
|
||||
anchor ? (replacements.get(anchor.id) ?? anchor.id) : null;
|
||||
const project = (
|
||||
scrollTop: number,
|
||||
row: RowGeometry | undefined,
|
||||
row: Pick<RowGeometry, "id" | "top"> | undefined,
|
||||
replacements = NO_REPLACEMENTS,
|
||||
) =>
|
||||
anchor && row && row.id === getRowId(replacements)
|
||||
? (anchor.pinnedScrollTop ?? scrollTop) + edgeOf(row, anchor.edge) - anchor.position
|
||||
: scrollTop;
|
||||
) => scrollTop + (anchor && row && row.id === getRowId(replacements) ? row.top - anchor.top : 0);
|
||||
return {
|
||||
getRowId,
|
||||
getReadingRowId: (scrollTop: number) =>
|
||||
@@ -50,14 +33,14 @@ export function createReadingAnchor() {
|
||||
reset() {
|
||||
anchor = null;
|
||||
},
|
||||
// A disclosure names the row that owns its trigger and the edge the trigger sits on. That
|
||||
// edge stays in place through the toggle, even when another row sits under the reading line.
|
||||
pin(row: RowGeometry, edge: DisclosureTriggerEdge, scrollTop: number) {
|
||||
anchor = { id: row.id, edge, position: edgeOf(row, edge), pinnedScrollTop: scrollTop };
|
||||
// A disclosure names the row that owns its trigger. That row keeps its top
|
||||
// through the toggle, even when another row sits under the reading line.
|
||||
pin(row: Pick<RowGeometry, "id" | "top">) {
|
||||
anchor = { id: row.id, top: row.top };
|
||||
},
|
||||
scroll(scrollTop: number) {
|
||||
const next = readingRow(scrollTop);
|
||||
anchor = next ? anchorTop(next) : null;
|
||||
anchor = next ? { id: next.id, top: next.top } : null;
|
||||
},
|
||||
reconcile(
|
||||
scrollTop: number,
|
||||
@@ -74,7 +57,7 @@ export function createReadingAnchor() {
|
||||
// A prepend can expose the bottom of an estimated row above the reader.
|
||||
// Do not transfer ownership to it until the user moves the reading position.
|
||||
const next = previous && !userScrolled ? previous : readingRow(correctedTop);
|
||||
anchor = next ? anchorTop(next) : null;
|
||||
anchor = next ? { id: next.id, top: next.top } : null;
|
||||
return correctedTop;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getAssistantMessageSpacing, getGapBetweenStreamItems } from "./spacing";
|
||||
import { summarizeToolCalls } from "@/tool-calls/detail-level/overview/model";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
const message: StreamItem = {
|
||||
kind: "assistant_message",
|
||||
@@ -8,27 +7,6 @@ const message: StreamItem = {
|
||||
text: "First\n\nSecond",
|
||||
timestamp: new Date(0),
|
||||
};
|
||||
const prompt: StreamItem = {
|
||||
kind: "user_message",
|
||||
id: "p",
|
||||
text: "Fix it",
|
||||
timestamp: new Date(0),
|
||||
};
|
||||
const thought: StreamItem = {
|
||||
kind: "thought",
|
||||
id: "t",
|
||||
text: "Reading",
|
||||
timestamp: new Date(0),
|
||||
status: "ready",
|
||||
};
|
||||
const summary: StreamItem = {
|
||||
kind: "turn_summary",
|
||||
id: "turn-summary:p",
|
||||
hostId: "p",
|
||||
expanded: false,
|
||||
timestamp: new Date(0),
|
||||
tools: summarizeToolCalls([]),
|
||||
};
|
||||
describe("whole-message spacing", () => {
|
||||
it("compacts only the gap to its turn footer", () => {
|
||||
expect(getAssistantMessageSpacing({ item: message })).toBe("default");
|
||||
@@ -39,18 +17,4 @@ describe("whole-message spacing", () => {
|
||||
it("keeps separate messages separated", () => {
|
||||
expect(getGapBetweenStreamItems(message, { ...message, id: "next" })).toBeGreaterThan(0);
|
||||
});
|
||||
it("sets a prompt the same distance above whatever answers it", () => {
|
||||
const belowPrompt = getGapBetweenStreamItems(prompt, thought);
|
||||
expect(belowPrompt).toBeGreaterThan(0);
|
||||
expect(getGapBetweenStreamItems(prompt, message)).toBe(belowPrompt);
|
||||
expect(getGapBetweenStreamItems(prompt, summary)).toBe(belowPrompt);
|
||||
});
|
||||
it("spaces a turn's summary row like the work rows it stands for", () => {
|
||||
expect(getGapBetweenStreamItems(summary, message)).toBe(
|
||||
getGapBetweenStreamItems(thought, message),
|
||||
);
|
||||
expect(getGapBetweenStreamItems(thought, summary)).toBe(
|
||||
getGapBetweenStreamItems(thought, thought),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,12 +11,8 @@ export function getAssistantMessageSpacing(params: {
|
||||
}
|
||||
|
||||
const isUserMessageItem = (item?: StreamItem | null) => item?.kind === "user_message";
|
||||
/** A row of the agent's work, including the summary row that stands in for a folded turn's. */
|
||||
const isWorkItem = (item?: StreamItem | null) =>
|
||||
item?.kind === "tool_call" ||
|
||||
item?.kind === "thought" ||
|
||||
item?.kind === "todo_list" ||
|
||||
item?.kind === "turn_summary";
|
||||
const isToolSequenceItem = (item?: StreamItem | null) =>
|
||||
item?.kind === "tool_call" || item?.kind === "thought" || item?.kind === "todo_list";
|
||||
|
||||
export function getGapBetweenStreamItems(
|
||||
item: StreamItem | null,
|
||||
@@ -29,16 +25,19 @@ export function getGapBetweenStreamItems(
|
||||
if (isUserMessageItem(item) && isUserMessageItem(belowItem)) {
|
||||
return SPACING[1];
|
||||
}
|
||||
if (isUserMessageItem(item)) {
|
||||
return SPACING[4];
|
||||
}
|
||||
if (isWorkItem(item) && isWorkItem(belowItem)) {
|
||||
if (item.kind === "user_message" && belowItem.kind === "assistant_message") {
|
||||
return 0;
|
||||
}
|
||||
if (item.kind === "assistant_message" && isWorkItem(belowItem)) {
|
||||
if (isToolSequenceItem(item) && isToolSequenceItem(belowItem)) {
|
||||
return 0;
|
||||
}
|
||||
if (item.kind === "user_message" && isToolSequenceItem(belowItem)) {
|
||||
return SPACING[4];
|
||||
}
|
||||
if (item.kind === "assistant_message" && isToolSequenceItem(belowItem)) {
|
||||
return SPACING[1];
|
||||
}
|
||||
if (isWorkItem(item) && belowItem.kind === "assistant_message") {
|
||||
if (isToolSequenceItem(item) && belowItem.kind === "assistant_message") {
|
||||
return SPACING[1];
|
||||
}
|
||||
return SPACING[4];
|
||||
|
||||
@@ -25,9 +25,6 @@ import {
|
||||
} from "@legendapp/list/react-native";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
@@ -35,27 +32,21 @@ import { shouldAnimateNativeScrollToBottom } from "./native-scroll-animation";
|
||||
import { useRevisedHistoryRows } from "./history-row-revision";
|
||||
import { useBottomAnchorController } from "./bottom-anchor-controller";
|
||||
import { useScrollKeyboardDismiss } from "./scroll-keyboard-dismiss/use-scroll-keyboard-dismiss";
|
||||
import {
|
||||
createNativeDisclosureHold,
|
||||
disclosureAnchor,
|
||||
createEndHoldDistance,
|
||||
type EndHoldDistance,
|
||||
} from "./native-disclosure-hold";
|
||||
import { createNativeDisclosureHold } from "./native-disclosure-hold";
|
||||
import { EndPinnedScrollView } from "./native-end-pin";
|
||||
import { createNewRowEstimates } from "./new-row-estimates";
|
||||
import type {
|
||||
DisclosureTriggerEdge,
|
||||
StreamRenderInput,
|
||||
StreamStrategy,
|
||||
StreamViewportHandle,
|
||||
} from "./strategy";
|
||||
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy";
|
||||
import { createStreamStrategy, isNearBottomForStreamRenderStrategy } from "./strategy";
|
||||
import {
|
||||
abandonHistoryStartPaginationRequest,
|
||||
createHistoryStartPaginationState,
|
||||
evaluateHistoryStartPagination,
|
||||
isHistoryStartLoadingOperation,
|
||||
rearmHistoryStartPagination,
|
||||
settleHistoryStartPagination,
|
||||
type HistoryStartPaginationInput,
|
||||
type HistoryStartPaginationTransition,
|
||||
} from "./history-start-pagination";
|
||||
import { useHistoryStartPagination } from "./use-history-start-pagination";
|
||||
import { nativeHistoryStartGeometry } from "./native-history-start-geometry";
|
||||
import { useStreamTopOverlayInset } from "./top-overlay";
|
||||
import {
|
||||
createHistoryStartSettleScheduler,
|
||||
@@ -117,16 +108,6 @@ function distanceFromEnd(input: {
|
||||
}): number {
|
||||
return Math.max(0, input.contentHeight - input.viewportHeight - input.offsetY);
|
||||
}
|
||||
const viewportStyle: ViewStyle = { flex: 1 };
|
||||
const initialFillStyle: ViewStyle = {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
};
|
||||
|
||||
function keyExtractor(item: { id: string }): string {
|
||||
return item.id;
|
||||
@@ -144,8 +125,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
viewportRef,
|
||||
routeBottomAnchorRequest,
|
||||
isAuthoritativeHistoryReady,
|
||||
isInitialHistoryLoading,
|
||||
revealInitialHistory,
|
||||
onNearBottomChange,
|
||||
onNearHistoryStart,
|
||||
isLoadingOlderHistory,
|
||||
@@ -156,8 +135,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
baseListContentContainerStyle,
|
||||
strategy,
|
||||
} = props;
|
||||
const isActive = useRetainedPanelActive();
|
||||
const { t } = useTranslation();
|
||||
const { renderHistoryMountedRow, renderLiveHeadRow, renderLiveAuxiliary } = renderers;
|
||||
const listRef = useRef<LegendListRef>(null);
|
||||
// LegendList places the rows once, behind a hidden reveal, when the list mounts.
|
||||
@@ -180,6 +157,10 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
const isReleasedAtEndRef = useRef(false);
|
||||
const programmaticScrollEventBudgetRef = useRef(0);
|
||||
const historyStartReadyRef = useRef(false);
|
||||
const [historyStartPaginationState, setHistoryStartPaginationState] = useState(
|
||||
createHistoryStartPaginationState,
|
||||
);
|
||||
const historyStartPaginationStateRef = useRef(historyStartPaginationState);
|
||||
const historyStartSettleSchedulerRef = useRef<HistoryStartSettleScheduler | null>(null);
|
||||
|
||||
const historyItems = useMemo(() => {
|
||||
@@ -210,36 +191,48 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
metrics.viewportMeasuredForKey === metrics.containerKey &&
|
||||
metrics.contentMeasuredForKey === metrics.containerKey;
|
||||
return {
|
||||
...nativeHistoryStartGeometry({
|
||||
contentHeight: metrics.contentHeight,
|
||||
viewportHeight: metrics.viewportHeight,
|
||||
offsetY: metrics.offsetY,
|
||||
topOverlayInset,
|
||||
}),
|
||||
distanceFromHistoryStart: metrics.offsetY,
|
||||
hasOlderHistory,
|
||||
isLoadingOlderHistory,
|
||||
isReady:
|
||||
isActive &&
|
||||
isAuthoritativeHistoryReady &&
|
||||
historyStartReadyRef.current &&
|
||||
hasMeasuredViewport,
|
||||
isReady: historyStartReadyRef.current && hasMeasuredViewport,
|
||||
progressKey: olderHistoryProgressKey,
|
||||
};
|
||||
});
|
||||
const { state: historyStartPaginationState, controller: historyStartPagination } =
|
||||
useHistoryStartPagination({
|
||||
initiallyVisible: !isInitialHistoryLoading,
|
||||
read: getHistoryStartPaginationInput,
|
||||
load: async () => onNearHistoryStart(),
|
||||
scheduleSettle: () => scheduleHistoryStartSettle(),
|
||||
});
|
||||
const evaluateHistoryStart = useStableEvent(() => historyStartPagination.update());
|
||||
const disposeHistoryStart = useStableEvent(() => historyStartPagination.dispose());
|
||||
const isHistoryStartSettling = useStableEvent(
|
||||
() => historyStartPagination.state.status === "settling",
|
||||
const applyHistoryStartPaginationTransition = useStableEvent(
|
||||
(transition: HistoryStartPaginationTransition) => {
|
||||
const previousState = historyStartPaginationStateRef.current;
|
||||
historyStartPaginationStateRef.current = transition.state;
|
||||
if (transition.state !== previousState) {
|
||||
setHistoryStartPaginationState(transition.state);
|
||||
}
|
||||
if (transition.shouldLoad) {
|
||||
const requestedProgressKey = olderHistoryProgressKey;
|
||||
if (requestedProgressKey === null) {
|
||||
return;
|
||||
}
|
||||
void (async () => {
|
||||
const started = await onNearHistoryStart();
|
||||
if (started === true) {
|
||||
return;
|
||||
}
|
||||
applyHistoryStartPaginationTransition({
|
||||
state: abandonHistoryStartPaginationRequest(
|
||||
historyStartPaginationStateRef.current,
|
||||
requestedProgressKey,
|
||||
),
|
||||
shouldLoad: false,
|
||||
});
|
||||
})();
|
||||
}
|
||||
},
|
||||
);
|
||||
const finishInitialFill = useStableEvent(() => historyStartPagination.reveal());
|
||||
const retryHistoryStart = useStableEvent(() => historyStartPagination.rearm());
|
||||
const evaluateHistoryStart = useStableEvent(() => {
|
||||
const transition = evaluateHistoryStartPagination(
|
||||
historyStartPaginationStateRef.current,
|
||||
getHistoryStartPaginationInput(),
|
||||
);
|
||||
applyHistoryStartPaginationTransition(transition);
|
||||
});
|
||||
const scheduleHistoryStartSettle = useStableEvent(() => {
|
||||
let scheduler = historyStartSettleSchedulerRef.current;
|
||||
if (!scheduler) {
|
||||
@@ -247,9 +240,15 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
settleFrames: HISTORY_START_SETTLE_FRAMES,
|
||||
requestFrame: requestAnimationFrame,
|
||||
cancelFrame: cancelAnimationFrame,
|
||||
isSettling: () => isHistoryStartSettling(),
|
||||
isSettling: () => historyStartPaginationStateRef.current.status === "settling",
|
||||
isLoading: () => getHistoryStartPaginationInput().isLoadingOlderHistory,
|
||||
onSettle: () => historyStartPagination.settle(),
|
||||
onSettle: () => {
|
||||
const transition = settleHistoryStartPagination(
|
||||
historyStartPaginationStateRef.current,
|
||||
getHistoryStartPaginationInput(),
|
||||
);
|
||||
applyHistoryStartPaginationTransition(transition);
|
||||
},
|
||||
});
|
||||
historyStartSettleSchedulerRef.current = scheduler;
|
||||
}
|
||||
@@ -316,14 +315,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
const isAtEnd = useStableEvent(
|
||||
() => distanceFromEnd(streamViewportMetricsRef.current) <= AT_END_OFFSET_PX,
|
||||
);
|
||||
// A hold on the content end mounts the end pin while detached (`holdRow`). The release reads the
|
||||
// tracked distance instead of the offset: the content size reaches JS before the pin's scroll does.
|
||||
const [endHold, setEndHold] = useState<{ rowId: string; distance: EndHoldDistance } | null>(null);
|
||||
const releaseDisclosure = useStableEvent(() => {
|
||||
setEndHold(null);
|
||||
bottomAnchorController.releaseDisclosure({
|
||||
isAtEnd: endHold ? endHold.distance.current() <= AT_END_OFFSET_PX : isAtEnd(),
|
||||
});
|
||||
bottomAnchorController.releaseDisclosure({ isAtEnd: isAtEnd() });
|
||||
});
|
||||
const [disclosureHold] = useState(() =>
|
||||
createNativeDisclosureHold({
|
||||
@@ -333,54 +326,16 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
onRelease: releaseDisclosure,
|
||||
}),
|
||||
);
|
||||
const cancelDisclosureHold = useStableEvent(() => {
|
||||
disclosureHold.cancel();
|
||||
setEndHold(null);
|
||||
});
|
||||
useEffect(() => () => cancelDisclosureHold(), [cancelDisclosureHold]);
|
||||
useEffect(() => () => disclosureHold.cancel(), [disclosureHold]);
|
||||
// The hold detaches the controller in the same event as `apply`, so the toggle's commit already
|
||||
// has end following off and LegendList anchoring on the held row, or on the row below it for a
|
||||
// trigger on the row's bottom edge. The row's measurement trigger makes that same commit carry
|
||||
// the row's new height. While following, a bottom trigger keeps following: the end pin holds the
|
||||
// content end, and with it the bottom of the row, in the frame the new height mounts.
|
||||
// The last row's bottom is the content end, which LegendList cannot anchor. The end pin can, from
|
||||
// any offset, but only once it is mounted: a pin that mounts with the new height anchors nothing.
|
||||
// So the toggle lands a frame after the pin's commit, whose mount runs before the frame's timers.
|
||||
const latestDisclosureAnchor = useStableEvent(
|
||||
(rowId: string, triggerEdge: DisclosureTriggerEdge) =>
|
||||
disclosureAnchor(rows, rowId, triggerEdge),
|
||||
);
|
||||
const holdRow = useStableEvent(
|
||||
(rowId: string, apply: () => void, triggerEdge: DisclosureTriggerEdge) => {
|
||||
const applyAndMeasure = () => {
|
||||
apply();
|
||||
rowLayoutSyncRegistry.get(rowId)?.();
|
||||
};
|
||||
if (triggerEdge === "bottom" && bottomAnchorController.mode === "sticky-bottom") {
|
||||
applyAndMeasure();
|
||||
return;
|
||||
}
|
||||
const anchor = disclosureAnchor(rows, rowId, triggerEdge);
|
||||
bottomAnchorController.holdForDisclosure();
|
||||
if (anchor.kind === "row" || !isListPlacedRef.current) {
|
||||
setEndHold(null);
|
||||
disclosureHold.hold(anchor);
|
||||
applyAndMeasure();
|
||||
return;
|
||||
}
|
||||
setEndHold({
|
||||
rowId,
|
||||
distance: createEndHoldDistance(distanceFromEnd(streamViewportMetricsRef.current)),
|
||||
});
|
||||
disclosureHold.holdNextFrame(
|
||||
() => latestDisclosureAnchor(rowId, triggerEdge),
|
||||
(latestAnchor) => {
|
||||
if (latestAnchor.kind === "row") setEndHold(null);
|
||||
applyAndMeasure();
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
// has end following off and LegendList anchoring on the held row. The row's measurement trigger
|
||||
// makes that same commit carry the row's new height.
|
||||
const holdRow = useStableEvent((rowId: string, apply: () => void) => {
|
||||
disclosureHold.hold(rowId);
|
||||
bottomAnchorController.holdForDisclosure();
|
||||
apply();
|
||||
rowLayoutSyncRegistry.get(rowId)?.();
|
||||
});
|
||||
const handleItemSizeChanged = useStableEvent(
|
||||
(info: { size: number; previous: number; itemKey: string; itemData: StreamItem }) => {
|
||||
newRowEstimates.noteMeasured(info.itemData, info.size);
|
||||
@@ -388,8 +343,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
},
|
||||
);
|
||||
const shouldRestorePosition = useStableEvent((item: StreamItem) => {
|
||||
const anchorRowId = disclosureHold.anchorRowId();
|
||||
return anchorRowId === null || item.id === anchorRowId;
|
||||
const heldRowId = disclosureHold.heldRowId();
|
||||
return heldRowId === null || item.id === heldRowId;
|
||||
});
|
||||
// One owner keeps the scroll offset at any moment:
|
||||
// - Before the list has placed its rows for the agent: LegendList's initial scroll to the end. It
|
||||
@@ -403,23 +358,16 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
// scrolls to the end its JS sizes predict, which runs ahead of the native mount, so it lands
|
||||
// short of the real end and the controller reads that as the user scrolling away.
|
||||
// - A disclosure hold: the held row. The hold detaches the controller, which removes the pin, and
|
||||
// LegendList keeps only the held row in place, or the row below it when the trigger sits on the
|
||||
// held row's bottom edge. A bottom-edge hold while following leaves the end pin in charge, and
|
||||
// one on the last row mounts the pin for the hold: the row's bottom is the content end.
|
||||
// LegendList keeps only the held row in place.
|
||||
// - A user scroll: the finger. The controller detaches for the gesture, so streamed lines grow below
|
||||
// the rows the user is dragging, and resumes following if the gesture ends at the end.
|
||||
// - Detached: the visible rows. LegendList keeps the first visible row in place, so history loading
|
||||
// above or rows growing below leave what the user is reading where it is.
|
||||
const isFollowingEnd = bottomAnchorController.mode === "sticky-bottom";
|
||||
// An end hold pins the end only while its row's bottom is the end. A reply landing below the row
|
||||
// drops the pin in the commit that mounts the reply, so the reply grows below the held row.
|
||||
const isEndHeld =
|
||||
endHold !== null && disclosureAnchor(rows, endHold.rowId, "bottom").kind === "end";
|
||||
const isEndPinned = isFollowingEnd || isEndHeld;
|
||||
const pinsEnd = isEndPinned && isListPlaced;
|
||||
const pinsEnd = isFollowingEnd && isListPlaced;
|
||||
const maintainVisibleContentPosition = useMemo(
|
||||
() => (isEndPinned ? false : { data: true, size: true, shouldRestorePosition }),
|
||||
[isEndPinned, shouldRestorePosition],
|
||||
() => (isFollowingEnd ? false : { data: true, size: true, shouldRestorePosition }),
|
||||
[isFollowingEnd, shouldRestorePosition],
|
||||
);
|
||||
// A new identity re-renders LegendList's scroll component, which is how the pin follows the mode.
|
||||
const renderScrollComponent = useCallback(
|
||||
@@ -442,11 +390,10 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
clearPendingUserScrollEnd();
|
||||
disposeHistoryStart();
|
||||
historyStartSettleSchedulerRef.current?.cancel();
|
||||
historyStartSettleSchedulerRef.current = null;
|
||||
};
|
||||
}, [clearPendingUserScrollEnd, disposeHistoryStart, evaluateHistoryStart]);
|
||||
}, [clearPendingUserScrollEnd, evaluateHistoryStart]);
|
||||
|
||||
useEffect(() => {
|
||||
const handle: StreamViewportHandle = {
|
||||
@@ -559,22 +506,22 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
});
|
||||
|
||||
const handleScrollBeginDrag = useStableEvent((event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
finishInitialFill();
|
||||
clearPendingUserScrollEnd();
|
||||
isUserScrollActiveRef.current = true;
|
||||
scrollKeyboardDismiss.onScrollBeginDrag(event);
|
||||
// The gesture takes the viewport from a disclosure hold. Size changes from a streaming row can
|
||||
// keep the hold's quiet frames from ever passing, and a held row that scrolls away would leave
|
||||
// no row LegendList may anchor a history page on. The gesture's end decides following.
|
||||
cancelDisclosureHold();
|
||||
disclosureHold.cancel();
|
||||
bottomAnchorController.beginUserScroll();
|
||||
historyStartPagination.rearm();
|
||||
const rearmed = rearmHistoryStartPagination(historyStartPaginationStateRef.current);
|
||||
if (rearmed !== historyStartPaginationStateRef.current) {
|
||||
historyStartPaginationStateRef.current = rearmed;
|
||||
setHistoryStartPaginationState(rearmed);
|
||||
evaluateHistoryStart();
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (revealInitialHistory) finishInitialFill();
|
||||
}, [finishInitialFill, revealInitialHistory]);
|
||||
|
||||
// A gesture ends at the end when the finger lifts at or past it, or when its momentum stops there.
|
||||
// A release past the end bounces back to it on iOS, and lines that land during the bounce scroll
|
||||
// in as they do while following. Momentum stopping short of the end, or output that arrived below
|
||||
@@ -642,7 +589,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
contentHeight: nextContentHeight,
|
||||
contentMeasuredForKey: isListPlacedRef.current ? "native-virtualized" : null,
|
||||
};
|
||||
endHold?.distance.noteContentSize(streamViewportMetricsRef.current);
|
||||
bottomAnchorController.handleContentSizeChange();
|
||||
// Content growing below a detached viewport moves the end away without a scroll event.
|
||||
if (
|
||||
@@ -652,7 +598,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
publishNearBottom();
|
||||
}
|
||||
evaluateHistoryStart();
|
||||
if (isHistoryStartSettling()) {
|
||||
if (historyStartPaginationStateRef.current.status === "settling") {
|
||||
scheduleHistoryStartSettle();
|
||||
}
|
||||
});
|
||||
@@ -665,23 +611,19 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
contentMeasuredForKey: "native-virtualized",
|
||||
};
|
||||
bottomAnchorController.handleContentSizeChange();
|
||||
evaluateHistoryStart();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
evaluateHistoryStart();
|
||||
if (isHistoryStartSettling()) {
|
||||
if (historyStartPaginationStateRef.current.status === "settling") {
|
||||
scheduleHistoryStartSettle();
|
||||
}
|
||||
}, [
|
||||
evaluateHistoryStart,
|
||||
hasOlderHistory,
|
||||
isActive,
|
||||
isAuthoritativeHistoryReady,
|
||||
isLoadingOlderHistory,
|
||||
olderHistoryProgressKey,
|
||||
scheduleHistoryStartSettle,
|
||||
isHistoryStartSettling,
|
||||
]);
|
||||
|
||||
// LegendList re-renders a mounted row only when its item or `extraData` changes, not when
|
||||
@@ -716,65 +658,52 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
const isLoadingOperation = isHistoryStartLoadingOperation(historyStartPaginationState);
|
||||
return (
|
||||
<View style={historyStartSlotStyle} testID="older-history-slot">
|
||||
{isLoadingOperation && (
|
||||
{isLoadingOperation ? (
|
||||
<View testID="load-older-history-spinner">
|
||||
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
|
||||
</View>
|
||||
)}
|
||||
{!isLoadingOperation &&
|
||||
historyStartPaginationState.status === "failed" &&
|
||||
hasOlderHistory && (
|
||||
<Button variant="ghost" size="sm" onPress={retryHistoryStart}>
|
||||
{t("paneFind.retry")}
|
||||
</Button>
|
||||
)}
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}, [historyStartPaginationState, hasOlderHistory, retryHistoryStart, t]);
|
||||
}, [historyStartPaginationState]);
|
||||
|
||||
return (
|
||||
<View style={viewportStyle}>
|
||||
<LegendList<StreamItem>
|
||||
ref={listRef}
|
||||
data={rows}
|
||||
renderItem={renderItem}
|
||||
extraData={renderHistoryMountedRow}
|
||||
keyExtractor={keyExtractor}
|
||||
testID="agent-chat-scroll"
|
||||
nativeID="agent-chat-scroll-native-virtualized"
|
||||
ListHeaderComponent={historyStartContent}
|
||||
ListFooterComponent={liveAuxiliaryContent ?? undefined}
|
||||
contentContainerStyle={listContentContainerStyle}
|
||||
automaticallyAdjustContentInsets={false}
|
||||
automaticallyAdjustsScrollIndicatorInsets={false}
|
||||
contentInsetAdjustmentBehavior="never"
|
||||
style={[listStyle, { opacity: historyStartPaginationState.visible ? 1 : 0 }]}
|
||||
onLayout={handleListLayout}
|
||||
onScroll={handleScroll}
|
||||
onScrollBeginDrag={handleScrollBeginDrag}
|
||||
onScrollEndDrag={handleScrollEndDrag}
|
||||
onMomentumScrollBegin={handleMomentumScrollBegin}
|
||||
onMomentumScrollEnd={handleMomentumScrollEnd}
|
||||
scrollEventThrottle={16}
|
||||
onContentSizeChange={handleContentSizeChange}
|
||||
onItemSizeChanged={handleItemSizeChanged}
|
||||
onReady={handleListPlaced}
|
||||
maintainVisibleContentPosition={maintainVisibleContentPosition}
|
||||
renderScrollComponent={renderScrollComponent}
|
||||
recycleItems={false}
|
||||
alignItemsAtEnd
|
||||
initialScrollAtEnd
|
||||
drawDistance={NATIVE_STREAM_DRAW_DISTANCE}
|
||||
getEstimatedItemSize={getEstimatedItemSize}
|
||||
scrollEnabled={scrollEnabled}
|
||||
showsVerticalScrollIndicator
|
||||
/>
|
||||
{!historyStartPaginationState.visible ? (
|
||||
<View pointerEvents="none" testID="agent-initial-fill" style={initialFillStyle}>
|
||||
<ThemedLoadingSpinner size="large" uniProps={foregroundMutedColorMapping} />
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
<LegendList<StreamItem>
|
||||
ref={listRef}
|
||||
data={rows}
|
||||
renderItem={renderItem}
|
||||
extraData={renderHistoryMountedRow}
|
||||
keyExtractor={keyExtractor}
|
||||
testID="agent-chat-scroll"
|
||||
// The Android scroll owner identifies the timeline to contain nested panel gestures.
|
||||
nativeID="agent-chat-scroll-native-virtualized"
|
||||
ListHeaderComponent={historyStartContent}
|
||||
ListFooterComponent={liveAuxiliaryContent ?? undefined}
|
||||
contentContainerStyle={listContentContainerStyle}
|
||||
automaticallyAdjustContentInsets={false}
|
||||
automaticallyAdjustsScrollIndicatorInsets={false}
|
||||
contentInsetAdjustmentBehavior="never"
|
||||
style={listStyle}
|
||||
onLayout={handleListLayout}
|
||||
onScroll={handleScroll}
|
||||
onScrollBeginDrag={handleScrollBeginDrag}
|
||||
onScrollEndDrag={handleScrollEndDrag}
|
||||
onMomentumScrollBegin={handleMomentumScrollBegin}
|
||||
onMomentumScrollEnd={handleMomentumScrollEnd}
|
||||
scrollEventThrottle={16}
|
||||
onContentSizeChange={handleContentSizeChange}
|
||||
onItemSizeChanged={handleItemSizeChanged}
|
||||
onReady={handleListPlaced}
|
||||
maintainVisibleContentPosition={maintainVisibleContentPosition}
|
||||
renderScrollComponent={renderScrollComponent}
|
||||
recycleItems={false}
|
||||
alignItemsAtEnd
|
||||
initialScrollAtEnd
|
||||
drawDistance={NATIVE_STREAM_DRAW_DISTANCE}
|
||||
getEstimatedItemSize={getEstimatedItemSize}
|
||||
scrollEnabled={scrollEnabled}
|
||||
showsVerticalScrollIndicator
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { DEFAULT_CONTENT_MAX_WIDTH } from "@/styles/theme";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import type { DisclosureTriggerEdge, StreamRenderInput, StreamViewportHandle } from "./strategy";
|
||||
import type { StreamRenderInput, StreamViewportHandle } from "./strategy";
|
||||
import { createWebStreamStrategy } from "./strategy-web";
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
@@ -67,7 +67,7 @@ function mountTimeline(historyMounted: StreamItem[]) {
|
||||
routeBottomAnchorRequest: null,
|
||||
isAuthoritativeHistoryReady: true,
|
||||
onNearBottomChange: () => {},
|
||||
onNearHistoryStart: async () => true,
|
||||
onNearHistoryStart: () => true,
|
||||
isLoadingOlderHistory: false,
|
||||
hasOlderHistory: false,
|
||||
olderHistoryProgressKey: null,
|
||||
@@ -91,15 +91,10 @@ function mountTimeline(historyMounted: StreamItem[]) {
|
||||
|
||||
return {
|
||||
render: (segments: Partial<StreamRenderInput["segments"]>) => act(() => render(segments)),
|
||||
hold: (rowId: string, apply: () => void, triggerEdge: DisclosureTriggerEdge = "top") =>
|
||||
act(() => viewportRef.current!.holdRow(rowId, apply, triggerEdge)),
|
||||
hold: (rowId: string, apply: () => void) =>
|
||||
act(() => viewportRef.current!.holdRow(rowId, apply)),
|
||||
rowTop: (id: string) =>
|
||||
rowElement(id).getBoundingClientRect().top - scrollContainer.getBoundingClientRect().top,
|
||||
rowBottom: (id: string) =>
|
||||
rowElement(id).getBoundingClientRect().bottom - scrollContainer.getBoundingClientRect().top,
|
||||
scrollTo: (top: number) => {
|
||||
scrollContainer.scrollTop = top;
|
||||
},
|
||||
distanceFromEnd: () =>
|
||||
scrollContainer.scrollHeight - scrollContainer.clientHeight - scrollContainer.scrollTop,
|
||||
// Resize observers and the hold's settle run on animation frames.
|
||||
@@ -144,53 +139,6 @@ describe("web disclosure hold", () => {
|
||||
expect(timeline.rowTop("message-5")).toBe(triggerTop);
|
||||
});
|
||||
|
||||
// A long message's show more sits under the text it opens, on the bottom edge of its row.
|
||||
it("keeps the bottom of a bottom-trigger row through show more and show less while following output", async () => {
|
||||
const history = [1, 2, 3, 4, 5, 6].map((index) => row(index));
|
||||
const timeline = mountTimeline(history);
|
||||
await timeline.settle();
|
||||
const triggerBottom = timeline.rowBottom("message-5");
|
||||
|
||||
timeline.hold(
|
||||
"message-5",
|
||||
() => timeline.render({ historyMounted: [...history.slice(0, 4), row(5, 240), history[5]!] }),
|
||||
"bottom",
|
||||
);
|
||||
await timeline.settle();
|
||||
expect(timeline.rowBottom("message-5")).toBe(triggerBottom);
|
||||
|
||||
timeline.hold("message-5", () => timeline.render({ historyMounted: history }), "bottom");
|
||||
await timeline.settle();
|
||||
expect(timeline.rowBottom("message-5")).toBe(triggerBottom);
|
||||
});
|
||||
|
||||
it("keeps the bottom of a bottom-trigger row through show more and show less when scrolled up", async () => {
|
||||
const history = [1, 2, 3, 4, 5, 6].map((index) => row(index));
|
||||
const timeline = mountTimeline(history);
|
||||
await timeline.settle();
|
||||
timeline.scrollTo(60);
|
||||
await timeline.settle();
|
||||
// message-2 is cut off at the top; message-3 is fully in view.
|
||||
for (const index of [2, 3]) {
|
||||
const triggerBottom = timeline.rowBottom(`message-${index}`);
|
||||
const grown = history.map((item, position) =>
|
||||
position === index - 1 ? row(index, 240) : item,
|
||||
);
|
||||
|
||||
timeline.hold(`message-${index}`, () => timeline.render({ historyMounted: grown }), "bottom");
|
||||
await timeline.settle();
|
||||
expect(timeline.rowBottom(`message-${index}`)).toBe(triggerBottom);
|
||||
|
||||
timeline.hold(
|
||||
`message-${index}`,
|
||||
() => timeline.render({ historyMounted: history }),
|
||||
"bottom",
|
||||
);
|
||||
await timeline.settle();
|
||||
expect(timeline.rowBottom(`message-${index}`)).toBe(triggerBottom);
|
||||
}
|
||||
});
|
||||
|
||||
it("resumes following when collapsing an anchored row lands back at the end", async () => {
|
||||
const history = [1, 2, 3, 4, 5, 6].map((index) => row(index));
|
||||
const timeline = mountTimeline(history);
|
||||
|
||||
@@ -152,7 +152,7 @@ describe("createWebStreamStrategy", () => {
|
||||
routeBottomAnchorRequest: null,
|
||||
isAuthoritativeHistoryReady: true,
|
||||
onNearBottomChange: () => {},
|
||||
onNearHistoryStart: async () => true,
|
||||
onNearHistoryStart: () => true,
|
||||
isLoadingOlderHistory: false,
|
||||
hasOlderHistory: false,
|
||||
olderHistoryProgressKey: null,
|
||||
@@ -274,7 +274,7 @@ describe("createWebStreamStrategy", () => {
|
||||
routeBottomAnchorRequest: null,
|
||||
isAuthoritativeHistoryReady: true,
|
||||
onNearBottomChange: () => {},
|
||||
onNearHistoryStart: async () => true,
|
||||
onNearHistoryStart: () => true,
|
||||
isLoadingOlderHistory: false,
|
||||
hasOlderHistory: false,
|
||||
olderHistoryProgressKey: null,
|
||||
|
||||
@@ -20,7 +20,6 @@ import { withUnistyles } from "react-native-unistyles";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getStreamItemMessageId, type StreamItem } from "@/types/stream";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { WEB_SCROLLBAR_SIZE_PX } from "@/styles/web-scrollbar";
|
||||
@@ -31,10 +30,15 @@ import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./
|
||||
import { useRevisedHistoryRows } from "./history-row-revision";
|
||||
import { createStreamStrategy } from "./strategy";
|
||||
import {
|
||||
abandonHistoryStartPaginationRequest,
|
||||
createHistoryStartPaginationState,
|
||||
evaluateHistoryStartPagination,
|
||||
isHistoryStartLoadingOperation,
|
||||
rearmHistoryStartPagination,
|
||||
settleHistoryStartPagination,
|
||||
type HistoryStartPaginationInput,
|
||||
type HistoryStartPaginationTransition,
|
||||
} from "./history-start-pagination";
|
||||
import { useHistoryStartPagination } from "./use-history-start-pagination";
|
||||
import {
|
||||
createHistoryStartSettleScheduler,
|
||||
type HistoryStartSettleScheduler,
|
||||
@@ -73,17 +77,7 @@ const historyStartSlotStyle: CSSProperties = {
|
||||
};
|
||||
|
||||
const streamRowsHostStyle: CSSProperties = { position: "relative", width: "100%" };
|
||||
const initialFillStyle: CSSProperties = {
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
pointerEvents: "none",
|
||||
};
|
||||
|
||||
// Rows and the live auxiliary (turn footer, pending permissions) center their content with
|
||||
// `alignSelf`, which only applies inside a flex container.
|
||||
const streamRowStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
@@ -304,8 +298,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
viewportRef,
|
||||
routeBottomAnchorRequest,
|
||||
isAuthoritativeHistoryReady,
|
||||
isInitialHistoryLoading,
|
||||
revealInitialHistory,
|
||||
onNearBottomChange,
|
||||
onReadingPositionChange,
|
||||
onNearHistoryStart,
|
||||
@@ -326,9 +318,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
[historyMounted, historyVirtualized, inputSegments],
|
||||
);
|
||||
const isActive = useRetainedPanelActive();
|
||||
const { t } = useTranslation();
|
||||
const rowsHostRef = useRef<HTMLDivElement | null>(null);
|
||||
const auxiliaryRef = useRef<HTMLDivElement | null>(null);
|
||||
const isActiveRef = useRef(isActive);
|
||||
const scrollContainerRef = useRef<HTMLElement | null>(null);
|
||||
const contentRef = useRef<HTMLElement | null>(null);
|
||||
@@ -362,6 +351,10 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
const pendingAutoScrollFrameRef = useRef<number | null>(null);
|
||||
const pendingAutoScrollTimeoutRef = useRef<number | null>(null);
|
||||
const historyStartReadyRef = useRef(false);
|
||||
const [historyStartPaginationState, setHistoryStartPaginationState] = useState(
|
||||
createHistoryStartPaginationState,
|
||||
);
|
||||
const historyStartPaginationStateRef = useRef(historyStartPaginationState);
|
||||
const readingAnchor = useMemo(createReadingAnchor, []);
|
||||
const measuredRowHeights = useRef(new Map<string, { width: number; height: number }>());
|
||||
const historyStartSettleSchedulerRef = useRef<HistoryStartSettleScheduler | null>(null);
|
||||
@@ -418,7 +411,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
{
|
||||
id: String(measurement.key),
|
||||
top: measurement.start,
|
||||
height: measurement.size,
|
||||
},
|
||||
props.readingRowReplacements,
|
||||
)
|
||||
@@ -489,36 +481,60 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
!followOutputRef.current || isScrollContainerNearBottom(scrollContainer);
|
||||
return {
|
||||
distanceFromHistoryStart: scrollContainer.scrollTop,
|
||||
isUnderfilled:
|
||||
(rowsHostRef.current?.getBoundingClientRect().height ?? 0) +
|
||||
(auxiliaryRef.current?.getBoundingClientRect().height ?? 0) <
|
||||
scrollContainer.clientHeight,
|
||||
hasOlderHistory,
|
||||
isLoadingOlderHistory,
|
||||
// An explicit jump owns the landing position. Revealing another local
|
||||
// page here changes the virtual index before the target has mounted.
|
||||
isReady:
|
||||
historyStartReadyRef.current &&
|
||||
isAuthoritativeHistoryReady &&
|
||||
bottomAnchorSettled &&
|
||||
!isJumpSettling(),
|
||||
isReady: historyStartReadyRef.current && bottomAnchorSettled && !isJumpSettling(),
|
||||
progressKey: olderHistoryProgressKey,
|
||||
};
|
||||
});
|
||||
const { state: historyStartPaginationState, controller: historyStartPagination } =
|
||||
useHistoryStartPagination({
|
||||
initiallyVisible: !isInitialHistoryLoading,
|
||||
read: getHistoryStartPaginationInput,
|
||||
load: async () => onNearHistoryStart(),
|
||||
scheduleSettle: () => scheduleHistoryStartPrependSettle(),
|
||||
});
|
||||
const evaluateHistoryStart = useStableEvent(() => historyStartPagination.update());
|
||||
const disposeHistoryStart = useStableEvent(() => historyStartPagination.dispose());
|
||||
const isHistoryStartSettling = useStableEvent(
|
||||
() => historyStartPagination.state.status === "settling",
|
||||
const applyHistoryStartPaginationTransition = useStableEvent(
|
||||
(transition: HistoryStartPaginationTransition) => {
|
||||
const previousState = historyStartPaginationStateRef.current;
|
||||
historyStartPaginationStateRef.current = transition.state;
|
||||
if (transition.state !== previousState) {
|
||||
setHistoryStartPaginationState(transition.state);
|
||||
}
|
||||
if (!transition.shouldLoad || olderHistoryProgressKey === null) {
|
||||
return;
|
||||
}
|
||||
const requestedProgressKey = olderHistoryProgressKey;
|
||||
void (async () => {
|
||||
const started = await onNearHistoryStart();
|
||||
if (started === true) {
|
||||
return;
|
||||
}
|
||||
applyHistoryStartPaginationTransition({
|
||||
state: abandonHistoryStartPaginationRequest(
|
||||
historyStartPaginationStateRef.current,
|
||||
requestedProgressKey,
|
||||
),
|
||||
shouldLoad: false,
|
||||
});
|
||||
})();
|
||||
},
|
||||
);
|
||||
const finishInitialFill = useStableEvent(() => historyStartPagination.reveal());
|
||||
const rearmHistoryStartFromUserIntent = useStableEvent(() => historyStartPagination.rearm());
|
||||
const evaluateHistoryStart = useStableEvent(() => {
|
||||
const input = getHistoryStartPaginationInput();
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
const transition = evaluateHistoryStartPagination(
|
||||
historyStartPaginationStateRef.current,
|
||||
input,
|
||||
);
|
||||
applyHistoryStartPaginationTransition(transition);
|
||||
});
|
||||
const rearmHistoryStartFromUserIntent = useStableEvent(() => {
|
||||
const rearmed = rearmHistoryStartPagination(historyStartPaginationStateRef.current);
|
||||
if (rearmed === historyStartPaginationStateRef.current) {
|
||||
return;
|
||||
}
|
||||
historyStartPaginationStateRef.current = rearmed;
|
||||
setHistoryStartPaginationState(rearmed);
|
||||
evaluateHistoryStart();
|
||||
});
|
||||
const scheduleHistoryStartPrependSettle = useStableEvent(() => {
|
||||
let scheduler = historyStartSettleSchedulerRef.current;
|
||||
if (!scheduler) {
|
||||
@@ -526,12 +542,22 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
settleFrames: HISTORY_START_SETTLE_FRAMES,
|
||||
requestFrame: (callback) => window.requestAnimationFrame(callback),
|
||||
cancelFrame: (frame) => window.cancelAnimationFrame(frame),
|
||||
isSettling: () => isHistoryStartSettling(),
|
||||
isSettling: () => historyStartPaginationStateRef.current.status === "settling",
|
||||
isLoading: () => {
|
||||
const input = getHistoryStartPaginationInput();
|
||||
return !input || input.isLoadingOlderHistory;
|
||||
},
|
||||
onSettle: () => historyStartPagination.settle(),
|
||||
onSettle: () => {
|
||||
const input = getHistoryStartPaginationInput();
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
const transition = settleHistoryStartPagination(
|
||||
historyStartPaginationStateRef.current,
|
||||
input,
|
||||
);
|
||||
applyHistoryStartPaginationTransition(transition);
|
||||
},
|
||||
});
|
||||
historyStartSettleSchedulerRef.current = scheduler;
|
||||
}
|
||||
@@ -768,7 +794,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
]);
|
||||
|
||||
const stopFollowingOutputFromUserIntent = useStableEvent(() => {
|
||||
finishInitialFill();
|
||||
cancelPendingStickToBottom();
|
||||
if (followOutputRef.current) {
|
||||
setFollowOutput(false);
|
||||
@@ -776,10 +801,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
rearmHistoryStartFromUserIntent();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (revealInitialHistory) finishInitialFill();
|
||||
}, [finishInitialFill, revealInitialHistory]);
|
||||
|
||||
const handleDomScroll = useCallback(() => {
|
||||
const scrollContainer = scrollContainerRef.current;
|
||||
if (!isActiveRef.current || !scrollContainer || !isScrollContainerMeasurable(scrollContainer)) {
|
||||
@@ -825,11 +846,10 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frame);
|
||||
historyStartReadyRef.current = false;
|
||||
disposeHistoryStart();
|
||||
historyStartSettleSchedulerRef.current?.cancel();
|
||||
historyStartSettleSchedulerRef.current = null;
|
||||
};
|
||||
}, [disposeHistoryStart, evaluateHistoryStart]);
|
||||
}, [evaluateHistoryStart]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isActiveRef.current || !isActivationReady) {
|
||||
@@ -918,18 +938,16 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
updateScrollMetrics();
|
||||
}
|
||||
evaluateHistoryStart();
|
||||
if (isHistoryStartSettling()) {
|
||||
if (historyStartPaginationStateRef.current.status === "settling") {
|
||||
scheduleHistoryStartPrependSettle();
|
||||
}
|
||||
}, [
|
||||
evaluateHistoryStart,
|
||||
hasOlderHistory,
|
||||
isActive,
|
||||
isAuthoritativeHistoryReady,
|
||||
isLoadingOlderHistory,
|
||||
olderHistoryProgressKey,
|
||||
scheduleHistoryStartPrependSettle,
|
||||
isHistoryStartSettling,
|
||||
segments.historyMounted.length,
|
||||
segments.historyVirtualized.length,
|
||||
segments.liveHead.length,
|
||||
@@ -968,7 +986,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
lastObservedViewportGeometryRef.current = nextGeometry;
|
||||
}
|
||||
reconcileReadingPosition();
|
||||
if (isHistoryStartSettling()) {
|
||||
if (historyStartPaginationStateRef.current.status === "settling") {
|
||||
scheduleHistoryStartPrependSettle();
|
||||
}
|
||||
updateScrollMetrics();
|
||||
@@ -1002,7 +1020,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
isActive,
|
||||
reportReadingPosition,
|
||||
scheduleHistoryStartPrependSettle,
|
||||
isHistoryStartSettling,
|
||||
forceStickToBottom,
|
||||
updateScrollMetrics,
|
||||
measureVirtualizedRows,
|
||||
@@ -1173,12 +1190,11 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
}
|
||||
scheduleStickToBottom();
|
||||
},
|
||||
holdRow: (rowId, apply, triggerEdge) => {
|
||||
holdRow: (rowId, apply) => {
|
||||
cancelPendingStickToBottom();
|
||||
setFollowOutput(false);
|
||||
const row = measureRows()?.find((candidate) => candidate.id === rowId);
|
||||
const scrollTop = scrollContainerRef.current?.scrollTop;
|
||||
if (row && scrollTop !== undefined) readingAnchor.pin(row, triggerEdge, scrollTop);
|
||||
if (row) readingAnchor.pin(row);
|
||||
disclosurePendingRef.current = true;
|
||||
apply();
|
||||
},
|
||||
@@ -1225,9 +1241,8 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
// Detached reading has one owner: reconcileReadingPosition.
|
||||
overflowAnchor: followOutput ? "auto" : "none",
|
||||
scrollbarWidth: overlayScrollbarEnabled ? "none" : undefined,
|
||||
opacity: historyStartPaginationState.visible ? 1 : 0,
|
||||
};
|
||||
}, [followOutput, historyStartPaginationState.visible, isMobileBreakpoint, scrollEnabled]);
|
||||
}, [followOutput, isMobileBreakpoint, scrollEnabled]);
|
||||
const viewportStyle = useMemo(
|
||||
(): CSSProperties => ({
|
||||
position: "relative",
|
||||
@@ -1304,21 +1319,14 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
const isLoadingOperation = isHistoryStartLoadingOperation(historyStartPaginationState);
|
||||
return (
|
||||
<div style={historyStartSlotStyle} data-testid="older-history-slot">
|
||||
{isLoadingOperation && (
|
||||
{isLoadingOperation ? (
|
||||
<div data-testid="load-older-history-spinner">
|
||||
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
|
||||
</div>
|
||||
)}
|
||||
{!isLoadingOperation &&
|
||||
historyStartPaginationState.status === "failed" &&
|
||||
hasOlderHistory && (
|
||||
<button type="button" onClick={rearmHistoryStartFromUserIntent}>
|
||||
{t("paneFind.retry")}
|
||||
</button>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}, [historyStartPaginationState, hasOlderHistory, rearmHistoryStartFromUserIntent, t]);
|
||||
}, [historyStartPaginationState]);
|
||||
const shouldRenderEmpty =
|
||||
!boundary.hasMountedHistory &&
|
||||
!boundary.hasVirtualizedHistory &&
|
||||
@@ -1338,7 +1346,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
>
|
||||
<div ref={handleContentRef} style={contentContainerStyle}>
|
||||
{historyStartSlot}
|
||||
<div ref={rowsHostRef} style={streamRowsHostStyle}>
|
||||
<div style={streamRowsHostStyle}>
|
||||
<div style={virtualRowsContainerStyle} />
|
||||
{[
|
||||
...virtualRows.map((virtualRow) => {
|
||||
@@ -1366,17 +1374,10 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
...mountedRows,
|
||||
]}
|
||||
</div>
|
||||
<div ref={auxiliaryRef} style={streamRowStyle}>
|
||||
{liveAuxiliary}
|
||||
</div>
|
||||
{liveAuxiliary}
|
||||
{shouldRenderEmpty ? listEmptyComponent : null}
|
||||
</div>
|
||||
</div>
|
||||
{!historyStartPaginationState.visible ? (
|
||||
<div data-testid="agent-initial-fill" style={initialFillStyle}>
|
||||
<ThemedLoadingSpinner size="large" uniProps={foregroundMutedColorMapping} />
|
||||
</div>
|
||||
) : null}
|
||||
{scrollEnabled && !isMobileBreakpoint ? (
|
||||
<DomOverlayScrollbar
|
||||
scrollContainerRef={scrollContainerRef}
|
||||
|
||||
@@ -46,23 +46,16 @@ export interface ScrollToMessageOccurrence {
|
||||
targetTop(): number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The edge of its row a disclosure's trigger sits on. A header trigger sits on the top: the content
|
||||
* it opens grows below it. A trigger under the content it opens, such as a long message's show
|
||||
* more, sits on the bottom.
|
||||
*/
|
||||
export type DisclosureTriggerEdge = "top" | "bottom";
|
||||
|
||||
export interface StreamViewportHandle {
|
||||
/** Give keyboard navigation back to the scrolling surface on web. */
|
||||
focus?: () => void;
|
||||
scrollToBottom: (reason?: BottomAnchorLocalRequest["reason"]) => void;
|
||||
prepareForViewportChange: () => void;
|
||||
/**
|
||||
* Run a disclosure's state change so the row that owns its trigger keeps the edge the trigger
|
||||
* sits on in place. Following output stops; it resumes if the change leaves the list at its end.
|
||||
* Run a disclosure's state change so the row that owns its trigger keeps its visual top.
|
||||
* Following output stops; it resumes if the change leaves the list at its end.
|
||||
*/
|
||||
holdRow: (rowId: string, apply: () => void, triggerEdge: DisclosureTriggerEdge) => void;
|
||||
holdRow: (rowId: string, apply: () => void) => void;
|
||||
scrollToMessage?: (messageId: string, occurrence?: ScrollToMessageOccurrence) => void;
|
||||
}
|
||||
|
||||
@@ -92,13 +85,11 @@ export interface StreamRenderInput {
|
||||
viewportRef: RefObject<StreamViewportHandle | null>;
|
||||
routeBottomAnchorRequest: BottomAnchorRouteRequest | null;
|
||||
isAuthoritativeHistoryReady: boolean;
|
||||
isInitialHistoryLoading?: boolean;
|
||||
revealInitialHistory?: boolean;
|
||||
onNearBottomChange: (value: boolean) => void;
|
||||
// The history row under the top of the viewport, for surfaces that mark where the reader
|
||||
// is in the transcript. Only the web viewport measures it today.
|
||||
onReadingPositionChange?: (rowId: string | null) => void;
|
||||
onNearHistoryStart: () => Promise<boolean>;
|
||||
onNearHistoryStart: () => boolean | Promise<boolean>;
|
||||
isLoadingOlderHistory: boolean;
|
||||
hasOlderHistory: boolean;
|
||||
olderHistoryProgressKey: string | null;
|
||||
|
||||
@@ -12,21 +12,6 @@ function reply(id: string): StreamItem {
|
||||
return { kind: "assistant_message", id, text: id, timestamp: at };
|
||||
}
|
||||
|
||||
function pluginReply(sourceId: string, part = "0"): StreamItem {
|
||||
return {
|
||||
kind: "plugin",
|
||||
id: `math/${sourceId}/${part}`,
|
||||
sourceId,
|
||||
sourceKind: "assistant_message",
|
||||
timestamp: at,
|
||||
pluginId: "math",
|
||||
pluginItemId: `${sourceId}/${part}`,
|
||||
itemKind: "formula",
|
||||
version: 1,
|
||||
data: { text: "$$x^2$$" },
|
||||
};
|
||||
}
|
||||
|
||||
function thought(id: string): StreamItem {
|
||||
return { kind: "thought", id, text: id, timestamp: at, status: "ready" };
|
||||
}
|
||||
@@ -56,7 +41,7 @@ function collapse(tail: StreamItem[], options: Partial<CollapseTurnsInput> = {})
|
||||
isLatestTurnOpen: false,
|
||||
expandedHostIds: new Set(),
|
||||
getGroupedCalls: () => undefined,
|
||||
previousSummaryRowsByHostId: new Map(),
|
||||
previousTurnsByHostId: new Map(),
|
||||
...options,
|
||||
});
|
||||
}
|
||||
@@ -64,91 +49,38 @@ function collapse(tail: StreamItem[], options: Partial<CollapseTurnsInput> = {})
|
||||
const ids = (items: StreamItem[]) => items.map((item) => item.id);
|
||||
|
||||
describe("collapseTurns", () => {
|
||||
it("keeps every card of the final reply outside the folded turn", () => {
|
||||
const items = [
|
||||
user("prompt"),
|
||||
reply("acknowledgment"),
|
||||
shell("call"),
|
||||
pluginReply("final", "left"),
|
||||
pluginReply("final", "right"),
|
||||
];
|
||||
const result = collapse(items);
|
||||
expect(ids(result.tail)).toEqual([
|
||||
"prompt",
|
||||
"turn-summary:prompt",
|
||||
"math/final/left",
|
||||
"math/final/right",
|
||||
]);
|
||||
expect(result.hostIdByFoldedMessageId.get("acknowledgment")).toBe("prompt");
|
||||
expect(result.hostIdByFoldedMessageId.has("final")).toBe(false);
|
||||
const expanded = collapse(items, { expandedHostIds: new Set(["prompt"]) });
|
||||
expect(ids(expanded.tail)).toEqual([
|
||||
"prompt",
|
||||
"turn-summary:prompt",
|
||||
"acknowledgment",
|
||||
"call",
|
||||
"math/final/left",
|
||||
"math/final/right",
|
||||
]);
|
||||
expect([...expanded.nestedItemIds]).toEqual(["acknowledgment", "call"]);
|
||||
});
|
||||
|
||||
it("folds intermediate plugin replies even without tool calls", () => {
|
||||
const result = collapse([user("prompt"), pluginReply("intermediate"), pluginReply("final")]);
|
||||
expect(ids(result.tail)).toEqual(["prompt", "turn-summary:prompt", "math/final/0"]);
|
||||
expect(result.hostIdByFoldedMessageId.get("intermediate")).toBe("prompt");
|
||||
});
|
||||
|
||||
it.each([undefined, "thought", "tool_call"] as const)(
|
||||
"does not treat a plugin row with source kind %s as a reply",
|
||||
(sourceKind) => {
|
||||
const card = { ...pluginReply("card"), sourceKind };
|
||||
const items = [user("prompt"), shell("call"), card];
|
||||
expect(collapse(items).tail).toEqual(items);
|
||||
expect(collapse(items).summaryRowsByHostId.size).toBe(0);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps a plugin-rendered reply and its work unfolded while the turn is open", () => {
|
||||
const items = [user("prompt"), shell("call"), pluginReply("final")];
|
||||
expect(collapse(items, { isLatestTurnOpen: true }).tail).toEqual(items);
|
||||
});
|
||||
|
||||
// A mounted row renders again only when its item changes, so a turn whose work and open state
|
||||
// are unchanged keeps its summary row object across folds of the same history.
|
||||
it("keeps a summary row until its turn's work or open state changes", () => {
|
||||
const first = collapse([user("u1"), shell("c1"), reply("r1"), user("u2"), shell("c2")], {
|
||||
// A history row renders again only when its item changes, so a prompt that is already retained
|
||||
// when its turn closes needs its row revised to show the summary line.
|
||||
it("reports the hosts whose summary line appeared, changed or went away", () => {
|
||||
const open = collapse([user("u1"), shell("c1"), reply("r1"), user("u2"), shell("c2")], {
|
||||
isLatestTurnOpen: true,
|
||||
});
|
||||
expect([...open.updatedHostIds]).toEqual(["u1"]);
|
||||
const closed = collapse(
|
||||
[user("u1"), shell("c1"), reply("r1"), user("u2"), shell("c2"), reply("r2")],
|
||||
{ previousSummaryRowsByHostId: first.summaryRowsByHostId },
|
||||
{
|
||||
previousTurnsByHostId: open.turnsByHostId,
|
||||
},
|
||||
);
|
||||
expect(closed.summaryRowsByHostId.get("u1")).toBe(first.summaryRowsByHostId.get("u1"));
|
||||
expect([...closed.updatedHostIds]).toEqual(["u2"]);
|
||||
const unchanged = collapse(
|
||||
[user("u1"), shell("c1"), reply("r1"), user("u2"), shell("c2"), reply("r2")],
|
||||
{
|
||||
previousTurnsByHostId: closed.turnsByHostId,
|
||||
},
|
||||
);
|
||||
expect(unchanged.updatedHostIds.size).toBe(0);
|
||||
const moreWork = collapse(
|
||||
[user("u1"), shell("c1"), reply("r1"), user("u2"), shell("c2"), shell("c3"), reply("r2")],
|
||||
{ previousSummaryRowsByHostId: closed.summaryRowsByHostId },
|
||||
{ previousTurnsByHostId: unchanged.turnsByHostId },
|
||||
);
|
||||
expect(moreWork.summaryRowsByHostId.get("u1")).toBe(closed.summaryRowsByHostId.get("u1"));
|
||||
expect(moreWork.summaryRowsByHostId.get("u2")).not.toBe(closed.summaryRowsByHostId.get("u2"));
|
||||
const opened = collapse(moreWork.tail, {
|
||||
previousSummaryRowsByHostId: moreWork.summaryRowsByHostId,
|
||||
expandedHostIds: new Set(["u1"]),
|
||||
expect([...moreWork.updatedHostIds]).toEqual(["u2"]);
|
||||
const rewound = collapse([user("u1"), shell("c1"), reply("r1")], {
|
||||
previousTurnsByHostId: moreWork.turnsByHostId,
|
||||
});
|
||||
expect(opened.summaryRowsByHostId.get("u1")).not.toBe(moreWork.summaryRowsByHostId.get("u1"));
|
||||
expect([...rewound.updatedHostIds]).toEqual(["u2"]);
|
||||
});
|
||||
it("stands at its host's timeline position, taking a new row when that position lands", () => {
|
||||
const live = collapse([user("u1"), shell("c1"), reply("r1")]);
|
||||
expect(live.summaryRowsByHostId.get("u1")).not.toHaveProperty("timelineCursor");
|
||||
const cursor = { epoch: "epoch-1", seq: 7 };
|
||||
const placed = [{ ...user("u1"), timelineCursor: cursor }, shell("c1"), reply("r1")];
|
||||
const canonical = collapse(placed, { previousSummaryRowsByHostId: live.summaryRowsByHostId });
|
||||
expect(canonical.summaryRowsByHostId.get("u1")).not.toBe(live.summaryRowsByHostId.get("u1"));
|
||||
expect(canonical.summaryRowsByHostId.get("u1")?.timelineCursor).toEqual(cursor);
|
||||
const again = collapse(placed, { previousSummaryRowsByHostId: canonical.summaryRowsByHostId });
|
||||
expect(again.summaryRowsByHostId.get("u1")).toBe(canonical.summaryRowsByHostId.get("u1"));
|
||||
});
|
||||
it("maps hidden rows to their turn's host only while the turn is closed", () => {
|
||||
it("maps hidden rows only while their turn is closed", () => {
|
||||
const result = collapse([user("u1"), shell("c1"), reply("final")]);
|
||||
expect(result.hostIdByHiddenRowId.get("c1")).toBe("u1");
|
||||
const expanded = collapse([user("u1"), shell("c1"), reply("final")], {
|
||||
@@ -183,15 +115,15 @@ describe("collapseTurns", () => {
|
||||
reply("done"),
|
||||
].map((item) => Object.assign(item, { turnId: "same-run" }));
|
||||
const result = collapse(items);
|
||||
expect(ids(result.tail)).toEqual(["u1", "turn-summary:u1", "finished", "compaction", "done"]);
|
||||
expect(result.summaryRowsByHostId.get("u1")?.tools).toMatchObject({
|
||||
expect(ids(result.tail)).toEqual(["u1", "finished", "compaction", "done"]);
|
||||
expect(result.turnsByHostId.get("u1")?.summary.tools).toMatchObject({
|
||||
commandCount: 1,
|
||||
otherToolCount: 0,
|
||||
paseoActions: { "Created agent": 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it("folds a finished turn's work into a summary row under its user message and keeps the reply", () => {
|
||||
it("folds a finished turn's work into its user message and keeps the reply", () => {
|
||||
const result = collapse([
|
||||
user("u1"),
|
||||
thought("t1"),
|
||||
@@ -201,16 +133,14 @@ describe("collapseTurns", () => {
|
||||
reply("final"),
|
||||
]);
|
||||
|
||||
expect(ids(result.tail)).toEqual(["u1", "turn-summary:u1", "final"]);
|
||||
expect(result.tail[1]).toEqual({
|
||||
kind: "turn_summary",
|
||||
id: "turn-summary:u1",
|
||||
expect(ids(result.tail)).toEqual(["u1", "final"]);
|
||||
expect(result.turnsByHostId.get("u1")).toEqual({
|
||||
hostId: "u1",
|
||||
timestamp: at,
|
||||
expanded: false,
|
||||
tools: expect.objectContaining({ commandCount: 2 }),
|
||||
summary: {
|
||||
tools: expect.objectContaining({ commandCount: 2 }),
|
||||
},
|
||||
});
|
||||
expect(result.summaryRowsByHostId.get("u1")).toBe(result.tail[1]);
|
||||
expect(result.hostIdByFoldedMessageId.get("early")).toBe("u1");
|
||||
});
|
||||
|
||||
@@ -219,9 +149,9 @@ describe("collapseTurns", () => {
|
||||
expandedHostIds: new Set(["u1"]),
|
||||
});
|
||||
|
||||
expect(ids(result.tail)).toEqual(["u1", "turn-summary:u1", "t1", "c1", "final"]);
|
||||
expect(ids(result.tail)).toEqual(["u1", "t1", "c1", "final"]);
|
||||
expect([...result.nestedItemIds]).toEqual(["t1", "c1"]);
|
||||
expect(result.summaryRowsByHostId.get("u1")?.expanded).toBe(true);
|
||||
expect(result.turnsByHostId.get("u1")?.expanded).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves the latest turn alone while it is still producing output", () => {
|
||||
@@ -229,8 +159,8 @@ describe("collapseTurns", () => {
|
||||
|
||||
const result = collapse(tail, { isLatestTurnOpen: true });
|
||||
|
||||
expect(ids(result.tail)).toEqual(["u1", "turn-summary:u1", "r1", "u2", "c2", "r2"]);
|
||||
expect([...result.summaryRowsByHostId.keys()]).toEqual(["u1"]);
|
||||
expect(ids(result.tail)).toEqual(["u1", "r1", "u2", "c2", "r2"]);
|
||||
expect([...result.turnsByHostId.keys()]).toEqual(["u1"]);
|
||||
});
|
||||
|
||||
it("keeps rows after the reply, and turns with no work before the reply, as they are", () => {
|
||||
@@ -243,8 +173,8 @@ describe("collapseTurns", () => {
|
||||
reply("r2"),
|
||||
]);
|
||||
|
||||
expect(ids(result.tail)).toEqual(["u1", "turn-summary:u1", "r1", "after", "u2", "r2"]);
|
||||
expect([...result.summaryRowsByHostId.keys()]).toEqual(["u1"]);
|
||||
expect(ids(result.tail)).toEqual(["u1", "r1", "after", "u2", "r2"]);
|
||||
expect([...result.turnsByHostId.keys()]).toEqual(["u1"]);
|
||||
});
|
||||
|
||||
it("does not fold a turn that only thought before its reply", () => {
|
||||
@@ -253,7 +183,7 @@ describe("collapseTurns", () => {
|
||||
const result = collapse(tail);
|
||||
|
||||
expect(result.tail).toBe(tail);
|
||||
expect(result.summaryRowsByHostId.size).toBe(0);
|
||||
expect(result.turnsByHostId.size).toBe(0);
|
||||
});
|
||||
|
||||
it("does not fold a turn without a reply or whose prompt is above the loaded history", () => {
|
||||
@@ -262,7 +192,7 @@ describe("collapseTurns", () => {
|
||||
const result = collapse(tail);
|
||||
|
||||
expect(result.tail).toBe(tail);
|
||||
expect(result.summaryRowsByHostId.size).toBe(0);
|
||||
expect(result.turnsByHostId.size).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps a completed response folded when a promptless canonical turn starts", () => {
|
||||
@@ -271,15 +201,15 @@ describe("collapseTurns", () => {
|
||||
);
|
||||
const next = { ...shell("heartbeat"), turnId: "second" };
|
||||
const result = collapse([...first, next], { isLatestTurnOpen: true });
|
||||
expect(ids(result.tail)).toEqual(["u1", "turn-summary:u1", "r1", "heartbeat"]);
|
||||
expect(result.tail[1]).toMatchObject({ kind: "turn_summary", turnId: "first" });
|
||||
expect(ids(result.tail)).toEqual(["u1", "r1", "heartbeat"]);
|
||||
expect(result.turnsByHostId.has("u1")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps mid-turn messages visible when work is folded", () => {
|
||||
const tail = [user("prompt"), shell("work"), user("steer"), shell("more"), reply("done")];
|
||||
for (const item of tail) item.turnId = "same-turn";
|
||||
const result = collapse(tail);
|
||||
expect(ids(result.tail)).toEqual(["prompt", "turn-summary:prompt", "steer", "done"]);
|
||||
expect(ids(result.tail)).toEqual(["prompt", "steer", "done"]);
|
||||
expect(result.hostIdByFoldedMessageId.has("steer")).toBe(false);
|
||||
});
|
||||
|
||||
@@ -289,7 +219,7 @@ describe("collapseTurns", () => {
|
||||
expandedHostIds: new Set(["prompt"]),
|
||||
getGroupedCalls: (id) => (id === "one" ? calls : undefined),
|
||||
});
|
||||
expect(ids(result.tail)).toEqual(["prompt", "turn-summary:prompt", "one", "two", "done"]);
|
||||
expect(ids(result.tail)).toEqual(["prompt", "one", "two", "done"]);
|
||||
expect([...result.nestedItemIds]).toEqual(["one", "two"]);
|
||||
});
|
||||
|
||||
@@ -299,6 +229,8 @@ describe("collapseTurns", () => {
|
||||
getGroupedCalls: (itemId) => (itemId === "c1" ? group : undefined),
|
||||
});
|
||||
|
||||
expect(result.summaryRowsByHostId.get("u1")?.tools).toMatchObject({ commandCount: 3 });
|
||||
expect(result.turnsByHostId.get("u1")?.summary).toMatchObject({
|
||||
tools: { commandCount: 3 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,25 +3,35 @@ import {
|
||||
getStreamItemMessageId,
|
||||
type StreamItem,
|
||||
type ThoughtItem,
|
||||
type TimelinePosition,
|
||||
type ToolCallItem,
|
||||
type TurnSummaryItem,
|
||||
} from "@/types/stream";
|
||||
import { summarizeToolCalls, type OverviewSummary } from "@/tool-calls/detail-level/overview/model";
|
||||
|
||||
export interface TurnSummary {
|
||||
tools: OverviewSummary;
|
||||
}
|
||||
|
||||
export interface CollapsedTurn {
|
||||
/** The input or first loaded row that carries the turn's summary line. */
|
||||
hostId: string;
|
||||
expanded: boolean;
|
||||
summary: TurnSummary;
|
||||
}
|
||||
|
||||
export interface TurnCollapse {
|
||||
tail: StreamItem[];
|
||||
turnsByHostId: ReadonlyMap<string, CollapsedTurn>;
|
||||
/** Rows of expanded turns, which render inside the turn's indent rail. */
|
||||
nestedItemIds: ReadonlySet<string>;
|
||||
/** The host of the turn a folded message belongs to, for surfaces that must reveal it. */
|
||||
/** The turn a folded message belongs to, for surfaces that must reveal it. */
|
||||
hostIdByFoldedMessageId: ReadonlyMap<string, string>;
|
||||
/** Mounted rows replaced by a closed turn's host, for preserving the reading position. */
|
||||
hostIdByHiddenRowId: ReadonlyMap<string, string>;
|
||||
/**
|
||||
* The summary rows by host. A mounted row renders again only when its item changes, so a fold
|
||||
* keeps the previous row object for a turn whose work and open state are unchanged.
|
||||
* Hosts whose summary line appeared, changed or went away since `previousTurnsByHostId`. A
|
||||
* history row renders again only when its item changes, so these rows are revised.
|
||||
*/
|
||||
summaryRowsByHostId: ReadonlyMap<string, TurnSummaryItem>;
|
||||
updatedHostIds: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
export interface CollapseTurnsInput {
|
||||
@@ -31,39 +41,39 @@ export interface CollapseTurnsInput {
|
||||
expandedHostIds: ReadonlySet<string>;
|
||||
/** The calls a tool-call group row stands for; undefined for a single call. */
|
||||
getGroupedCalls: (itemId: string) => readonly (ToolCallItem | ThoughtItem)[] | undefined;
|
||||
/** The previous fold of the same history, whose unchanged summary rows are reused. */
|
||||
previousSummaryRowsByHostId: ReadonlyMap<string, TurnSummaryItem>;
|
||||
/** The previous fold of the same history, the baseline for `updatedHostIds`. */
|
||||
previousTurnsByHostId: ReadonlyMap<string, CollapsedTurn>;
|
||||
}
|
||||
|
||||
const EMPTY_TURNS: ReadonlyMap<string, CollapsedTurn> = new Map();
|
||||
const EMPTY_IDS: ReadonlySet<string> = new Set();
|
||||
const EMPTY_ROWS: ReadonlyMap<string, TurnSummaryItem> = new Map();
|
||||
const EMPTY_HOSTS: ReadonlyMap<string, string> = new Map();
|
||||
|
||||
function summarizeTurn(
|
||||
items: readonly StreamItem[],
|
||||
getGroupedCalls: CollapseTurnsInput["getGroupedCalls"],
|
||||
): OverviewSummary {
|
||||
): TurnSummary {
|
||||
const calls: (ToolCallItem | ThoughtItem)[] = [];
|
||||
for (const item of items) {
|
||||
if ((item.kind === "tool_call" || item.kind === "thought") && !isTurnTrigger(item))
|
||||
calls.push(...(getGroupedCalls(item.id) ?? [item]));
|
||||
}
|
||||
return summarizeToolCalls(calls);
|
||||
return { tools: summarizeToolCalls(calls) };
|
||||
}
|
||||
|
||||
function hasWork(tools: OverviewSummary): boolean {
|
||||
const { paseoActions, thoughtCount: _thoughtCount, ...counts } = tools;
|
||||
function hasWork(summary: TurnSummary): boolean {
|
||||
const { paseoActions, thoughtCount: _thoughtCount, ...counts } = summary.tools;
|
||||
return Object.keys(paseoActions).length > 0 || Object.values(counts).some((count) => count > 0);
|
||||
}
|
||||
|
||||
function hasSameWork(a: OverviewSummary, b: OverviewSummary): boolean {
|
||||
const { paseoActions: actionsA, ...countsA } = a;
|
||||
const { paseoActions: actionsB, ...countsB } = b;
|
||||
return hasSameCounts(countsA, countsB) && hasSameCounts(actionsA, actionsB);
|
||||
}
|
||||
|
||||
function hasSamePosition(a: TimelinePosition | undefined, b: TimelinePosition | undefined) {
|
||||
return a === b || (a !== undefined && b !== undefined && a.epoch === b.epoch && a.seq === b.seq);
|
||||
function isSameTurn(a: CollapsedTurn, b: CollapsedTurn): boolean {
|
||||
const { paseoActions: actionsA, ...countsA } = a.summary.tools;
|
||||
const { paseoActions: actionsB, ...countsB } = b.summary.tools;
|
||||
return (
|
||||
a.expanded === b.expanded &&
|
||||
hasSameCounts(countsA, countsB) &&
|
||||
hasSameCounts(actionsA, actionsB)
|
||||
);
|
||||
}
|
||||
|
||||
function hasSameCounts(a: Record<string, number>, b: Record<string, number>): boolean {
|
||||
@@ -71,71 +81,42 @@ function hasSameCounts(a: Record<string, number>, b: Record<string, number>): bo
|
||||
return keys.length === Object.keys(b).length && keys.every((key) => a[key] === b[key]);
|
||||
}
|
||||
|
||||
function summaryRow(
|
||||
host: StreamItem,
|
||||
expanded: boolean,
|
||||
tools: OverviewSummary,
|
||||
previous: TurnSummaryItem | undefined,
|
||||
): TurnSummaryItem {
|
||||
if (
|
||||
previous &&
|
||||
previous.expanded === expanded &&
|
||||
hasSameWork(previous.tools, tools) &&
|
||||
hasSamePosition(previous.timelineCursor, host.timelineCursor)
|
||||
) {
|
||||
return previous;
|
||||
function findUpdatedHosts(
|
||||
previous: ReadonlyMap<string, CollapsedTurn>,
|
||||
next: ReadonlyMap<string, CollapsedTurn>,
|
||||
): ReadonlySet<string> {
|
||||
const updated = new Set<string>();
|
||||
for (const [hostId, turn] of next) {
|
||||
const before = previous.get(hostId);
|
||||
if (!before || !isSameTurn(before, turn)) updated.add(hostId);
|
||||
}
|
||||
return {
|
||||
kind: "turn_summary",
|
||||
id: `turn-summary:${host.id}`,
|
||||
// The row stands at its host's place in the timeline, so the reading position over it
|
||||
// still resolves to the turn's prompt.
|
||||
...(host.timelineCursor !== undefined ? { timelineCursor: host.timelineCursor } : {}),
|
||||
...(host.turnId !== undefined ? { turnId: host.turnId } : {}),
|
||||
timestamp: host.timestamp,
|
||||
hostId: host.id,
|
||||
expanded,
|
||||
tools,
|
||||
};
|
||||
for (const hostId of previous.keys()) {
|
||||
if (!next.has(hostId)) updated.add(hostId);
|
||||
}
|
||||
return updated.size === 0 ? EMPTY_IDS : updated;
|
||||
}
|
||||
|
||||
function staysVisibleWhenCollapsed(item: StreamItem): boolean {
|
||||
return isTurnTrigger(item) || item.kind === "compaction";
|
||||
}
|
||||
|
||||
function isAssistantReply(item: StreamItem): boolean {
|
||||
return (
|
||||
item.kind === "assistant_message" ||
|
||||
(item.kind === "plugin" && item.sourceKind === "assistant_message")
|
||||
);
|
||||
}
|
||||
|
||||
function findLastReplyIndex(tail: StreamItem[], start: number, end: number): number {
|
||||
function findLastAssistantIndex(tail: StreamItem[], start: number, end: number): number {
|
||||
for (let index = end - 1; index >= start; index -= 1) {
|
||||
const item = tail[index]!;
|
||||
if (!isAssistantReply(item)) continue;
|
||||
const messageId = getStreamItemMessageId(item);
|
||||
// A transformer can split one reply into several cards; keep the whole reply visible.
|
||||
while (index > start) {
|
||||
const previous = tail[index - 1]!;
|
||||
if (!isAssistantReply(previous) || getStreamItemMessageId(previous) !== messageId) break;
|
||||
index -= 1;
|
||||
}
|
||||
return index;
|
||||
if (tail[index]?.kind === "assistant_message") return index;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Folds each answered turn behind a summary row under its user message: everything the agent
|
||||
* did before its last message collapses into that row, and the last message stays in the chat.
|
||||
* A turn folds only once it is complete, its user message is loaded, and it sent messages or
|
||||
* called tools before its reply. Reasoning is revealed only when the turn opens.
|
||||
* Folds each answered turn into its user message: everything the agent did before its last
|
||||
* message collapses into one summary line, and that message stays in the chat. A turn folds
|
||||
* only once it is complete, its user message is loaded, and it sent messages or called tools
|
||||
* before its reply. Reasoning is revealed only when the turn opens.
|
||||
*/
|
||||
export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
|
||||
const { tail } = input;
|
||||
const output: StreamItem[] = [];
|
||||
const summaryRowsByHostId = new Map<string, TurnSummaryItem>();
|
||||
const turnsByHostId = new Map<string, CollapsedTurn>();
|
||||
const nestedItemIds = new Set<string>();
|
||||
const hostIdByFoldedMessageId = new Map<string, string>();
|
||||
const hostIdByHiddenRowId = new Map<string, string>();
|
||||
@@ -144,7 +125,7 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
|
||||
const flushRun = (runEnd: number) => {
|
||||
const host = tail[runStart - 1];
|
||||
const isClosed = runEnd < tail.length || !input.isLatestTurnOpen;
|
||||
const replyIndex = findLastReplyIndex(tail, runStart, runEnd);
|
||||
const replyIndex = findLastAssistantIndex(tail, runStart, runEnd);
|
||||
if (
|
||||
!host ||
|
||||
(host.kind !== "user_message" && host.kind !== "tool_call") ||
|
||||
@@ -155,19 +136,13 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
|
||||
return;
|
||||
}
|
||||
const folded = tail.slice(runStart, replyIndex);
|
||||
const tools = summarizeTurn(folded, input.getGroupedCalls);
|
||||
if (!hasWork(tools) && !folded.some(isAssistantReply)) {
|
||||
const summary = summarizeTurn(folded, input.getGroupedCalls);
|
||||
if (!hasWork(summary) && !folded.some((item) => item.kind === "assistant_message")) {
|
||||
output.push(...tail.slice(runStart, runEnd));
|
||||
return;
|
||||
}
|
||||
const expanded = input.expandedHostIds.has(host.id);
|
||||
const summary = summaryRow(
|
||||
host,
|
||||
expanded,
|
||||
tools,
|
||||
input.previousSummaryRowsByHostId.get(host.id),
|
||||
);
|
||||
summaryRowsByHostId.set(host.id, summary);
|
||||
turnsByHostId.set(host.id, { hostId: host.id, expanded, summary });
|
||||
const revealed = expanded
|
||||
? folded.flatMap<StreamItem>((item) =>
|
||||
item.kind === "tool_call" || item.kind === "thought"
|
||||
@@ -182,7 +157,6 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
|
||||
else hostIdByHiddenRowId.set(item.id, host.id);
|
||||
}
|
||||
output.push(
|
||||
summary,
|
||||
...(expanded ? revealed : folded.filter((item) => staysVisibleWhenCollapsed(item))),
|
||||
);
|
||||
output.push(...tail.slice(replyIndex, runEnd));
|
||||
@@ -200,20 +174,26 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
|
||||
}
|
||||
flushRun(tail.length);
|
||||
|
||||
if (summaryRowsByHostId.size === 0) {
|
||||
const updatedHostIds = findUpdatedHosts(input.previousTurnsByHostId, turnsByHostId);
|
||||
if (turnsByHostId.size === 0) {
|
||||
return {
|
||||
tail,
|
||||
turnsByHostId: EMPTY_TURNS,
|
||||
nestedItemIds: EMPTY_IDS,
|
||||
hostIdByFoldedMessageId: EMPTY_HOSTS,
|
||||
hostIdByHiddenRowId: EMPTY_HOSTS,
|
||||
summaryRowsByHostId: EMPTY_ROWS,
|
||||
updatedHostIds,
|
||||
};
|
||||
}
|
||||
return {
|
||||
tail: output,
|
||||
tail:
|
||||
output.length === tail.length && output.every((item, index) => item === tail[index])
|
||||
? tail
|
||||
: output,
|
||||
turnsByHostId,
|
||||
nestedItemIds,
|
||||
hostIdByFoldedMessageId,
|
||||
hostIdByHiddenRowId,
|
||||
summaryRowsByHostId,
|
||||
updatedHostIds,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,25 +4,25 @@ import { Summary } from "lucide-react-native";
|
||||
import { useDisclosureHold } from "@/agent-stream/disclosure";
|
||||
import { ExpandableBadge } from "@/components/message";
|
||||
import { useOverviewSummaryLabel } from "@/tool-calls/detail-level/overview/label";
|
||||
import type { TurnSummaryItem } from "@/types/stream";
|
||||
import type { CollapsedTurn } from "./model";
|
||||
|
||||
/**
|
||||
* The row a collapsed turn leaves under its user message, styled like a tool-call group.
|
||||
* Pressing it opens the turn's rows beneath it.
|
||||
*/
|
||||
export const TurnSummaryLine = memo(function TurnSummaryLine({
|
||||
item,
|
||||
turn,
|
||||
onExpandedChange,
|
||||
}: {
|
||||
item: TurnSummaryItem;
|
||||
turn: CollapsedTurn;
|
||||
onExpandedChange: (hostId: string, expanded: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const label = useOverviewSummaryLabel(item.tools) || t("turnSummary.details");
|
||||
const label = useOverviewSummaryLabel(turn.summary.tools) || t("turnSummary.details");
|
||||
const hold = useDisclosureHold();
|
||||
const toggle = useCallback(
|
||||
() => hold(() => onExpandedChange(item.hostId, !item.expanded)),
|
||||
[hold, onExpandedChange, item.expanded, item.hostId],
|
||||
() => hold(() => onExpandedChange(turn.hostId, !turn.expanded)),
|
||||
[hold, onExpandedChange, turn.expanded, turn.hostId],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -30,7 +30,7 @@ export const TurnSummaryLine = memo(function TurnSummaryLine({
|
||||
testID="turn-summary"
|
||||
label={label}
|
||||
icon={Summary}
|
||||
isExpanded={item.expanded}
|
||||
isExpanded={turn.expanded}
|
||||
onToggle={toggle}
|
||||
borderlessWhenExpanded
|
||||
/>
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import type { TurnTiming } from "@/timeline/turn-time";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { createNativeStreamStrategy } from "./strategy-native";
|
||||
import { CompletedTurnFooterRow } from "./turn-footer";
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
// A Sunday, so a turn that ended on Friday shows a weekday timestamp.
|
||||
const NOW = new Date(2026, 10, 8, 5, 0);
|
||||
const FRIDAY_NIGHT = new Date(2026, 10, 6, 23, 11);
|
||||
|
||||
let root: Root;
|
||||
let host: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("React", React);
|
||||
vi.useFakeTimers({ toFake: ["Date"], now: NOW });
|
||||
host = document.createElement("div");
|
||||
host.style.width = "880px";
|
||||
document.body.appendChild(host);
|
||||
root = createRoot(host);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
act(() => root.unmount());
|
||||
host.remove();
|
||||
vi.useRealTimers();
|
||||
await i18n.changeLanguage("en");
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
const reply: StreamItem = {
|
||||
kind: "assistant_message",
|
||||
id: "reply",
|
||||
text: "Done.",
|
||||
timestamp: FRIDAY_NIGHT,
|
||||
};
|
||||
|
||||
function renderCompletedFooter(timing: TurnTiming): void {
|
||||
act(() =>
|
||||
root.render(
|
||||
<CompletedTurnFooterRow
|
||||
strategy={createNativeStreamStrategy()}
|
||||
items={[reply]}
|
||||
timing={timing}
|
||||
startIndex={0}
|
||||
supportsTimelineCursor={false}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** How many painted lines each footer label takes. */
|
||||
function paintedLinesByLabel(element: HTMLElement): Record<string, number> {
|
||||
const lines: Record<string, number> = {};
|
||||
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
|
||||
while (walker.nextNode()) {
|
||||
const text = walker.currentNode.textContent ?? "";
|
||||
if (!text.trim()) continue;
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(walker.currentNode);
|
||||
lines[text] = new Set(Array.from(range.getClientRects(), (rect) => Math.round(rect.top))).size;
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
// The footer shows the duration and swaps to the end timestamp on hover or tap. Neither label
|
||||
// may be sized by the other: a timestamp with more characters can still paint narrower.
|
||||
it.each([
|
||||
{
|
||||
language: "en",
|
||||
timing: { completedAt: FRIDAY_NIGHT, durationMs: 26_000 },
|
||||
duration: "Worked for 26s",
|
||||
},
|
||||
// CJK glyphs make the shorter string paint wider in every font.
|
||||
{
|
||||
language: "ja",
|
||||
timing: { completedAt: FRIDAY_NIGHT, durationMs: 59 * 60_000 + 58_000 },
|
||||
duration: "作業時間 59m 58s",
|
||||
},
|
||||
])("keeps both timing labels on one line when the chat has room ($language)", async (input) => {
|
||||
await i18n.changeLanguage(input.language);
|
||||
|
||||
renderCompletedFooter(input.timing);
|
||||
|
||||
expect(paintedLinesByLabel(host)).toEqual({
|
||||
[input.duration]: 1,
|
||||
"Friday 11:11 PM": 1,
|
||||
});
|
||||
});
|
||||
@@ -23,10 +23,6 @@ import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
const ThemedSyncedLoader = withUnistyles(SyncedLoader);
|
||||
const workingIndicatorColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
export const TURN_FOOTER_BOTTOM_SPACING = SPACING[8];
|
||||
// Every footer variant (running, completed with Copy, completed with only its duration) gets
|
||||
// this content height, so a turn ending swaps the footer's controls without moving the rows
|
||||
// above it.
|
||||
const TURN_FOOTER_CONTENT_HEIGHT = 24;
|
||||
|
||||
export type TurnContentStrategy = StreamStrategy;
|
||||
export type AssistantTurnForkHandler = (input: {
|
||||
@@ -212,11 +208,7 @@ function CompletedTurnFooter({
|
||||
|
||||
function TurnFooterRow({ children }: { children: ReactNode }) {
|
||||
const rowStyle = useMemo(() => [stylesheet.streamItemWrapper, stylesheet.turnFooterRow], []);
|
||||
return (
|
||||
<View style={rowStyle} testID="turn-footer">
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
return <View style={rowStyle}>{children}</View>;
|
||||
}
|
||||
|
||||
const stylesheet = StyleSheet.create((theme) => ({
|
||||
@@ -226,22 +218,18 @@ const stylesheet = StyleSheet.create((theme) => ({
|
||||
alignSelf: "center",
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
},
|
||||
// The message above drops its bottom padding for the footer (compactBottom), so this margin
|
||||
// alone sets the gap. 20 puts the footer as far below the text as the text sits below a
|
||||
// tool row: 12 message padding + 4 stream gap + 5 badge padding/border, less the footer's
|
||||
// 1px taller row.
|
||||
turnFooterRow: {
|
||||
marginTop: theme.spacing[4] + theme.spacing[1],
|
||||
marginTop: theme.spacing[2] + 5,
|
||||
},
|
||||
turnFooterSlot: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
alignSelf: "stretch",
|
||||
minHeight: TURN_FOOTER_CONTENT_HEIGHT + TURN_FOOTER_BOTTOM_SPACING,
|
||||
alignSelf: "flex-start",
|
||||
minHeight: 24,
|
||||
paddingBottom: TURN_FOOTER_BOTTOM_SPACING,
|
||||
},
|
||||
turnFooterContent: {
|
||||
height: TURN_FOOTER_CONTENT_HEIGHT,
|
||||
height: 24,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-start",
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import {
|
||||
createHistoryStartPagination,
|
||||
type HistoryStartPaginationInput,
|
||||
type HistoryStartPaginationState,
|
||||
} from "./history-start-pagination";
|
||||
|
||||
export function useHistoryStartPagination(input: {
|
||||
initiallyVisible: boolean;
|
||||
read: () => HistoryStartPaginationInput | null;
|
||||
load: () => Promise<boolean>;
|
||||
scheduleSettle: () => void;
|
||||
}) {
|
||||
const read = useStableEvent(input.read);
|
||||
const load = useStableEvent(input.load);
|
||||
const scheduleSettle = useStableEvent(input.scheduleSettle);
|
||||
const [state, setState] = useState<HistoryStartPaginationState>(() => ({
|
||||
status: "dormant",
|
||||
visible: input.initiallyVisible,
|
||||
}));
|
||||
const controller = useRef<ReturnType<typeof createHistoryStartPagination> | null>(null);
|
||||
if (!controller.current) {
|
||||
controller.current = createHistoryStartPagination({
|
||||
initiallyVisible: input.initiallyVisible,
|
||||
read,
|
||||
load,
|
||||
scheduleSettle,
|
||||
changed: setState,
|
||||
});
|
||||
}
|
||||
return { state, controller: controller.current };
|
||||
}
|
||||
@@ -248,8 +248,6 @@ export interface AgentStreamViewProps {
|
||||
turnPresentation: TurnPresentation;
|
||||
routeBottomAnchorRequest?: BottomAnchorRouteRequest | null;
|
||||
isAuthoritativeHistoryReady?: boolean;
|
||||
isInitialHistoryLoading?: boolean;
|
||||
revealInitialHistory?: boolean;
|
||||
/** Tail space required by a transparent overlay rendered at the bottom edge. */
|
||||
bottomOverlayTailClearance?: number;
|
||||
/** Bottom offset required for controls floating above that overlay. */
|
||||
@@ -347,8 +345,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
turnPresentation,
|
||||
routeBottomAnchorRequest = null,
|
||||
isAuthoritativeHistoryReady = true,
|
||||
isInitialHistoryLoading,
|
||||
revealInitialHistory,
|
||||
bottomOverlayTailClearance = 0,
|
||||
bottomOverlayControlClearance,
|
||||
toast,
|
||||
@@ -693,23 +689,26 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
}
|
||||
return revealLoadedHistory(messageId);
|
||||
});
|
||||
// Read through a stable event so a collapse rerun does not change the renderer identity;
|
||||
// nested rows are revised through `historyRowRevision`.
|
||||
// Read through stable events so a collapse rerun does not change the renderer identity;
|
||||
// rows whose turn state changed are revised through `historyRowRevision`.
|
||||
const getCollapsedTurn = useStableEvent((hostId: string) =>
|
||||
presentation.turns.turnsByHostId.get(hostId),
|
||||
);
|
||||
const getTurnIndent = useStableEvent((layoutItem: StreamLayoutItem): TurnIndent => {
|
||||
const { nestedItemIds } = presentation.turns;
|
||||
const { item } = layoutItem;
|
||||
if (nestedItemIds.has(item.id)) {
|
||||
if (nestedItemIds.has(layoutItem.item.id)) {
|
||||
const below = layoutItem.belowItem;
|
||||
return below && nestedItemIds.has(below.id) ? "nested" : "lastNested";
|
||||
}
|
||||
return item.kind === "turn_summary" && item.expanded ? "openedHost" : "none";
|
||||
return getCollapsedTurn(layoutItem.item.id)?.expanded ? "openedHost" : "none";
|
||||
});
|
||||
|
||||
const setToolCallGroupExpanded = toolCallExpansion.setGroupExpanded;
|
||||
|
||||
const renderUserMessageItem = useCallback(
|
||||
(layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "user_message" }>) => {
|
||||
return (
|
||||
const collapsedTurn = getCollapsedTurn(item.id);
|
||||
const message = (
|
||||
<UserMessage
|
||||
serverId={resolvedServerId}
|
||||
agentId={agentId}
|
||||
@@ -729,8 +728,25 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
}
|
||||
/>
|
||||
);
|
||||
if (!collapsedTurn) {
|
||||
return message;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{message}
|
||||
<TurnSummaryLine turn={collapsedTurn} onExpandedChange={setTurnExpanded} />
|
||||
</>
|
||||
);
|
||||
},
|
||||
[context.capabilities, agentId, client, pendingClientMessageIds, resolvedServerId],
|
||||
[
|
||||
context.capabilities,
|
||||
agentId,
|
||||
client,
|
||||
getCollapsedTurn,
|
||||
pendingClientMessageIds,
|
||||
resolvedServerId,
|
||||
setTurnExpanded,
|
||||
],
|
||||
);
|
||||
|
||||
const renderAssistantMessageItem = useCallback(
|
||||
@@ -915,10 +931,17 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
return renderToolCallItem(layoutItem, item);
|
||||
|
||||
case "tool_call":
|
||||
return renderToolCallItem(layoutItem, item);
|
||||
|
||||
case "turn_summary":
|
||||
return <TurnSummaryLine item={item} onExpandedChange={setTurnExpanded} />;
|
||||
return (
|
||||
<>
|
||||
{renderToolCallItem(layoutItem, item)}
|
||||
{getCollapsedTurn(item.id) ? (
|
||||
<TurnSummaryLine
|
||||
turn={getCollapsedTurn(item.id)!}
|
||||
onExpandedChange={setTurnExpanded}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
case "notification":
|
||||
return <Notification level={item.level} message={item.message} />;
|
||||
@@ -949,6 +972,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
renderUserMessageItem,
|
||||
renderAssistantMessageItem,
|
||||
renderToolCallItem,
|
||||
getCollapsedTurn,
|
||||
setTurnExpanded,
|
||||
resolvedServerId,
|
||||
],
|
||||
@@ -1127,17 +1151,19 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
const streamScrollEnabled =
|
||||
!streamRenderStrategy.shouldDisableParentScrollOnInlineDetailsExpansion() ||
|
||||
expandedInlineToolCallIds.size === 0;
|
||||
const { nestedItemIds } = presentation.turns;
|
||||
const { nestedItemIds, updatedHostIds: updatedTurnHostIds } = presentation.turns;
|
||||
const historyRowRevision = useMemo(
|
||||
() => ({
|
||||
contentById: {
|
||||
has: (id: string) => presentation.historyGroupUpdatesByHostId.has(id),
|
||||
has: (id: string) =>
|
||||
presentation.historyGroupUpdatesByHostId.has(id) || updatedTurnHostIds.has(id),
|
||||
},
|
||||
displayStateById: {
|
||||
// An opened lone call also marks its future group open; that row stays unrevised
|
||||
// until a group actually forms there.
|
||||
has: (id: string) =>
|
||||
(expandedToolCallGroups.has(id) && getToolCallGroup(id) !== undefined) ||
|
||||
expandedTurnHostIds.has(id) ||
|
||||
nestedItemIds.has(id),
|
||||
},
|
||||
globalDisplayState: isMobile,
|
||||
@@ -1145,9 +1171,11 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
[
|
||||
expandedToolCallGroups,
|
||||
getToolCallGroup,
|
||||
expandedTurnHostIds,
|
||||
isMobile,
|
||||
nestedItemIds,
|
||||
presentation.historyGroupUpdatesByHostId,
|
||||
updatedTurnHostIds,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1181,8 +1209,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
viewportRef,
|
||||
routeBottomAnchorRequest,
|
||||
isAuthoritativeHistoryReady,
|
||||
isInitialHistoryLoading,
|
||||
revealInitialHistory,
|
||||
onNearBottomChange: nearBottom.set,
|
||||
onReadingPositionChange: handleReadingPositionChange,
|
||||
onNearHistoryStart: loadOlder,
|
||||
@@ -1325,12 +1351,6 @@ function agentStreamViewPropsEqual(
|
||||
if (left.isAuthoritativeHistoryReady !== right.isAuthoritativeHistoryReady) {
|
||||
reasons.push("isAuthoritativeHistoryReady");
|
||||
}
|
||||
if (left.isInitialHistoryLoading !== right.isInitialHistoryLoading) {
|
||||
reasons.push("isInitialHistoryLoading");
|
||||
}
|
||||
if (left.revealInitialHistory !== right.revealInitialHistory) {
|
||||
reasons.push("revealInitialHistory");
|
||||
}
|
||||
if (left.bottomOverlayTailClearance !== right.bottomOverlayTailClearance) {
|
||||
reasons.push("bottomOverlayTailClearance");
|
||||
}
|
||||
|
||||
@@ -65,7 +65,6 @@ export function estimateStreamItemHeight({
|
||||
case "tool_call":
|
||||
return COLLAPSED_TOOL_SEQUENCE_ROW_HEIGHT_ESTIMATE;
|
||||
case "thought":
|
||||
case "turn_summary":
|
||||
return COLLAPSED_TOOL_SEQUENCE_ROW_HEIGHT_ESTIMATE;
|
||||
case "todo_list":
|
||||
return 144;
|
||||
|
||||
@@ -55,7 +55,6 @@ import { DesktopWindowControls } from "@/components/desktop/window-controls";
|
||||
import { SidebarModelProvider } from "@/components/sidebar/sidebar-model";
|
||||
import { WorkspacePinShortcutHandler } from "@/components/workspace-pin-shortcut-handler";
|
||||
import { WorkspaceRenameHost } from "@/components/workspace-rename-host";
|
||||
import { WorkspaceArchiveUndoShortcutHost } from "@/workspace/archive-undo";
|
||||
import { WorkspaceExplorerOpenGesture } from "@/mobile-panels/explorer-open-gesture";
|
||||
import { ProviderSettingsHost } from "@/components/provider-settings-host";
|
||||
import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
|
||||
@@ -558,7 +557,6 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
<PluginCommandCenterActions />
|
||||
<WorkspacePinShortcutHandler />
|
||||
<WorkspaceRenameHost />
|
||||
<WorkspaceArchiveUndoShortcutHost />
|
||||
<CommandCenter />
|
||||
<AddProjectFlowHost />
|
||||
<HostChooserModal />
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
import { expect, test } from "vitest";
|
||||
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
||||
import { createHostBackgroundWorkModel } from "./host";
|
||||
|
||||
test("retries a failed observation and receives subsequent updates", async () => {
|
||||
interface Observer {
|
||||
snapshot(): void;
|
||||
update(message: SessionOutboundMessage): void;
|
||||
error(error: unknown): void;
|
||||
}
|
||||
const observers: Observer[] = [];
|
||||
let releases = 0;
|
||||
const item = {
|
||||
id: "shell",
|
||||
kind: "shell",
|
||||
description: "sleep",
|
||||
startedAt: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
const client = {
|
||||
getLastServerInfoMessage: () => ({ features: { agentBackgroundWork: true } }),
|
||||
stopAgentBackgroundWork: async () => {},
|
||||
listAgentBackgroundWork: async () => [item],
|
||||
observeAgentBackgroundWork: () => ({
|
||||
subscribe: (observer: Observer) => {
|
||||
observers.push(observer);
|
||||
},
|
||||
release: async () => {
|
||||
releases++;
|
||||
},
|
||||
}),
|
||||
};
|
||||
const model = createHostBackgroundWorkModel("host", "agent", {
|
||||
getClient: () => client,
|
||||
subscribe: () => () => {},
|
||||
});
|
||||
const unsubscribe = model.subscribe(() => {});
|
||||
observers[0].snapshot();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(model.getSnapshot()).toEqual({ status: "ready", items: [item] });
|
||||
observers[0].error(new Error("subscription failed"));
|
||||
expect(model.getSnapshot()).toEqual({ status: "error", items: [item] });
|
||||
model.retry();
|
||||
expect(observers).toHaveLength(2);
|
||||
expect(releases).toBe(1);
|
||||
observers[1].snapshot();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(model.getSnapshot()).toEqual({ status: "ready", items: [item] });
|
||||
observers[0].error(new Error("late error"));
|
||||
expect(model.getSnapshot()).toEqual({ status: "ready", items: [item] });
|
||||
observers[1].update({
|
||||
type: "agent.background_work.update",
|
||||
payload: { agentId: "agent", items: [] },
|
||||
});
|
||||
expect(model.getSnapshot()).toEqual({ status: "ready", items: [] });
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
test("starts observing after the same client reconnects", async () => {
|
||||
let connected = false;
|
||||
let notify = () => {};
|
||||
let snapshot = () => {};
|
||||
const item = {
|
||||
id: "shell",
|
||||
kind: "shell",
|
||||
description: "sleep",
|
||||
startedAt: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
const client = {
|
||||
getLastServerInfoMessage: () =>
|
||||
connected ? { features: { agentBackgroundWork: true } } : null,
|
||||
stopAgentBackgroundWork: async () => {},
|
||||
listAgentBackgroundWork: async () => [item],
|
||||
observeAgentBackgroundWork: () => ({
|
||||
subscribe: (observer: { snapshot(): void }) => {
|
||||
snapshot = observer.snapshot;
|
||||
},
|
||||
release: async () => {},
|
||||
}),
|
||||
};
|
||||
const model = createHostBackgroundWorkModel("host", "agent", {
|
||||
getClient: () => client,
|
||||
subscribe: (listener) => {
|
||||
notify = listener;
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
const unsubscribe = model.subscribe(() => {});
|
||||
connected = true;
|
||||
notify();
|
||||
snapshot();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(model.getSnapshot()).toEqual({ status: "ready", items: [item] });
|
||||
unsubscribe();
|
||||
});
|
||||
@@ -1,115 +0,0 @@
|
||||
import type { PaseoAgentBackgroundWorkItem } from "@getpaseo/client";
|
||||
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { createBackgroundWorkModel } from "./model";
|
||||
|
||||
interface BackgroundWorkClient {
|
||||
getLastServerInfoMessage(): {
|
||||
features?: { agentBackgroundWork?: boolean; agentBackgroundWorkStop?: boolean } | null;
|
||||
} | null;
|
||||
stopAgentBackgroundWork(agentId: string, item: PaseoAgentBackgroundWorkItem): Promise<void>;
|
||||
listAgentBackgroundWork(agentId: string): Promise<PaseoAgentBackgroundWorkItem[]>;
|
||||
observeAgentBackgroundWork(): {
|
||||
subscribe(observer: {
|
||||
snapshot(): void;
|
||||
update(message: SessionOutboundMessage): void;
|
||||
error(error: unknown): void;
|
||||
}): unknown;
|
||||
release(): Promise<void>;
|
||||
};
|
||||
}
|
||||
|
||||
interface BackgroundWorkHost {
|
||||
getClient(): BackgroundWorkClient | null;
|
||||
subscribe(listener: () => void): () => void;
|
||||
}
|
||||
|
||||
function hostFor(serverId: string): BackgroundWorkHost {
|
||||
const host = getHostRuntimeStore();
|
||||
return {
|
||||
getClient: () => host.getSnapshot(serverId)?.client ?? null,
|
||||
subscribe: (listener) => host.subscribe(serverId, listener),
|
||||
};
|
||||
}
|
||||
|
||||
export function createHostBackgroundWorkModel(
|
||||
serverId: string,
|
||||
agentId: string,
|
||||
host: BackgroundWorkHost = hostFor(serverId),
|
||||
) {
|
||||
let client: BackgroundWorkClient | null = null;
|
||||
const controls = (items: PaseoAgentBackgroundWorkItem[]) => {
|
||||
// COMPAT(agentBackgroundWorkStop): added after v0.15.0, remove after 2027-05-03.
|
||||
if (client?.getLastServerInfoMessage()?.features?.agentBackgroundWorkStop === true)
|
||||
return items;
|
||||
return items.map(({ canStop: _canStop, stopToken: _stopToken, ...item }) => item);
|
||||
};
|
||||
return createBackgroundWorkModel({
|
||||
stop: (item) => {
|
||||
if (!client) return Promise.reject(new Error("Host disconnected"));
|
||||
return client.stopAgentBackgroundWork(agentId, item);
|
||||
},
|
||||
list: () => {
|
||||
if (!client) return Promise.reject(new Error("Host disconnected"));
|
||||
return client.listAgentBackgroundWork(agentId).then(controls);
|
||||
},
|
||||
observe: ({ snapshot, update, disconnected, failed }) => {
|
||||
let release: (() => void) | undefined;
|
||||
let currentObservation:
|
||||
| ReturnType<BackgroundWorkClient["observeAgentBackgroundWork"]>
|
||||
| undefined;
|
||||
const sync = () => {
|
||||
const candidate = host.getClient();
|
||||
// The runtime keeps the client across reconnects, but clears server info until handshake.
|
||||
const next = candidate?.getLastServerInfoMessage() ? candidate : null;
|
||||
if (next === client) return;
|
||||
const previousRelease = release;
|
||||
client = next;
|
||||
release = undefined;
|
||||
disconnected();
|
||||
previousRelease?.();
|
||||
if (!client) return;
|
||||
// COMPAT(agentBackgroundWork): added after v0.11.0-beta.5, remove gate after 2027-04-06.
|
||||
if (client.getLastServerInfoMessage()?.features?.agentBackgroundWork !== true) {
|
||||
failed();
|
||||
return;
|
||||
}
|
||||
const observedClient = client;
|
||||
const observation = client.observeAgentBackgroundWork();
|
||||
currentObservation = observation;
|
||||
observation.subscribe({
|
||||
snapshot: () => {
|
||||
if (client === observedClient && currentObservation === observation) snapshot();
|
||||
},
|
||||
update: (message) => {
|
||||
if (
|
||||
client === observedClient &&
|
||||
currentObservation === observation &&
|
||||
message.type === "agent.background_work.update" &&
|
||||
message.payload.agentId === agentId
|
||||
)
|
||||
update(controls(message.payload.items));
|
||||
},
|
||||
error: () => {
|
||||
if (client === observedClient && currentObservation === observation) failed();
|
||||
},
|
||||
});
|
||||
release = () => {
|
||||
if (currentObservation === observation) currentObservation = undefined;
|
||||
void observation
|
||||
.release()
|
||||
.catch((error: unknown) =>
|
||||
console.error("Background work subscription cleanup failed", error),
|
||||
);
|
||||
};
|
||||
};
|
||||
const unsubscribe = host.subscribe(sync);
|
||||
sync();
|
||||
return () => {
|
||||
unsubscribe();
|
||||
client = null;
|
||||
release?.();
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
import { useCallback, useSyncExternalStore } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { ComposerTrackPill, ComposerTrackRow } from "@/composer/tracks";
|
||||
import { LiveElapsed } from "@/components/message";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { PaseoAgentBackgroundWorkItem } from "@getpaseo/client";
|
||||
import { createHostBackgroundWorkModel } from "./host";
|
||||
import { backgroundWorkText } from "./presentation";
|
||||
export { createHostBackgroundWorkModel } from "./host";
|
||||
|
||||
function kindKey(kind: string): "shell" | "monitor" | "other" {
|
||||
if (kind === "shell" || kind === "monitor") return kind;
|
||||
return "other";
|
||||
}
|
||||
|
||||
export function BackgroundWorkTrack({
|
||||
model,
|
||||
count,
|
||||
}: {
|
||||
model: ReturnType<typeof createHostBackgroundWorkModel>;
|
||||
count: number;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { items, status, stops } = useSyncExternalStore(
|
||||
model.subscribe,
|
||||
model.getSnapshot,
|
||||
model.getSnapshot,
|
||||
);
|
||||
const missing = Object.values(stops ?? {}).filter(
|
||||
(entry) => !items?.some((item) => item.stopToken === entry.item.stopToken),
|
||||
);
|
||||
const rows = [...(items ?? []), ...missing.map((entry) => entry.item)];
|
||||
if (count === 0 && rows.length === 0) return null;
|
||||
const visibleCount = items?.length || count;
|
||||
return (
|
||||
<ComposerTrackPill
|
||||
testID="background-work"
|
||||
panelTitle={t("message.backgroundWork.title")}
|
||||
segments={[
|
||||
{
|
||||
bucket: "running",
|
||||
text:
|
||||
visibleCount === 1
|
||||
? t("message.backgroundWork.pillOne")
|
||||
: t("message.backgroundWork.pillMany", { count: visibleCount }),
|
||||
},
|
||||
]}
|
||||
>
|
||||
{status === "loading" || status === "stale" ? (
|
||||
<ComposerTrackRow>
|
||||
<Text style={styles.facts}>{t("message.backgroundWork.waiting")}</Text>
|
||||
</ComposerTrackRow>
|
||||
) : null}
|
||||
{status === "error" ? (
|
||||
<>
|
||||
<ComposerTrackRow>
|
||||
<Text style={styles.facts}>{t("message.backgroundWork.error")}</Text>
|
||||
</ComposerTrackRow>
|
||||
<ComposerTrackRow
|
||||
onPress={model.retry}
|
||||
closeOnSelect={false}
|
||||
accessibilityLabel={t("message.backgroundWork.retry")}
|
||||
>
|
||||
<Text style={styles.title}>{t("message.backgroundWork.retry")}</Text>
|
||||
</ComposerTrackRow>
|
||||
</>
|
||||
) : null}
|
||||
{rows.map((item) => (
|
||||
<BackgroundWorkRow
|
||||
key={item.stopToken ?? item.id}
|
||||
item={item}
|
||||
model={model}
|
||||
live={Boolean(items?.some((entry) => entry.stopToken === item.stopToken))}
|
||||
ready={status === "ready"}
|
||||
stopStatus={stops?.[item.stopToken ?? ""]?.status}
|
||||
/>
|
||||
))}
|
||||
</ComposerTrackPill>
|
||||
);
|
||||
}
|
||||
|
||||
function BackgroundWorkRow({
|
||||
item,
|
||||
model,
|
||||
live,
|
||||
ready,
|
||||
stopStatus,
|
||||
}: {
|
||||
item: PaseoAgentBackgroundWorkItem;
|
||||
model: ReturnType<typeof createHostBackgroundWorkModel>;
|
||||
live: boolean;
|
||||
ready: boolean;
|
||||
stopStatus?: "pending" | "error";
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const kind = t(`message.backgroundWork.${kindKey(item.kind)}`);
|
||||
const stop = useCallback(() => {
|
||||
void model.stopItem(item);
|
||||
}, [model, item]);
|
||||
const dismiss = useCallback(
|
||||
() => model.dismissStop(item.stopToken ?? ""),
|
||||
[model, item.stopToken],
|
||||
);
|
||||
let detail = t("message.backgroundWork.notRunning");
|
||||
if (stopStatus === "pending") detail = t("message.backgroundWork.stopping");
|
||||
const { title, command } = backgroundWorkText(item, kind);
|
||||
const action = item.canStop && ready && live;
|
||||
const label =
|
||||
stopStatus === "pending"
|
||||
? t("message.backgroundWork.stopping")
|
||||
: t("message.backgroundWork.stop");
|
||||
return (
|
||||
<ComposerTrackRow>
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
{command ? <Text style={styles.facts}>{command}</Text> : null}
|
||||
<Text style={styles.facts}>
|
||||
{kind} ·{" "}
|
||||
{live ? (
|
||||
<LiveElapsed startedAt={new Date(item.startedAt)} active style={styles.facts} />
|
||||
) : (
|
||||
detail
|
||||
)}
|
||||
</Text>
|
||||
{stopStatus === "error" ? (
|
||||
<Text style={styles.facts}>{t("message.backgroundWork.stopError")}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
{action ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
accessibilityLabel={`${t("message.backgroundWork.stop")} ${title}`}
|
||||
disabled={stopStatus === "pending"}
|
||||
onPress={stop}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
) : null}
|
||||
{!live && stopStatus === "error" ? (
|
||||
<Button variant="ghost" size="sm" onPress={dismiss}>
|
||||
{t("message.backgroundWork.dismiss")}
|
||||
</Button>
|
||||
) : null}
|
||||
</ComposerTrackRow>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
row: { flexGrow: 1, flexShrink: 1, flexBasis: "auto", minWidth: 0 },
|
||||
title: { fontSize: theme.fontSize.base, color: theme.colors.foreground },
|
||||
facts: { fontSize: theme.fontSize.sm, color: theme.colors.foregroundMuted },
|
||||
}));
|
||||
@@ -1,233 +0,0 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { createBackgroundWorkModel } from "./model";
|
||||
|
||||
test("keeps a newer update when an earlier list resolves after it", async () => {
|
||||
let resolve!: (
|
||||
value: Array<{ id: string; kind: string; description: string | null; startedAt: string }>,
|
||||
) => void;
|
||||
let update!: (
|
||||
items: Array<{ id: string; kind: string; description: string | null; startedAt: string }>,
|
||||
) => void;
|
||||
let snapshot!: () => void;
|
||||
const model = createBackgroundWorkModel({
|
||||
list: () =>
|
||||
new Promise((done) => {
|
||||
resolve = done;
|
||||
}),
|
||||
observe: (callbacks) => {
|
||||
snapshot = callbacks.snapshot;
|
||||
update = callbacks.update;
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
const unsubscribe = model.subscribe(() => {});
|
||||
expect(model.getSnapshot().items).toBeNull();
|
||||
snapshot();
|
||||
const item = { id: "1", kind: "shell", description: "sleep", startedAt: "2026-01-01T00:00:00Z" };
|
||||
update([item]);
|
||||
resolve([]);
|
||||
await Promise.resolve();
|
||||
expect(model.getSnapshot().items).toEqual([item]);
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
test("retains the last inventory while reconnecting and replaces it after resync", async () => {
|
||||
const item = { id: "1", kind: "shell", description: "sleep", startedAt: "2026-01-01T00:00:00Z" };
|
||||
const reads: Array<(value: (typeof item)[]) => void> = [];
|
||||
let snapshot!: () => void;
|
||||
const model = createBackgroundWorkModel({
|
||||
list: () => new Promise((resolve) => reads.push(resolve)),
|
||||
observe: (callbacks) => {
|
||||
snapshot = callbacks.snapshot;
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
const unsubscribe = model.subscribe(() => {});
|
||||
snapshot();
|
||||
reads[0]([item]);
|
||||
await Promise.resolve();
|
||||
snapshot();
|
||||
expect(model.getSnapshot().items).toEqual([item]);
|
||||
reads[1]([]);
|
||||
await Promise.resolve();
|
||||
expect(model.getSnapshot().items).toEqual([]);
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
test("ignores a list response from a connection that was replaced", async () => {
|
||||
let snapshot!: () => void;
|
||||
let disconnected!: () => void;
|
||||
let resolve!: (
|
||||
items: Array<{ id: string; kind: string; description: string | null; startedAt: string }>,
|
||||
) => void;
|
||||
const model = createBackgroundWorkModel({
|
||||
list: () =>
|
||||
new Promise((done) => {
|
||||
resolve = done;
|
||||
}),
|
||||
observe: (callbacks) => {
|
||||
snapshot = callbacks.snapshot;
|
||||
disconnected = callbacks.disconnected;
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
const unsubscribe = model.subscribe(() => {});
|
||||
snapshot();
|
||||
disconnected();
|
||||
resolve([{ id: "old", kind: "shell", description: null, startedAt: "2026-01-01T00:00:00Z" }]);
|
||||
await Promise.resolve();
|
||||
expect(model.getSnapshot().items).toBeNull();
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
test("retains stale items on failure and retries the inventory", async () => {
|
||||
const item = { id: "1", kind: "shell", description: "sleep", startedAt: "2026-01-01T00:00:00Z" };
|
||||
let snapshot!: () => void;
|
||||
let resolve!: (items: (typeof item)[]) => void;
|
||||
let reject!: (error: Error) => void;
|
||||
const model = createBackgroundWorkModel({
|
||||
list: () =>
|
||||
new Promise((done, fail) => {
|
||||
resolve = done;
|
||||
reject = fail;
|
||||
}),
|
||||
observe: (callbacks) => {
|
||||
snapshot = callbacks.snapshot;
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
const unsubscribe = model.subscribe(() => {});
|
||||
snapshot();
|
||||
resolve([item]);
|
||||
await Promise.resolve();
|
||||
snapshot();
|
||||
expect(model.getSnapshot()).toEqual({ status: "stale", items: [item] });
|
||||
reject(new Error("offline"));
|
||||
await Promise.resolve();
|
||||
expect(model.getSnapshot()).toEqual({ status: "error", items: [item] });
|
||||
model.retry();
|
||||
expect(model.getSnapshot()).toEqual({ status: "stale", items: [item] });
|
||||
resolve([]);
|
||||
await Promise.resolve();
|
||||
expect(model.getSnapshot()).toEqual({ status: "ready", items: [] });
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
test("reports an initial read failure without claiming an empty inventory", async () => {
|
||||
let snapshot!: () => void;
|
||||
const model = createBackgroundWorkModel({
|
||||
list: async () => {
|
||||
throw new Error("offline");
|
||||
},
|
||||
observe: (callbacks) => {
|
||||
snapshot = callbacks.snapshot;
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
const unsubscribe = model.subscribe(() => {});
|
||||
snapshot();
|
||||
await Promise.resolve();
|
||||
expect(model.getSnapshot()).toEqual({ status: "error", items: null });
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
test("recreates a failed observation on retry and resumes live updates", async () => {
|
||||
const item = { id: "1", kind: "shell", description: "sleep", startedAt: "2026-01-01T00:00:00Z" };
|
||||
let observation!: Parameters<Parameters<typeof createBackgroundWorkModel>[0]["observe"]>[0];
|
||||
let starts = 0;
|
||||
let releases = 0;
|
||||
const model = createBackgroundWorkModel({
|
||||
list: async () => [item],
|
||||
observe: (callbacks) => {
|
||||
observation = callbacks;
|
||||
starts++;
|
||||
return () => {
|
||||
releases++;
|
||||
};
|
||||
},
|
||||
});
|
||||
const unsubscribe = model.subscribe(() => {});
|
||||
observation.failed();
|
||||
expect(model.getSnapshot()).toEqual({ status: "error", items: null });
|
||||
model.retry();
|
||||
expect(starts).toBe(2);
|
||||
expect(releases).toBe(1);
|
||||
observation.snapshot();
|
||||
await Promise.resolve();
|
||||
expect(model.getSnapshot()).toEqual({ status: "ready", items: [item] });
|
||||
observation.update([]);
|
||||
expect(model.getSnapshot()).toEqual({ status: "ready", items: [] });
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
test("keeps a stop pending across a roster removal and exposes a failed outcome", async () => {
|
||||
const item = {
|
||||
id: "1",
|
||||
kind: "shell",
|
||||
description: "sleep",
|
||||
startedAt: "2026-01-01",
|
||||
canStop: true,
|
||||
stopToken: "generation-1",
|
||||
};
|
||||
let update!: (items: (typeof item)[]) => void;
|
||||
let reject!: (error: Error) => void;
|
||||
const model = createBackgroundWorkModel({
|
||||
list: async () => [item],
|
||||
stop: () =>
|
||||
new Promise((_, fail) => {
|
||||
reject = fail;
|
||||
}),
|
||||
observe: (callbacks) => {
|
||||
update = callbacks.update;
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
const unsubscribe = model.subscribe(() => {});
|
||||
update([item]);
|
||||
const pending = model.stopItem(item);
|
||||
update([]);
|
||||
expect(model.getSnapshot().stops?.["generation-1"]).toEqual({ status: "pending", item });
|
||||
reject(new Error("connection lost"));
|
||||
await pending;
|
||||
expect(model.getSnapshot().stops?.["generation-1"]).toEqual({ status: "error", item });
|
||||
model.dismissStop("generation-1");
|
||||
expect(model.getSnapshot().stops).toEqual({});
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
test("ignores a stop response from a disconnected generation", async () => {
|
||||
const first = {
|
||||
id: "1",
|
||||
kind: "shell",
|
||||
description: "old",
|
||||
startedAt: "2026-01-01",
|
||||
canStop: true,
|
||||
stopToken: "first",
|
||||
};
|
||||
const replacement = { ...first, description: "new", stopToken: "second" };
|
||||
let update!: (items: (typeof first)[]) => void;
|
||||
let disconnect!: () => void;
|
||||
let resolve!: () => void;
|
||||
const model = createBackgroundWorkModel({
|
||||
list: async () => [],
|
||||
stop: () =>
|
||||
new Promise((done) => {
|
||||
resolve = done;
|
||||
}),
|
||||
observe: (callbacks) => {
|
||||
update = callbacks.update;
|
||||
disconnect = callbacks.disconnected;
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
const unsubscribe = model.subscribe(() => {});
|
||||
update([first]);
|
||||
const pending = model.stopItem(first);
|
||||
disconnect();
|
||||
update([replacement]);
|
||||
resolve();
|
||||
await pending;
|
||||
expect(model.getSnapshot().items).toEqual([replacement]);
|
||||
expect(model.getSnapshot().stops?.first).toEqual({ status: "error", item: first });
|
||||
unsubscribe();
|
||||
});
|
||||
@@ -1,121 +0,0 @@
|
||||
import type { PaseoAgentBackgroundWorkItem as AgentBackgroundWorkItem } from "@getpaseo/client";
|
||||
|
||||
interface Port {
|
||||
list(): Promise<AgentBackgroundWorkItem[]>;
|
||||
stop?(item: AgentBackgroundWorkItem): Promise<void>;
|
||||
observe(callbacks: {
|
||||
snapshot(): void;
|
||||
disconnected(): void;
|
||||
failed(): void;
|
||||
update(items: AgentBackgroundWorkItem[]): void;
|
||||
}): () => void;
|
||||
}
|
||||
|
||||
interface BackgroundWorkState {
|
||||
status: "loading" | "stale" | "ready" | "error";
|
||||
items: AgentBackgroundWorkItem[] | null;
|
||||
stops?: Record<string, { status: "pending" | "error"; item: AgentBackgroundWorkItem }>;
|
||||
}
|
||||
|
||||
export function createBackgroundWorkModel(port: Port) {
|
||||
let state: BackgroundWorkState = { status: "loading", items: null };
|
||||
let sequence = 0;
|
||||
let stopGeneration = 0;
|
||||
let stop: (() => void) | undefined;
|
||||
let observationFailed = false;
|
||||
const listeners = new Set<() => void>();
|
||||
const publish = (next: BackgroundWorkState) => {
|
||||
state =
|
||||
next.stops === undefined && state.stops === undefined
|
||||
? next
|
||||
: { ...next, stops: next.stops ?? state.stops };
|
||||
for (const listener of listeners) listener();
|
||||
};
|
||||
const refresh = async () => {
|
||||
const request = ++sequence;
|
||||
publish({ status: state.items === null ? "loading" : "stale", items: state.items });
|
||||
try {
|
||||
const items = await port.list();
|
||||
if (request === sequence) publish({ status: "ready", items });
|
||||
} catch {
|
||||
if (request === sequence) publish({ status: "error", items: state.items });
|
||||
}
|
||||
};
|
||||
const start = () => {
|
||||
observationFailed = false;
|
||||
stop = port.observe({
|
||||
snapshot: () => {
|
||||
void refresh();
|
||||
},
|
||||
disconnected: () => {
|
||||
sequence++;
|
||||
stopGeneration++;
|
||||
const stops =
|
||||
state.stops &&
|
||||
Object.fromEntries(
|
||||
Object.entries(state.stops).map(([id, entry]) => [
|
||||
id,
|
||||
{ ...entry, status: "error" as const },
|
||||
]),
|
||||
);
|
||||
publish({ status: state.items === null ? "loading" : "stale", items: state.items, stops });
|
||||
},
|
||||
failed: () => {
|
||||
observationFailed = true;
|
||||
sequence++;
|
||||
publish({ status: "error", items: state.items });
|
||||
},
|
||||
update: (items) => {
|
||||
sequence++;
|
||||
publish({ status: "ready", items });
|
||||
},
|
||||
});
|
||||
};
|
||||
return {
|
||||
getSnapshot: () => state,
|
||||
dismissStop: (token: string) => {
|
||||
const stops = { ...state.stops };
|
||||
delete stops[token];
|
||||
publish({ ...state, stops });
|
||||
},
|
||||
stopItem: async (item: AgentBackgroundWorkItem) => {
|
||||
const token = item.stopToken;
|
||||
if (!port.stop || !token || state.stops?.[token]?.status === "pending") return;
|
||||
if (!state.items?.some((entry) => entry.stopToken === token)) return;
|
||||
const generation = stopGeneration;
|
||||
publish({ ...state, stops: { ...state.stops, [token]: { status: "pending", item } } });
|
||||
try {
|
||||
await port.stop(item);
|
||||
if (generation !== stopGeneration) return;
|
||||
const stops = { ...state.stops };
|
||||
delete stops[token];
|
||||
publish({ ...state, stops });
|
||||
} catch {
|
||||
if (generation === stopGeneration)
|
||||
publish({ ...state, stops: { ...state.stops, [token]: { status: "error", item } } });
|
||||
}
|
||||
},
|
||||
retry: () => {
|
||||
if (observationFailed) {
|
||||
stop?.();
|
||||
stop = undefined;
|
||||
publish({ status: state.items === null ? "loading" : "stale", items: state.items });
|
||||
start();
|
||||
} else {
|
||||
void refresh();
|
||||
}
|
||||
},
|
||||
subscribe(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
if (!stop) start();
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
if (!listeners.size) {
|
||||
sequence++;
|
||||
stop?.();
|
||||
stop = undefined;
|
||||
}
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user