Compare commits

...
Author SHA1 Message Date
paseo-bot[bot] 01888dc5de Keep large routed timelines connected when the receiver falls behind (#9109)
* fix(server): pace routed output to its receiving client

A routed transfer previously waited only for the destination-to-intermediary write, so a slower final hop overflowed the route queue. Negotiate bounded per-channel acknowledgments after final forwarding drains, preserving concurrent small sends and legacy peers.

* fix(transport): keep small routed writes concurrent

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-08 15:02:04 +00:00
paseo-bot[bot] 27872a8e86 test(app): wait for the model before the live-pin journey (#9102)
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-08 09:38:08 +00:00
Mohamed Boudra 1b0da5fce5 fix(server): keep earlier replies visible after agent resume (#9098)
* fix(server): keep earlier replies visible after agent resume

Idle runtime collection retains timeline rows but recreates provider sessions. Scope turn counters to a fresh UUID namespace so resumed sessions cannot reuse persisted turn identities.

* test(server): require a turn ID before inspecting its namespace
2026-11-08 16:09:45 +07:00
Mohamed Boudra 02ad59908f Keep show more and plan card toggles in place on Android (#9085)
* fix(app): keep show more and plan card triggers in place on expand

Long user message previews and plan cards toggled with a plain setState,
skipping the timeline's disclosure hold. On Android the expanded content
painted over the next row for a frame before the list learned the new
size and corrected the scroll. Route both toggles through
useDisclosureHold like the other timeline disclosures.

* fix(app): keep show more at the same y by holding the message's bottom edge

Show more sits under the text it opens, so a top-anchored hold pushed it off
screen. The disclosure hold takes a trigger edge; MessagePreview passes the
bottom edge. Web pins that edge in the reading anchor, native anchors the row
below the message, and while following the end pin keeps the bottom in place.
The LegendList patch counts the row just past the viewport as in view and drops
the Android clamp prediction that moved a collapse near the end to the end.

* fix(app): keep show more in place on an unanswered final prompt

The last row has no row below it to anchor its bottom on, so a show more
on a prompt the agent has not answered yet moved the trigger off screen.
The hold now mounts the end pin while detached and toggles a frame later,
once the pin anchors, and re-sticks only if the list was at its end when
the toggle was tapped.

The disclosure browser test now renders the real Markdown renderer.

* fix(app): resume following when a final prompt collapse reaches the end

An end hold decided its release from whether the list was at its end
when tapped. A collapse that leaves less scroll room than that distance
ends at the end, so the list stayed detached and later output grew below
the viewport. The release now clamps the tapped distance to the room
left. A row hold also clears an end hold still in progress, so the plan
card or another toggle anchors its own row.

* fix(app): keep following when a reply lands during a final prompt collapse

The end hold clamped its distance from the end only against the final
content size. A reply that grew the content after a collapse reached the
end brought the old distance back, so the hold released detached. The
hold now clamps on every content size change.

* fix(app): let a reply grow below a held final prompt

An end hold pinned the content end for the whole hold, so a reply that
landed during it scrolled the toggled prompt up. The pin now holds only
while the toggled row is the last row, and drops in the commit that
mounts the reply.

* fix(app): move a waiting final prompt hold to a reply that lands first

The end hold's toggle waits a frame for the end pin, and it kept the
anchor from the tap. A reply landing in that frame dropped the pin but
left the hold with no row, so the toggle moved the button. The disclosure
hold now owns the waiting toggle and resolves the anchor when the frame
runs.
2026-11-08 15:34:26 +07:00
Mohamed Boudra b509d499f7 fix(app): show one workspace header above split panes (#9088)
#8740 moved the workspace header into every split pane so each pane could
pick full or compact chrome by its own width. Every pane then repeated the
title, project row, workspace menu and Explorer toggle, and narrow panes
swapped their tab rail for the tab counter.

The tab counter belongs to the single-pane layout. With two or more visible
panes, the split container now renders the header once above them (full or
compact by column width, without a counter) and every pane keeps its tab
rail. A single visible pane, including focus mode, keeps the merged compact
header with the counter when narrow.
2026-11-08 14:06:36 +07:00
Mohamed Boudra f539ccf1c0 Recover a dead host connection within seconds of returning to the foreground (#9086)
* fix(client): recover a dead connection within seconds of returning to the foreground

A socket that died while the OS had JS suspended stays OPEN and keeps accepting
sends, so nothing but inbound frames can prove it alive. Since #8651 the
foreground check rode the regular heartbeat: a 15 s window, dedupe onto a probe
that straddled the suspension, misses from before background carried over, and
a 1.5 s backoff after teardown. Measured at the transport: 40 s to tear down and
41.5 s to reconnect, with 15 s to 30 s variations depending on what was in
flight when the app was suspended.

Foreground now starts a fresh cycle inside the same heartbeat owner: two 5 s
windows back to back, then teardown and an immediate reconnect. An outstanding
heartbeat probe is dropped without a miss because it measured the suspension,
not the socket; a latency measurement keeps its own deadline and is shared.
Repeated foreground events join the running cycle instead of restarting it.
The rule that any validated inbound frame voids a window is unchanged, so a
slow multi-frame transfer still survives, and after any progress the heartbeat
returns to its 10 s / 15 s cadence. Ordinary heartbeat teardowns keep their
backoff; only the foreground cycle reconnects at once, and a failed immediate
attempt backs off as before.

A single frame that takes longer than the two windows to arrive after
foreground is still torn down. That needs smaller daemon-to-client frames and
is documented as a known limit.

* fix(client): count only fresh evidence in the foreground liveness cycle

Two gaps in the foreground cycle let stale evidence decide a fresh check.

A latency measurement outstanding at foreground is shared by the check. If a
frame had arrived during that measurement before the app was suspended, the
check read that old activity as "alive" and went back to the normal cadence
without running its windows. The check now clears the shared probe's activity
flag when it begins, so only frames that arrive from then on count.

If the app was suspended again inside the second window, the next resume
either found the queued expiry already fired (teardown before AppState) or
deduped onto the old probe and let the expiry fire right after (teardown with
no fresh window). A foreground window whose timer fires well past its deadline
on the wall clock spanned a suspension; it is now discarded together with any
activity it saw and the misses before it, and the cycle starts over. The wall
clock is used because the monotonic clock may not advance while the OS has the
process suspended.

* fix(client): keep latency measurements alive when foreground replaces a heartbeat

A latency measurement that found a heartbeat ping outstanding joins that
probe. The foreground check used to fail the probe outright to start its
fresh window, which rejected the measurement before its own deadline on a
healthy socket; the diagnostics collector treats that as a failed
collection. The superseded probe is now detached without settling and
takes the outcome of the fresh window, so the heartbeat tick and any
measurement waiting on it see the next pong or the window's timeout
instead of a spurious error.

* fix(client): let latency measurements wait for the next pong instead of a probe

A measurement that joined an outstanding heartbeat probe took that
probe's fate. The foreground check replaces such a probe, so the
measurement failed early: first with the supersede error, then, after
the hand-off to the fresh window, with that window's five-second timeout
even when the caller had asked for a longer deadline and the pong was
about to arrive.

Measurements now register as pong waiters. A waiter settles on the next
pong, on a connection failure (close, transport dispose, ping send
failure) or on its own deadline, and no longer cares which probe put the
ping on the wire. The foreground check goes back to dropping the
straddling heartbeat probe outright, since the only thing waiting on a
probe's promise is the heartbeat tick.

* fix(client): report the joined ping's round trip from a latency measurement

A measurement that joins an in-flight heartbeat ping used to start its
clock at the join, so it reported only the remaining wait for the pong.
The clock now starts at the send of the ping the measurement waits on,
which matches what the heartbeat records for the same pong.
2026-11-08 14:01:51 +07:00
paseo-bot[bot] ac08077f7a Keep workspace attention in sync after agent runtimes close (#9087)
* fix(server): publish attention clears for unloaded agents

Stored attention clears bypassed agent state events and subscription ownership, leaving connected directory observers stale. Use the existing manager mutation and publish path for unloaded agents, and cover independent filtered subscribers through real daemon requests.

* test(server): run attention regressions in subscription CI

Keep full clear-response coverage alongside independent subscriber checks. Allow asynchronous native deletion classification beyond the default one-second polling deadline.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-08 06:54:33 +00:00
paseo-bot[bot] 79717e2bd3 test(app): share the settings sidebar scroll journey (#9089)
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-08 06:36:38 +00:00
paseo-bot[bot] 22f50d4dfa fix(app): preserve unsent drafts when closing agent tabs (#9079)
Archiving removes an agent from the active directory without deleting the conversation. Preserve its local composer draft there too, and clear it only on explicit entity removal.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-08 06:10:04 +00:00
Mohamed Boudra 2d7a115614 fix(app): keep the latest turn footer in the chat column on web (#9083)
At widths beyond the chat's max width, the latest turn's footer sat at the
chat's left padding instead of under the message column, while running and
after it ended. Earlier footers were aligned.

#8358 wrapped the web viewport's live auxiliary (the latest turn footer and
pending permissions) in a measuring div with no style. That div is a block
box, so the footer row's alignSelf: center no longer applied. Give it the
same flex column style as the stream rows.

Fixes #9081
2026-11-08 12:48:07 +07:00
Mohamed Boudra 680e6077a8 test(app): keep turn footer timing on one line when the chat has room (#9080)
The completed turn footer wrapped "Worked for 26s" onto two lines at full
chat width in builds before #9007, which sized the duration to whichever
label had more characters. A label with fewer characters can paint wider,
so the duration wrapped inside the narrower timestamp's width. #9007 gives
both labels the full label column; this locks that behaviour with a browser
test using real layout.

The browser test project could not load anything that reaches the toast
context: react-native-web has no ToastAndroid, and native ESM rejects the
named import. A react-native test shim re-exports react-native-web and adds
it. The lucide test stub gains the icons the message module imports.
2026-11-08 12:00:08 +07:00
Mohamed Boudra 835e339516 fix(app): show one hover card at a time while scanning sidebar workspaces (#9071)
* fix(app): keep one hover card open while scanning sidebar workspaces

Sweeping the pointer down the workspace list showed cards about three
frames late, stacked two or three at once, and left some open after the
pointer moved on.

The card was positioned through an async chain (measureInWindow, then
onLayout, then a position effect, then the portal's passive mount), so it
painted at -9999 first. While unplaced, its safe-zone bridge covered
everything above the trigger, so upward pointer moves kept cancelling its
close timer. The previous card also stayed for the close grace period
plus its fade while the next one opened.

Now each overlay layer holds one open card, and opening another trigger
closes it immediately. The card reads its trigger and overlay rects in a
layout effect inside the portal, so the first painted frame is already
placed. The grace timer still covers jitter and the gap onto the card.

* test(app): name the hover scan workspaces so the resting row's card is identified
2026-11-08 11:34:25 +07:00
paseo-bot[bot] 1927d859bc fix(app): identify routed connections in diagnostics (#9058)
The runtime already identifies connections through another host as routed, but both diagnostic fields fell through to unknown. Preserve that distinction in the report.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-08 04:13:10 +00:00
Mohamed Boudra fcde2072ab feat(diff): stream Changes revisions without blocking the socket (#8907)
* feat(protocol): define streamed diff revisions and resolve tag bases

* feat(server): stream shared diff revisions with paced file bodies

* feat(client): observe streamed diffs with atomic revision updates

* feat(app): show the Changes tree while diff bodies stream

* fix(server): retain the legacy structured diff payload budget

* fix(diff): preserve revision ordering and readable files

* fix(diff): select renamed patches and read dangling symlinks

* fix(diff): preserve nested checkouts and ambiguous patch paths

Only regular files and symlinks are readable untracked entries. Prefer patch metadata to ambiguous unquoted headers so paths containing a prefix delimiter keep their hunks.

* fix(diff): preserve BOM text and complete file replacements

Decode each string without consuming its leading BOM. File-type replacements emit multiple patch sections for one listed path, so retain all matching hunks. Assert the optional rename metadata in the existing commit-file journey.

* fix(diff): highlight symlinks from their stored target text

Working tree source context must match the symlink blob emitted by Git rather than the contents reached by following the link. Otherwise highlight runs do not cover the patch line and encoding fails the revision.

* fix(diff): tolerate disappearing highlight sources

Source context is optional after Git has produced the patch. Missing worktree link metadata must preserve the existing hunk-based highlighting fallback instead of failing the revision.

* fix(diff): batch cold reads and refresh logical comparison inputs

Acquire bounded pages of tracked patches lazily after Begin while retaining per-file highlighting and awaited frame delivery. Keep legacy preferred-base selection live across revisions and include submodule Git state in worktree cache identity.

* fix(diff): distinguish gitlink directories from replacements

A raw old gitlink mode also occurs when a submodule becomes a regular file. Query nested Git state only for a current directory, preserving normal file identity for replacements.

* fix(diff): invalidate timestamp-preserving file replacements

Size and modification time do not identify live file contents after a preserving copy. Include filesystem change time and file identity for tracked and untracked reuse, and verify live revisions agree with fresh reads.

* fix(diff): align highlight tokens with normalized patch lines

Git can normalize CRLF worktrees while highlighting reads their original line endings. Match tokens to the emitted patch text and trim only a normalization CR before encoding runs; mismatched source tokens are not attached.

* fix(diff): refresh equivalent worktree paths across platforms

Explicit refreshes must match filesystem-equivalent roots, including Windows path aliases. Use the existing realpath-aware matcher, budget real Git completion waits consistently, and assert optional status metadata in the filesystem commit-file journey.

* fix(diff): validate Git inputs and resolve refresh roots

Reuse now accounts for effective attributes, configuration, and canonical worktree blobs as well as filesystem state. Explicit refresh resolves repository identity like subscription creation, and callers await that resolution before acknowledging the command.

* fix(diff): keep non-Git workspace refresh successful

Diff invalidation has no target repository for an ordinary folder. Treat that expected absence as a no-op while preserving other root-resolution errors.

* fix(diff): observe scoped Git interpretation settings

Classify each metadata root with its worktree and common scopes, retaining both when the directories coincide. Shared attributes affect all checkouts, while config.worktree belongs to the owning checkout. Real subscriptions verify automatic binary and hunk-context refreshes in main and linked worktrees.

* fix(diff): observe interpretation inputs for pinned comparisons

Immutable commit objects still depend on mutable Git configuration and attributes. Declare those metadata inputs independently of ref, index, and worktree mutability, while keeping unrelated endpoint events out of pinned comparisons.

* fix(diff): observe interpretation independently of comparison endpoints

Commit comparisons still read worktree attributes. Watch those inputs independently, checking effective Git interpretation before invalidating fixed contents. Retain paths hidden by whitespace filtering so attribute changes can expose them. Forward binary frames through the branch-loading test interceptor.
2026-11-08 11:00:21 +07:00
Mohamed Boudra 2071bfda7f fix(client): close abandoned browser connections immediately (#9067)
* fix(client): close abandoned browser connections immediately

When the client gave up on a connection (liveness timeout, connect
timeout, reconnect, subscription failure) it closed the WebSocket with
1001. Browsers, Electron and Node's global WebSocket accept only 1000 or
3000-4999 and throw InvalidAccessError otherwise; the throw was
swallowed and the reference dropped, so the physical socket stayed open
and the daemon kept streaming into it until its 45 s lease expired.

The WebSocket adapter is the only code that calls WebSocket.close, so it
now sends 1000 for codes the API rejects and trims reasons to the
123-byte limit. Callers keep their reasons, and Node's `ws` sends the
same frame as a browser, so Node tests now catch this class of bug.

* test(client): drop timing-dependent hello count from abandoned-close test

Handshake retries after close are owned and tested by the relay's handshake-lifetime test; this test only checks that the abandoned socket closes.
2026-11-08 10:00:11 +07:00
paseo-bot[bot] ed4657bdf8 fix(app): avoid false never-online claims in diagnostics (#9054)
A missing runtime timestamp does not establish connection history. Report it as unknown while preserving supplied timestamps.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-08 02:59:58 +00:00
paseo-bot[bot] 71439e51a5 fix(website): submit plugin search text during hydration (#9059)
Read the submitted form value while hydration state is still catching up with text entered before its input listener. Cover the missed input event without relying on startup timing.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-08 00:27:13 +00:00
paseo-bot[bot] d92495487f fix(website): enable the language menu after hydration (#9046)
The server-rendered trigger accepted clicks before its handler existed. Tie its enabled state to hydration and cover delayed scripts so an early click cannot silently disappear.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-07 21:28:12 +00:00
paseo-bot[bot] 19848899d0 Keep host identity and error details in desktop logs (#9038)
* fix(app): preserve host error details in desktop logs

Bind host context through the existing client logger and emit structured text so desktop console capture retains the error record, including for adopted probe clients.

* fix(app): retain discovered host identity and nested error causes

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-07 21:19:18 +00:00
paseo-bot[bot] 249c35891b fix(app): load History after a browser reload (#9029)
Consume host connection statuses in History instead of a version-only dependency that the React Compiler removes. Keep saved sessions reachable when the host connects after the screen mounts.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-07 20:02:59 +00:00
paseo-bot[bot] b7a308ca00 fix(relay): stop handshake retries when the channel closes (#9025)
Cancel handshake work at the channel close transition instead of waiting for a socket acknowledgment from an unreachable peer.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-07 18:59:08 +00:00
paseo-bot[bot] 063b402d7c test(cli): align worker upgrade checks with protocol 6 (#9033)
The upgrade journey still expected protocol 5 after the worker moved to 6, stopping every main CLI run before adoption coverage. Keep obsolete versions 0 through 4 and exercise newer-worker rejection with version 7.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-07 18:19:31 +00:00
paseo-bot[bot] c908eddce6 Fill controlled form fields through browser tools (#9022)
* fix(desktop): fill controlled form fields before submission

Write text controls through native DOM setters so page-level value tracking sees the input event as a change. Cover state updates, submission, replacement and clearing through the real browser tool journey.

* refactor(desktop): select native field setters with explicit branches

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-07 17:52:53 +00:00
8a14d7b2c3 Collect idle Claude runtimes reopened without a turn (#9019)
Reopening a chat lists its commands, which resumes the agent and starts
a Claude process with no prompt. Claude only sends init with a turn, so
the runtime's background-work state stayed unknown and idle cleanup
vetoed it on every sweep: the process stayed resident until the daemon
restarted.

A process that has not been sent a prompt and has not reported init has
run no turn and owns no background work, so it can stop. Older CLIs
that ran a turn without a roster are still kept.

Fixes #9017

Co-authored-by: Brandon Elliott <923787+Brandon168@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 16:54:27 +00:00
paseo-bot[bot] ab8e1e61ff fix(app): keep the composer ready after clicking Send (#9015)
Pointer submission blurs the editor before sending and clears retained focus intent. Restore focus through the existing default send action for mouse-driven layouts, while keeping touch keyboard dismissal intact.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-07 16:18:07 +00:00
Mohamed Boudra 9131505810 Fill short collapsed chat histories (#8358)
* fix(app): fill short collapsed chat histories

Collapsed whole turns can leave the initial viewport short even when older history exists. Continue paging through measured pages before revealing a fresh chat, while preserving visible history and the reading anchor.

* fix(app): settle padded and stalled chat history

Measure the native history edge using the same content area as viewport filling, and bound child history requests so retry can start after a stalled fetch.

* fix(app): adapt viewport filling to the current timeline

* fix(app): resume history filling after switching tabs
2026-11-07 23:12:08 +07:00
Mohamed Boudra c6f0c24bf6 Make a folded turn's summary line its own chat row (#9009)
* fix(app): make a folded turn's summary line its own chat row

The summary line of a collapsed turn was drawn inside its prompt's row, so the
row gap landed below the summary and the prompt touched it. Turn collapse now
emits a `turn_summary` row between the host and the reply, and one spacing
rule covers every prompt: 16 above whatever answers it (reply, summary row or
tool row). The summary row spaces like the work rows it stands for, 4 above the
reply and flush under a tool host.

This removes the per-row summary lookup, the host revision tracking and the
fragments in the view. A fold reuses a turn's previous summary row object while
its work and open state are unchanged, so mounted rows do not re-render.

* fix(app): keep the turn summary row at its host's timeline position

The chat outline resolves the active prompt from the timeline position of
the row under the reading line. The summary row had none, so the outline lost
its mark while the row sat there. The row now carries its host's position and
is replaced when that position lands.

* fix(app): keep the reader on a folding turn's host, not its summary row

When an older page completes the oldest loaded turn and it folds, the reading
position moves from the hidden rows to the turn's host row, as on main, so
the prompt and its summary row both land in view. The pagination helper now
finds the summary line in the row under the prompt.
2026-11-07 23:00:13 +07:00
paseo-bot[bot] ef7de3d066 test(server): control recursive watcher reconciliation timing (#9010)
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-07 15:01:32 +00:00
Mohamed Boudra b7b38412a0 Keep turn footer timing within the available chat width (#9007)
* fix(app): keep turn footer timing within the chat width

Let visible timing text determine its height within the available chat width instead of sizing an absolute overlay by character count. Keep actions anchored when duration and timestamp labels swap.

* fix(app): keep footer timestamp hover target stable

* fix(app): reserve both wrapped timing label heights
2026-11-07 21:32:14 +07:00
a48bc3aa6f Keep concurrent Codex discovery from failing during startup (#9008)
* Serialize Codex app-server startup by state directory

* Keep Codex session constructor unchanged

* Type Codex startup failures and inject process launch port

* fix(codex): retain startup diagnostics emitted after exit

Classify failed initialization after stdio closes so the bounded SQLite retry sees the complete stderr. Keep running-session exit handling immediate. The regression delivers the SQLite diagnostic between exit and close.

* test(codex): inject history test process through the public dependency

The history fixture still replaced the old private spawn method, so provider launch preparation tried to find a real CLI in CI. Inject the same process interface as other provider tests and use an invalid command as a guard against accidental real launches.

---------

Co-authored-by: Michael Wu <michaelmwu@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Ivan Malison <1246619+colonelpanic8@users.noreply.github.com>
2026-11-07 14:03:47 +00:00
Patrick WangandCopilot 28c01ea1ce fix(terminal): preserve mouse encoding when replaying sessions (#9004)
Restore SGR and pixel mouse encoding alongside tracking modes so Copilot CLI scrolling survives switching conversations.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-11-07 13:21:25 +00:00
Mohamed Boudra a449271d57 Let plugins provide custom sidebar views across hosts (#8906)
* feat(plugin): expose stable sidebar entity data across hosts

Collections return interned references and preserve their arrays when membership and order stay unchanged. Entity selectors share one hook with identity defaults for full snapshots and shallow defaults for selections. The app caches host projections and shares underlying store and host subscriptions. Browser render counts exercise the real session store and plugin provider without memoized rows. Terminal hooks consume the collection source port; daemon-wide terminal discovery is the separate terminal phase.

* feat(plugin): let users select a sidebar view

An exclusive view replaces the sidebar content while the host retains navigation, callouts, and footer. The device remembers the selection, unavailable views fall back to the default, and throwing views provide recovery. Device-scoped plugin settings persist locally with schema validation and revision conflicts. Web E2E and Android tablet QA cover selection, preserved chrome, disabled-view fallback, and error recovery.

* feat(plugin): add sidebar view examples and ref actions

Keep sidebar layout and gestures inside the plugin while routing entity handles to their owning hosts. Examples cover empty projects, pinned workspaces, task sections, nested agents and device-local label folders.

* feat(plugin): observe terminals across connected hosts

Share host-wide terminal observations across plugin surfaces and gate them on terminalCollection. Keep directory observations compatible, and carry creation time in worker v6 state so v5 terminals can drain with an unknown timestamp.
2026-11-07 18:05:31 +07:00
296 changed files with 16733 additions and 3086 deletions
+58 -24
View File
@@ -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) 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, 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.
## The contract
@@ -14,12 +14,20 @@ const handleToggle = () => hold(() => setExpanded((value) => !value));
```
`view.tsx` wraps every timeline row in `DisclosureRow`, which routes `hold` to the strategy's
`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.
`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.
Following output:
- A hold always detaches the bottom anchor controller (`holdForDisclosure`).
- 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.
- 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
@@ -33,13 +41,16 @@ ends at its end and the trigger moves by the difference.
## Web
`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`).
`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.
## Native
On compact layouts, tool calls and tool-call groups open a sheet instead of expanding, so they do
not hold. Summary lines and todo cards expand inline.
not hold. Summary lines, todo cards, plan cards and show more 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
@@ -51,14 +62,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`). |
| 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. |
| 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. |
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
@@ -78,15 +89,38 @@ jumps by the keyboard height after the keyboard settles.
### The hold
`holdRow(rowId, apply)` runs in the toggle's event:
`holdRow(rowId, apply, triggerEdge)` runs in the toggle's event:
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
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
`shouldRestorePosition` says. A row cut off at the top would then let the row below it hold
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.
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.
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
@@ -130,7 +164,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 ten changes for the timeline. Its header lists
`patches/@legendapp+list+3.7.1.patch` carries twelve 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.
+1 -1
View File
@@ -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. 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; 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.
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.
+1 -1
View File
@@ -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. `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. 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.
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.
+7
View File
@@ -570,6 +570,13 @@ 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
+1 -1
View File
@@ -15,7 +15,7 @@ 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. 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. 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.
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.
+2 -2
View File
@@ -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. Older history remains
user-driven by scrolling upward.
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.
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
@@ -198,3 +198,130 @@ test.describe("Turn end", () => {
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,6 +6,7 @@ import { connectSeedClient } from "../support/helpers/seed-client";
import { createTempGitRepo } from "../support/helpers/workspace";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import {
closeActiveAgentWithShortcut,
archiveAgentFromDaemon,
archiveAgentFromSessions,
clickSessionRow,
@@ -20,8 +21,12 @@ 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", () => {
@@ -51,6 +56,34 @@ 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,
@@ -27,15 +27,22 @@ async function holdBranchSuggestions(page: Page) {
await page.routeWebSocket(daemonWsRoutePattern(), (browser) => {
const server = browser.connectToServer();
browser.onMessage((message) => {
const envelope = JSON.parse(message.toString());
if (envelope.type === "session" && envelope.message?.type === "branch_suggestions_request")
requestCount++;
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) => {
const envelope = JSON.parse(message.toString());
if (envelope.type === "session" && envelope.message?.type === "branch_suggestions_response") {
await gate;
if (typeof message === "string") {
const envelope = JSON.parse(message);
if (
envelope.type === "session" &&
envelope.message?.type === "branch_suggestions_response"
) {
await gate;
}
}
browser.send(message);
});
+162 -3
View File
@@ -8,7 +8,11 @@ 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, waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
import {
openChangesPanel,
openChangesTreePanel,
waitForWorkspaceTabsVisible,
} from "../support/helpers/workspace-tabs";
interface DirtyWorkspace {
id: string;
@@ -262,6 +266,126 @@ 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();
@@ -499,6 +623,35 @@ 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,
}) => {
@@ -1311,11 +1464,13 @@ 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(180_000);
test.setTimeout(300_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);
@@ -1894,12 +2049,16 @@ async function createWorkspaceWithTwoSelectionDiffs(): Promise<DirtyWorkspace> {
return { id: created.workspace.id, repoPath: repo.path };
}
async function createWorkspaceWithManyTinyDiffs(fileCount: number): Promise<DirtyWorkspace> {
async function createWorkspaceWithManyTinyDiffs(
fileCount: number,
branch = "main",
): 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`),
@@ -19,6 +19,7 @@ import {
expectWorkspaceHeaderVisible,
resizeComposerViewport,
submitMessage,
submitMessageWithButton,
typeIntoFocusedComposer,
} from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
@@ -146,6 +147,14 @@ test("submitting a message leaves the composer ready for the next message", asyn
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);
@@ -173,7 +182,7 @@ test("submitting a message leaves the composer ready for the next message", asyn
"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 })).toBeVisible();
await expect(page.getByRole("button", { name: "Copy turn", exact: true }).last()).toBeVisible();
await connection.drop();
await connection.waitForBlockedConnection();
await pasteRichTextIntoComposer({
@@ -313,6 +322,9 @@ 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 prepareUnfocusedCompactPane(page: Page, workspaceId: string) {
async function prepareNarrowSplit(page: Page, workspaceId: string) {
await page.setViewportSize({ width: 1600, height: 900 });
await gotoWorkspace(page, workspaceId);
await waitForWorkspaceTabsVisible(page);
@@ -74,33 +74,38 @@ async function prepareUnfocusedCompactPane(page: Page, workspaceId: string) {
};
}
async function launchTerminalFromCompactPane(page: Page, pane: Locator) {
await pane.getByTestId("workspace-tab-switcher-trigger").click();
await page.getByTestId("workspace-tab-switcher-new-terminal").click();
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();
await expect(pane.getByTestId("terminal-surface")).toBeVisible({ timeout: 30_000 });
}
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();
async function maximizeAndRestorePane(pane: Locator) {
await pane.getByTestId("workspace-maximize-pane").click();
await pane.getByTestId("workspace-restore-pane").click();
}
test("compact pane launch stays in its owning pane and pane controls remain reachable", async ({
test("narrow split panes keep their tab rails and launches stay in the owning pane", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "compact-pane-launch-" });
const workspace = await seedWorkspace({ repoPrefix: "narrow-split-launch-" });
try {
const { originalPane, otherPane } = await prepareUnfocusedCompactPane(
page,
workspace.workspaceId,
);
await launchTerminalFromCompactPane(page, originalPane);
const { originalPane, otherPane } = await prepareNarrowSplit(page, workspace.workspaceId);
await expectPanesKeepTabRails(page, [originalPane, otherPane]);
await launchTerminalFromPaneRail(page, originalPane);
await expect(otherPane.getByTestId("workspace-new-tab-panel")).toBeVisible();
await maximizeAndRestoreCompactPane(page, originalPane);
await maximizeAndRestorePane(originalPane);
await expect(otherPane).toBeVisible();
await expectPanesKeepTabRails(page, [originalPane, otherPane]);
} finally {
await workspace.cleanup();
}
@@ -118,17 +123,15 @@ async function expectPaneHasWindowDragSurface(pane: Locator) {
.toBe(true);
}
test("compact desktop panes retain window dragging with both sidebars closed", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "compact-pane-window-drag-" });
test("narrow desktop split panes retain window dragging with both sidebars closed", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "narrow-split-window-drag-" });
try {
await installLocalDesktopHost(page, workspace.repoPath);
const { originalPane, otherPane } = await prepareUnfocusedCompactPane(
page,
workspace.workspaceId,
);
const { originalPane, otherPane } = await prepareNarrowSplit(page, workspace.workspaceId);
await runWorkspaceActionFromCommandCenter(page, "Toggle left sidebar");
await expect(originalPane.getByTestId("workspace-tab-switcher-trigger")).toBeVisible();
await expect(otherPane.getByTestId("workspace-tab-switcher-trigger")).toBeVisible();
await expectPanesKeepTabRails(page, [originalPane, otherPane]);
await expectPaneHasWindowDragSurface(originalPane);
await expectPaneHasWindowDragSurface(otherPane);
} finally {
@@ -79,6 +79,12 @@ 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");
@@ -100,6 +106,7 @@ 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,6 +1,7 @@
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,
@@ -69,6 +70,8 @@ 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();
@@ -60,6 +60,15 @@ 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`);
@@ -151,6 +160,8 @@ test.describe("History search", () => {
await verifyChronologicalFiltering(page);
await verifyHistorySurvivesReload(page);
await verifyRecencyBeforeMatchStrength(page);
await verifyExactAndTypoHighlights(page);
@@ -95,66 +95,63 @@ test.describe("Settings sidebar scroll position", () => {
await expectSettingsSidebarScrollOffset(page, offset);
});
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 ({
test("keeps sidebar and detail scrolling independent through section changes", async ({
page,
}) => {
await gotoAppShell(page);
await openSettings(page);
const hostScrollTop = await scrollSettingsSidebarToEnd(page);
await expect(page.getByTestId("settings-host-section-providers")).toBeInViewport();
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);
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 scrollSettingsSidebarUp(page, 60);
await expectSettingsSidebarScrollOffset(page, sidebarOffset - 60);
await expectSettingsDetailScrollOffset(page, detailOffset);
});
const sidebar = page.getByTestId("settings-sidebar");
const appScrollTop = await revealSettingsSidebarSection(page, "diagnostics");
await expect(sidebar.getByRole("button", { name: "About", exact: true })).toBeInViewport();
await test.step("keeps the sidebar position after rapid consecutive selections", async () => {
const scrollTop = await scrollSettingsSidebarToEnd(page);
await openSettingsSection(page, "about");
await expectAboutContent(page);
await expectSettingsSidebarScrollOffset(page, appScrollTop);
await selectSettingsHostSectionsRapidly(page);
await expectSettingsSidebarScrollOffset(page, scrollTop);
await openSettingsSection(page, "diagnostics");
await expectDiagnosticsContent(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);
});
});
});
@@ -0,0 +1,18 @@
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();
});
});
@@ -6,6 +6,7 @@ import {
closeMobileAgentSidebar,
expectMobileAgentSidebarHidden,
expectMobileAgentSidebarVisible,
expectOnlyWorkspaceHoverCardFor,
expectWorkspaceHoverCardClosed,
expectWorkspaceHoverCardOpen,
expectWorkspaceHoverCardStaysOpen,
@@ -14,6 +15,7 @@ import {
openMobileAgentSidebar,
pinWorkspaceFromSidebar,
scrollWorkspaceList,
sweepPointerAcrossWorkspaceRows,
} from "../support/helpers/sidebar";
import { seedWorkspace } from "../support/helpers/seed-client";
import { expectWorkspaceHeader } from "../support/helpers/workspace-ui";
@@ -80,6 +82,22 @@ 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;
@@ -430,6 +448,38 @@ 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-" });
+84 -10
View File
@@ -63,6 +63,26 @@ async function rowsBetweenLatestSummaryAndReply(
}, 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[]> {
return page.evaluate(() => {
const rows = [...document.querySelectorAll("[data-message-id]")];
@@ -208,6 +228,7 @@ 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);
@@ -470,34 +491,87 @@ async function seedWholeTurnHistory() {
return agent;
}
test("loads complete long turns and preserves expansion while revealing older history", async ({
page,
}) => {
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 latest.scrollIntoViewIfNeeded();
await scrollTimelineRowIntoView(page, latest);
await latest.click();
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
} finally {
@@ -0,0 +1,131 @@
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();
}
});
});
@@ -219,6 +219,11 @@ 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,
@@ -237,6 +242,10 @@ 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));
@@ -17,6 +17,8 @@ type NewWorkspaceDaemonClient = Pick<
| "connect"
| "createPaseoWorktree"
| "createWorkspace"
| "createTerminal"
| "killTerminal"
| "fetchAgents"
| "fetchWorkspaces"
| "getPaseoWorktreeList"
@@ -34,6 +36,7 @@ type NewWorkspaceDaemonClient = Pick<
| "removePlugin"
| "reloadPlugin"
| "setWorkspaceTitle"
| "setWorkspaceLabel"
>;
type CreateWorkspacePayload = Awaited<ReturnType<NewWorkspaceDaemonClient["createWorkspace"]>>;
@@ -0,0 +1,191 @@
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,6 +96,35 @@ 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,8 +575,10 @@ 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 host row carries the folded turn's summary line.
await expect(promptRow.getByTestId("turn-summary")).toBeVisible();
// 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();
await expect
.poll(
async () => {
@@ -0,0 +1,147 @@
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);
});
});
+10 -6
View File
@@ -1,11 +1,15 @@
import { createContext, useContext, useMemo, type ReactNode, type RefObject } from "react";
import type { StreamViewportHandle } from "./strategy";
import type { DisclosureTriggerEdge, StreamViewportHandle } from "./strategy";
/**
* 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.
* 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"`.
*/
export type DisclosureHold = (apply: () => void) => void;
export type DisclosureHold = (
apply: () => void,
options?: { triggerEdge?: DisclosureTriggerEdge },
) => void;
const applyInPlace: DisclosureHold = (apply) => apply();
@@ -21,10 +25,10 @@ export function DisclosureRow({
children: ReactNode;
}) {
const hold = useMemo<DisclosureHold>(
() => (apply) => {
() => (apply, options) => {
const viewport = viewportRef.current;
if (viewport) {
viewport.holdRow(rowId, apply);
viewport.holdRow(rowId, apply, options?.triggerEdge ?? "top");
} else {
apply();
}
@@ -1,197 +1,310 @@
import { describe, expect, it } from "vitest";
import { nativeHistoryStartGeometry } from "./native-history-start-geometry";
import { describe, expect, it, vi } from "vitest";
import {
abandonHistoryStartPaginationRequest,
createHistoryStartPaginationState,
evaluateHistoryStartPagination,
isHistoryStartLoadingOperation,
rearmHistoryStartPagination,
settleHistoryStartPagination,
createHistoryStartPagination,
type HistoryStartPaginationInput,
} from "./history-start-pagination";
function createArmedHistoryStartPaginationState() {
return rearmHistoryStartPagination(createHistoryStartPaginationState());
}
const visibleHistoryStart: HistoryStartPaginationInput = {
const initial: HistoryStartPaginationInput = {
distanceFromHistoryStart: 0,
isUnderfilled: true,
hasOlderHistory: true,
isLoadingOlderHistory: false,
isReady: true,
progressKey: "epoch-1:20",
progressKey: "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("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",
});
expect(first.shouldLoad).toBe(true);
expect(localReveal.state).toEqual({
status: "settling",
loadedProgressKey: "epoch-1:20:local-60",
});
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 user scroll intent before loading", () => {
const dormant = createHistoryStartPaginationState();
const evaluated = evaluateHistoryStartPagination(dormant, visibleHistoryStart);
expect(evaluated).toEqual({ state: { status: "dormant" }, shouldLoad: false });
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("waits for anchored page geometry before authorizing another page", () => {
const first = evaluateHistoryStartPagination(
createArmedHistoryStartPaginationState(),
visibleHistoryStart,
);
const inFlight = evaluateHistoryStartPagination(first.state, {
...visibleHistoryStart,
isLoadingOlderHistory: true,
});
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("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("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("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("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("keeps visible history visible while filling", () => {
const h = harness(true);
h.pagination.update();
expect(h.pagination.state.visible).toBe(true);
h.pagination.dispose();
});
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("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("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]);
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,
});
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: () => {},
});
pagination.rearm();
expect(requests).toBe(0);
offsetY = 155;
pagination.update();
expect(requests).toBe(1);
pagination.dispose();
});
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: () => {},
});
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();
}
},
);
@@ -1,138 +1,149 @@
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 HistoryStartPaginationTransition {
state: HistoryStartPaginationState;
shouldLoad: boolean;
}
export function createHistoryStartPaginationState(): HistoryStartPaginationState {
return { status: "dormant" };
export interface HistoryStartPaginationState {
status: "dormant" | "ready" | "loading" | "settling" | "failed";
visible: boolean;
}
export function isHistoryStartLoadingOperation(state: HistoryStartPaginationState): boolean {
return state.status === "loading" || state.status === "settling";
}
export function rearmHistoryStartPagination(
state: HistoryStartPaginationState,
): HistoryStartPaginationState {
return state.status === "dormant" || state.status === "latched" ? { status: "ready" } : state;
}
/** 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 abandonHistoryStartPaginationRequest(
state: HistoryStartPaginationState,
requestedProgressKey: string,
): HistoryStartPaginationState {
if (
state.status !== "loading" ||
state.requestObserved ||
state.requestedProgressKey !== requestedProgressKey
) {
return state;
}
return { status: "latched" };
}
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 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,
};
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);
}
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 };
}
};
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 };
}
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();
},
);
};
return {
state: {
status: "loading",
requestedProgressKey: input.progressKey,
requestObserved: false,
get state() {
return state;
},
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,
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,
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,
};
}
@@ -3,6 +3,7 @@ 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.
@@ -130,7 +131,16 @@ function mountList(heldRowId: string) {
return position - state.scroll;
};
const rowSize = (rowId: string) => listRef.current?.getState().sizes.get(rowId);
return { render, scrollTo, heldRowTop, rowSize };
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 };
}
it("anchors a data change on a held row whose top is above the viewport", async () => {
@@ -171,6 +181,72 @@ 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,5 +1,10 @@
import { describe, expect, it } from "vitest";
import { createNativeDisclosureHold } from "./native-disclosure-hold";
import {
createNativeDisclosureHold,
disclosureAnchor,
createEndHoldDistance,
type DisclosureAnchor,
} from "./native-disclosure-hold";
function createFrames() {
let nextId = 1;
@@ -39,22 +44,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("turn-1");
hold.hold({ kind: "row", rowId: "turn-1" });
frames.runFrame();
expect(hold.heldRowId()).toBe("turn-1");
expect(hold.anchorRowId()).toBe("turn-1");
expect(releases).toEqual([]);
frames.runFrame();
expect(hold.heldRowId()).toBeNull();
expect(hold.anchorRowId()).toBeNull();
expect(releases).toEqual(["released"]);
});
it("restarts the quiet frames on every item size change", () => {
const { frames, hold, releases } = createHarness();
hold.hold("turn-1");
hold.hold({ kind: "row", rowId: "turn-1" });
frames.runFrame();
hold.noteItemSizeChanged();
frames.runFrame();
@@ -79,11 +84,11 @@ describe("native disclosure hold", () => {
it("moves the hold to the latest toggled row and releases once", () => {
const { frames, hold, releases } = createHarness();
hold.hold("turn-1");
hold.hold({ kind: "row", rowId: "turn-1" });
frames.runFrame();
hold.hold("turn-2");
hold.hold({ kind: "row", rowId: "turn-2" });
expect(hold.heldRowId()).toBe("turn-2");
expect(hold.anchorRowId()).toBe("turn-2");
frames.runFrame();
frames.runFrame();
@@ -94,12 +99,137 @@ describe("native disclosure hold", () => {
it("drops the hold without releasing when cancelled", () => {
const { frames, hold, releases } = createHarness();
hold.hold("turn-1");
hold.hold({ kind: "row", rowId: "turn-1" });
hold.cancel();
frames.runFrame();
frames.runFrame();
expect(hold.heldRowId()).toBeNull();
expect(hold.anchorRowId()).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,13 +1,30 @@
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 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.
* 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.
*/
export interface NativeDisclosureHold {
heldRowId(): string | null;
hold(rowId: string): void;
/** 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;
noteItemSizeChanged(): void;
/** Drops the hold without releasing it. */
cancel(): void;
}
@@ -17,9 +34,10 @@ export function createNativeDisclosureHold(input: {
cancelFrame: (id: number) => void;
onRelease: () => void;
}): NativeDisclosureHold {
let heldRowId: string | null = null;
let anchor: DisclosureAnchor | null = null;
let frameId: number | null = null;
let remainingFrames = 0;
let waitingToggle: { frameId: number; land: () => void } | null = null;
const stopCountdown = () => {
if (frameId !== null) {
@@ -35,7 +53,7 @@ export function createNativeDisclosureHold(input: {
return;
}
frameId = null;
heldRowId = null;
anchor = null;
input.onRelease();
};
@@ -45,20 +63,80 @@ 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 {
heldRowId: () => heldRowId,
hold(rowId) {
heldRowId = rowId;
restartCountdown();
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()) };
},
noteItemSizeChanged() {
if (heldRowId !== null) {
if (anchor !== null) {
restartCountdown();
}
},
cancel() {
landWaitingToggle();
stopCountdown();
heldRowId = null;
anchor = 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;
}
@@ -0,0 +1,12 @@
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,
};
}
@@ -608,8 +608,11 @@ it("folds work before a plugin-rendered reply and leaves the reply visible", ()
display: "collapseTurns",
isTurnActive: false,
});
expect(result.tail.map((item) => item.id)).toEqual(["prompt", "probe-plugin/reply/0"]);
expect([...result.turns.turnsByHostId.keys()]).toEqual(["prompt"]);
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" }]);
});
@@ -636,12 +639,14 @@ it("keeps streaming replies native, then folds work without losing manual expans
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",
]);
@@ -649,6 +654,7 @@ it("keeps streaming replies native, then folds work without losing manual expans
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",
]);
});
@@ -675,7 +681,7 @@ it.each(["full", "collapseToolCalls"] as const)(
"tool-one",
"probe-plugin/reply/0",
]);
expect(result.turns.turnsByHostId.size).toBe(0);
expect(result.turns.summaryRowsByHostId.size).toBe(0);
},
);
@@ -697,7 +703,13 @@ 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(tail.map((item) => item.id));
expect(result.tail.map((item) => item.id)).toEqual([
"prompt",
"turn-summary:prompt",
"tool-one",
"tool-two",
"reply",
]);
expect(result.groupsByHostId.size).toBe(0);
expect(result.readingRowReplacements.get("tool-two")).toBeUndefined();
});
@@ -739,7 +751,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.
// Both the original partial group and its replacement resolve directly to the turn's host.
expect(folded.readingRowReplacements.get(calls[1]!.id)).toBe("prompt");
expect(anchor.getRowId(folded.readingRowReplacements)).toBe("prompt");
const opened = present({ ...input, tail: calls, display: "full" });
@@ -788,7 +800,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", "reply"]);
expect(result.tail.map((item) => item.id)).toEqual(["prompt", "turn-summary:prompt", "reply"]);
});
// A sent prompt counts as activity before the daemon opens its turn, so the only known turn is
@@ -810,7 +822,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", "reply"]);
expect(result.tail.map((item) => item.id)).toEqual(["prompt", "turn-summary:prompt", "reply"]);
});
it("groups reasoning with tools unless Expand thinking is enabled, including setting changes", () => {
@@ -867,7 +879,13 @@ 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).toEqual(tail);
expect(opened.tail.map((item) => item.id)).toEqual([
"prompt",
"turn-summary:prompt",
"thought",
"tool-one",
"reply",
]);
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,
previousTurnsByHostId: new Map(),
previousSummaryRowsByHostId: new Map(),
});
/** Source messages reach plugins before display projection. */
@@ -135,7 +135,8 @@ export function createStreamPresentation() {
isLatestTurnOpen,
expandedHostIds: input.expandedTurnHostIds,
getGroupedCalls: (itemId) => projection.groupsByHostId.get(itemId)?.run.calls,
previousTurnsByHostId: collapsed?.turnsByHostId ?? NO_COLLAPSED_TURNS.turnsByHostId,
previousSummaryRowsByHostId:
collapsed?.summaryRowsByHostId ?? NO_COLLAPSED_TURNS.summaryRowsByHostId,
});
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 }, replacements)).toBe(1000);
expect(anchor.project(0, { id: "prompt", top: 1048, height: 100 }, 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,13 +59,32 @@ 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]!);
anchor.pin(rows[2]!, "top", 600);
// 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);
@@ -109,8 +128,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 })).toBe(2440);
expect(anchor.project(440, { id: "reading", top: 2500 })).toBe(2440);
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.reconcile(
440,
@@ -1,9 +1,14 @@
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 {
@@ -14,7 +19,16 @@ 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() {
let anchor: { id: string; top: number } | null = null;
// 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 geometry: readonly RowGeometry[] = [];
const readingRow = (scrollTop: number) =>
geometry.find((row) => row.top + row.height > scrollTop + READING_POSITION_OFFSET_PX);
@@ -22,9 +36,12 @@ export function createReadingAnchor() {
anchor ? (replacements.get(anchor.id) ?? anchor.id) : null;
const project = (
scrollTop: number,
row: Pick<RowGeometry, "id" | "top"> | undefined,
row: RowGeometry | undefined,
replacements = NO_REPLACEMENTS,
) => scrollTop + (anchor && row && row.id === getRowId(replacements) ? row.top - anchor.top : 0);
) =>
anchor && row && row.id === getRowId(replacements)
? (anchor.pinnedScrollTop ?? scrollTop) + edgeOf(row, anchor.edge) - anchor.position
: scrollTop;
return {
getRowId,
getReadingRowId: (scrollTop: number) =>
@@ -33,14 +50,14 @@ export function createReadingAnchor() {
reset() {
anchor = null;
},
// 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 };
// 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 };
},
scroll(scrollTop: number) {
const next = readingRow(scrollTop);
anchor = next ? { id: next.id, top: next.top } : null;
anchor = next ? anchorTop(next) : null;
},
reconcile(
scrollTop: number,
@@ -57,7 +74,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 ? { id: next.id, top: next.top } : null;
anchor = next ? anchorTop(next) : null;
return correctedTop;
},
};
@@ -1,5 +1,6 @@
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",
@@ -7,6 +8,27 @@ 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");
@@ -17,4 +39,18 @@ 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),
);
});
});
+12 -11
View File
@@ -11,8 +11,12 @@ export function getAssistantMessageSpacing(params: {
}
const isUserMessageItem = (item?: StreamItem | null) => item?.kind === "user_message";
const isToolSequenceItem = (item?: StreamItem | null) =>
item?.kind === "tool_call" || item?.kind === "thought" || item?.kind === "todo_list";
/** 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";
export function getGapBetweenStreamItems(
item: StreamItem | null,
@@ -25,19 +29,16 @@ export function getGapBetweenStreamItems(
if (isUserMessageItem(item) && isUserMessageItem(belowItem)) {
return SPACING[1];
}
if (item.kind === "user_message" && belowItem.kind === "assistant_message") {
return 0;
}
if (isToolSequenceItem(item) && isToolSequenceItem(belowItem)) {
return 0;
}
if (item.kind === "user_message" && isToolSequenceItem(belowItem)) {
if (isUserMessageItem(item)) {
return SPACING[4];
}
if (item.kind === "assistant_message" && isToolSequenceItem(belowItem)) {
if (isWorkItem(item) && isWorkItem(belowItem)) {
return 0;
}
if (item.kind === "assistant_message" && isWorkItem(belowItem)) {
return SPACING[1];
}
if (isToolSequenceItem(item) && belowItem.kind === "assistant_message") {
if (isWorkItem(item) && belowItem.kind === "assistant_message") {
return SPACING[1];
}
return SPACING[4];
+192 -120
View File
@@ -25,6 +25,9 @@ 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";
@@ -32,21 +35,27 @@ 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 } from "./native-disclosure-hold";
import {
createNativeDisclosureHold,
disclosureAnchor,
createEndHoldDistance,
type EndHoldDistance,
} from "./native-disclosure-hold";
import { EndPinnedScrollView } from "./native-end-pin";
import { createNewRowEstimates } from "./new-row-estimates";
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy";
import type {
DisclosureTriggerEdge,
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,
@@ -108,6 +117,16 @@ 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;
@@ -125,6 +144,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
viewportRef,
routeBottomAnchorRequest,
isAuthoritativeHistoryReady,
isInitialHistoryLoading,
revealInitialHistory,
onNearBottomChange,
onNearHistoryStart,
isLoadingOlderHistory,
@@ -135,6 +156,8 @@ 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.
@@ -157,10 +180,6 @@ 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(() => {
@@ -191,48 +210,36 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
metrics.viewportMeasuredForKey === metrics.containerKey &&
metrics.contentMeasuredForKey === metrics.containerKey;
return {
distanceFromHistoryStart: metrics.offsetY,
...nativeHistoryStartGeometry({
contentHeight: metrics.contentHeight,
viewportHeight: metrics.viewportHeight,
offsetY: metrics.offsetY,
topOverlayInset,
}),
hasOlderHistory,
isLoadingOlderHistory,
isReady: historyStartReadyRef.current && hasMeasuredViewport,
isReady:
isActive &&
isAuthoritativeHistoryReady &&
historyStartReadyRef.current &&
hasMeasuredViewport,
progressKey: olderHistoryProgressKey,
};
});
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 { 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 evaluateHistoryStart = useStableEvent(() => {
const transition = evaluateHistoryStartPagination(
historyStartPaginationStateRef.current,
getHistoryStartPaginationInput(),
);
applyHistoryStartPaginationTransition(transition);
});
const finishInitialFill = useStableEvent(() => historyStartPagination.reveal());
const retryHistoryStart = useStableEvent(() => historyStartPagination.rearm());
const scheduleHistoryStartSettle = useStableEvent(() => {
let scheduler = historyStartSettleSchedulerRef.current;
if (!scheduler) {
@@ -240,15 +247,9 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
settleFrames: HISTORY_START_SETTLE_FRAMES,
requestFrame: requestAnimationFrame,
cancelFrame: cancelAnimationFrame,
isSettling: () => historyStartPaginationStateRef.current.status === "settling",
isSettling: () => isHistoryStartSettling(),
isLoading: () => getHistoryStartPaginationInput().isLoadingOlderHistory,
onSettle: () => {
const transition = settleHistoryStartPagination(
historyStartPaginationStateRef.current,
getHistoryStartPaginationInput(),
);
applyHistoryStartPaginationTransition(transition);
},
onSettle: () => historyStartPagination.settle(),
});
historyStartSettleSchedulerRef.current = scheduler;
}
@@ -315,8 +316,14 @@ 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(() => {
bottomAnchorController.releaseDisclosure({ isAtEnd: isAtEnd() });
setEndHold(null);
bottomAnchorController.releaseDisclosure({
isAtEnd: endHold ? endHold.distance.current() <= AT_END_OFFSET_PX : isAtEnd(),
});
});
const [disclosureHold] = useState(() =>
createNativeDisclosureHold({
@@ -326,16 +333,54 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
onRelease: releaseDisclosure,
}),
);
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. 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 cancelDisclosureHold = useStableEvent(() => {
disclosureHold.cancel();
setEndHold(null);
});
useEffect(() => () => cancelDisclosureHold(), [cancelDisclosureHold]);
// 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();
},
);
},
);
const handleItemSizeChanged = useStableEvent(
(info: { size: number; previous: number; itemKey: string; itemData: StreamItem }) => {
newRowEstimates.noteMeasured(info.itemData, info.size);
@@ -343,8 +388,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
},
);
const shouldRestorePosition = useStableEvent((item: StreamItem) => {
const heldRowId = disclosureHold.heldRowId();
return heldRowId === null || item.id === heldRowId;
const anchorRowId = disclosureHold.anchorRowId();
return anchorRowId === null || item.id === anchorRowId;
});
// 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
@@ -358,16 +403,23 @@ 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.
// 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.
// - 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";
const pinsEnd = isFollowingEnd && isListPlaced;
// 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 maintainVisibleContentPosition = useMemo(
() => (isFollowingEnd ? false : { data: true, size: true, shouldRestorePosition }),
[isFollowingEnd, shouldRestorePosition],
() => (isEndPinned ? false : { data: true, size: true, shouldRestorePosition }),
[isEndPinned, shouldRestorePosition],
);
// A new identity re-renders LegendList's scroll component, which is how the pin follows the mode.
const renderScrollComponent = useCallback(
@@ -390,10 +442,11 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
return () => {
cancelAnimationFrame(frame);
clearPendingUserScrollEnd();
disposeHistoryStart();
historyStartSettleSchedulerRef.current?.cancel();
historyStartSettleSchedulerRef.current = null;
};
}, [clearPendingUserScrollEnd, evaluateHistoryStart]);
}, [clearPendingUserScrollEnd, disposeHistoryStart, evaluateHistoryStart]);
useEffect(() => {
const handle: StreamViewportHandle = {
@@ -506,22 +559,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.
disclosureHold.cancel();
cancelDisclosureHold();
bottomAnchorController.beginUserScroll();
const rearmed = rearmHistoryStartPagination(historyStartPaginationStateRef.current);
if (rearmed !== historyStartPaginationStateRef.current) {
historyStartPaginationStateRef.current = rearmed;
setHistoryStartPaginationState(rearmed);
evaluateHistoryStart();
}
historyStartPagination.rearm();
});
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
@@ -589,6 +642,7 @@ 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 (
@@ -598,7 +652,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
publishNearBottom();
}
evaluateHistoryStart();
if (historyStartPaginationStateRef.current.status === "settling") {
if (isHistoryStartSettling()) {
scheduleHistoryStartSettle();
}
});
@@ -611,19 +665,23 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
contentMeasuredForKey: "native-virtualized",
};
bottomAnchorController.handleContentSizeChange();
evaluateHistoryStart();
});
useEffect(() => {
evaluateHistoryStart();
if (historyStartPaginationStateRef.current.status === "settling") {
if (isHistoryStartSettling()) {
scheduleHistoryStartSettle();
}
}, [
evaluateHistoryStart,
hasOlderHistory,
isActive,
isAuthoritativeHistoryReady,
isLoadingOlderHistory,
olderHistoryProgressKey,
scheduleHistoryStartSettle,
isHistoryStartSettling,
]);
// LegendList re-renders a mounted row only when its item or `extraData` changes, not when
@@ -658,51 +716,65 @@ 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>
) : null}
)}
{!isLoadingOperation &&
historyStartPaginationState.status === "failed" &&
hasOlderHistory && (
<Button variant="ghost" size="sm" onPress={retryHistoryStart}>
{t("paneFind.retry")}
</Button>
)}
</View>
);
}, [historyStartPaginationState]);
}, [historyStartPaginationState, hasOlderHistory, retryHistoryStart, t]);
return (
<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}
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
/>
<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>
);
}
@@ -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 { StreamRenderInput, StreamViewportHandle } from "./strategy";
import type { DisclosureTriggerEdge, 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: () => true,
onNearHistoryStart: async () => true,
isLoadingOlderHistory: false,
hasOlderHistory: false,
olderHistoryProgressKey: null,
@@ -91,10 +91,15 @@ function mountTimeline(historyMounted: StreamItem[]) {
return {
render: (segments: Partial<StreamRenderInput["segments"]>) => act(() => render(segments)),
hold: (rowId: string, apply: () => void) =>
act(() => viewportRef.current!.holdRow(rowId, apply)),
hold: (rowId: string, apply: () => void, triggerEdge: DisclosureTriggerEdge = "top") =>
act(() => viewportRef.current!.holdRow(rowId, apply, triggerEdge)),
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.
@@ -139,6 +144,53 @@ 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: () => true,
onNearHistoryStart: async () => true,
isLoadingOlderHistory: false,
hasOlderHistory: false,
olderHistoryProgressKey: null,
@@ -274,7 +274,7 @@ describe("createWebStreamStrategy", () => {
routeBottomAnchorRequest: null,
isAuthoritativeHistoryReady: true,
onNearBottomChange: () => {},
onNearHistoryStart: () => true,
onNearHistoryStart: async () => true,
isLoadingOlderHistory: false,
hasOlderHistory: false,
olderHistoryProgressKey: null,
+78 -79
View File
@@ -20,6 +20,7 @@ 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";
@@ -30,15 +31,10 @@ 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,
@@ -77,7 +73,17 @@ 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",
@@ -298,6 +304,8 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
viewportRef,
routeBottomAnchorRequest,
isAuthoritativeHistoryReady,
isInitialHistoryLoading,
revealInitialHistory,
onNearBottomChange,
onReadingPositionChange,
onNearHistoryStart,
@@ -318,6 +326,9 @@ 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);
@@ -351,10 +362,6 @@ 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);
@@ -411,6 +418,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
{
id: String(measurement.key),
top: measurement.start,
height: measurement.size,
},
props.readingRowReplacements,
)
@@ -481,60 +489,36 @@ 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 && bottomAnchorSettled && !isJumpSettling(),
isReady:
historyStartReadyRef.current &&
isAuthoritativeHistoryReady &&
bottomAnchorSettled &&
!isJumpSettling(),
progressKey: olderHistoryProgressKey,
};
});
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 { 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 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 finishInitialFill = useStableEvent(() => historyStartPagination.reveal());
const rearmHistoryStartFromUserIntent = useStableEvent(() => historyStartPagination.rearm());
const scheduleHistoryStartPrependSettle = useStableEvent(() => {
let scheduler = historyStartSettleSchedulerRef.current;
if (!scheduler) {
@@ -542,22 +526,12 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
settleFrames: HISTORY_START_SETTLE_FRAMES,
requestFrame: (callback) => window.requestAnimationFrame(callback),
cancelFrame: (frame) => window.cancelAnimationFrame(frame),
isSettling: () => historyStartPaginationStateRef.current.status === "settling",
isSettling: () => isHistoryStartSettling(),
isLoading: () => {
const input = getHistoryStartPaginationInput();
return !input || input.isLoadingOlderHistory;
},
onSettle: () => {
const input = getHistoryStartPaginationInput();
if (!input) {
return;
}
const transition = settleHistoryStartPagination(
historyStartPaginationStateRef.current,
input,
);
applyHistoryStartPaginationTransition(transition);
},
onSettle: () => historyStartPagination.settle(),
});
historyStartSettleSchedulerRef.current = scheduler;
}
@@ -794,6 +768,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
]);
const stopFollowingOutputFromUserIntent = useStableEvent(() => {
finishInitialFill();
cancelPendingStickToBottom();
if (followOutputRef.current) {
setFollowOutput(false);
@@ -801,6 +776,10 @@ 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)) {
@@ -846,10 +825,11 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
return () => {
window.cancelAnimationFrame(frame);
historyStartReadyRef.current = false;
disposeHistoryStart();
historyStartSettleSchedulerRef.current?.cancel();
historyStartSettleSchedulerRef.current = null;
};
}, [evaluateHistoryStart]);
}, [disposeHistoryStart, evaluateHistoryStart]);
useLayoutEffect(() => {
if (!isActiveRef.current || !isActivationReady) {
@@ -938,16 +918,18 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
updateScrollMetrics();
}
evaluateHistoryStart();
if (historyStartPaginationStateRef.current.status === "settling") {
if (isHistoryStartSettling()) {
scheduleHistoryStartPrependSettle();
}
}, [
evaluateHistoryStart,
hasOlderHistory,
isActive,
isAuthoritativeHistoryReady,
isLoadingOlderHistory,
olderHistoryProgressKey,
scheduleHistoryStartPrependSettle,
isHistoryStartSettling,
segments.historyMounted.length,
segments.historyVirtualized.length,
segments.liveHead.length,
@@ -986,7 +968,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
lastObservedViewportGeometryRef.current = nextGeometry;
}
reconcileReadingPosition();
if (historyStartPaginationStateRef.current.status === "settling") {
if (isHistoryStartSettling()) {
scheduleHistoryStartPrependSettle();
}
updateScrollMetrics();
@@ -1020,6 +1002,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
isActive,
reportReadingPosition,
scheduleHistoryStartPrependSettle,
isHistoryStartSettling,
forceStickToBottom,
updateScrollMetrics,
measureVirtualizedRows,
@@ -1190,11 +1173,12 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}
scheduleStickToBottom();
},
holdRow: (rowId, apply) => {
holdRow: (rowId, apply, triggerEdge) => {
cancelPendingStickToBottom();
setFollowOutput(false);
const row = measureRows()?.find((candidate) => candidate.id === rowId);
if (row) readingAnchor.pin(row);
const scrollTop = scrollContainerRef.current?.scrollTop;
if (row && scrollTop !== undefined) readingAnchor.pin(row, triggerEdge, scrollTop);
disclosurePendingRef.current = true;
apply();
},
@@ -1241,8 +1225,9 @@ 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, isMobileBreakpoint, scrollEnabled]);
}, [followOutput, historyStartPaginationState.visible, isMobileBreakpoint, scrollEnabled]);
const viewportStyle = useMemo(
(): CSSProperties => ({
position: "relative",
@@ -1319,14 +1304,21 @@ 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>
) : null}
)}
{!isLoadingOperation &&
historyStartPaginationState.status === "failed" &&
hasOlderHistory && (
<button type="button" onClick={rearmHistoryStartFromUserIntent}>
{t("paneFind.retry")}
</button>
)}
</div>
);
}, [historyStartPaginationState]);
}, [historyStartPaginationState, hasOlderHistory, rearmHistoryStartFromUserIntent, t]);
const shouldRenderEmpty =
!boundary.hasMountedHistory &&
!boundary.hasVirtualizedHistory &&
@@ -1346,7 +1338,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
>
<div ref={handleContentRef} style={contentContainerStyle}>
{historyStartSlot}
<div style={streamRowsHostStyle}>
<div ref={rowsHostRef} style={streamRowsHostStyle}>
<div style={virtualRowsContainerStyle} />
{[
...virtualRows.map((virtualRow) => {
@@ -1374,10 +1366,17 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
...mountedRows,
]}
</div>
{liveAuxiliary}
<div ref={auxiliaryRef} style={streamRowStyle}>
{liveAuxiliary}
</div>
{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}
+13 -4
View File
@@ -46,16 +46,23 @@ 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 its visual top.
* 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 the edge the trigger
* sits on in place. Following output stops; it resumes if the change leaves the list at its end.
*/
holdRow: (rowId: string, apply: () => void) => void;
holdRow: (rowId: string, apply: () => void, triggerEdge: DisclosureTriggerEdge) => void;
scrollToMessage?: (messageId: string, occurrence?: ScrollToMessageOccurrence) => void;
}
@@ -85,11 +92,13 @@ 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: () => boolean | Promise<boolean>;
onNearHistoryStart: () => Promise<boolean>;
isLoadingOlderHistory: boolean;
hasOlderHistory: boolean;
olderHistoryProgressKey: string | null;
@@ -56,7 +56,7 @@ function collapse(tail: StreamItem[], options: Partial<CollapseTurnsInput> = {})
isLatestTurnOpen: false,
expandedHostIds: new Set(),
getGroupedCalls: () => undefined,
previousTurnsByHostId: new Map(),
previousSummaryRowsByHostId: new Map(),
...options,
});
}
@@ -73,17 +73,29 @@ describe("collapseTurns", () => {
pluginReply("final", "right"),
];
const result = collapse(items);
expect(ids(result.tail)).toEqual(["prompt", "math/final/left", "math/final/right"]);
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(expanded.tail).toEqual(items);
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", "math/final/0"]);
expect(ids(result.tail)).toEqual(["prompt", "turn-summary:prompt", "math/final/0"]);
expect(result.hostIdByFoldedMessageId.get("intermediate")).toBe("prompt");
});
@@ -93,7 +105,7 @@ describe("collapseTurns", () => {
const card = { ...pluginReply("card"), sourceKind };
const items = [user("prompt"), shell("call"), card];
expect(collapse(items).tail).toEqual(items);
expect(collapse(items).turnsByHostId.size).toBe(0);
expect(collapse(items).summaryRowsByHostId.size).toBe(0);
},
);
@@ -102,38 +114,41 @@ describe("collapseTurns", () => {
expect(collapse(items, { isLatestTurnOpen: true }).tail).toEqual(items);
});
// 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")], {
// 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")], {
isLatestTurnOpen: true,
});
expect([...open.updatedHostIds]).toEqual(["u1"]);
const closed = collapse(
[user("u1"), shell("c1"), reply("r1"), user("u2"), shell("c2"), reply("r2")],
{
previousTurnsByHostId: open.turnsByHostId,
},
{ previousSummaryRowsByHostId: first.summaryRowsByHostId },
);
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);
expect(closed.summaryRowsByHostId.get("u1")).toBe(first.summaryRowsByHostId.get("u1"));
const moreWork = collapse(
[user("u1"), shell("c1"), reply("r1"), user("u2"), shell("c2"), shell("c3"), reply("r2")],
{ previousTurnsByHostId: unchanged.turnsByHostId },
{ previousSummaryRowsByHostId: closed.summaryRowsByHostId },
);
expect([...moreWork.updatedHostIds]).toEqual(["u2"]);
const rewound = collapse([user("u1"), shell("c1"), reply("r1")], {
previousTurnsByHostId: moreWork.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([...rewound.updatedHostIds]).toEqual(["u2"]);
expect(opened.summaryRowsByHostId.get("u1")).not.toBe(moreWork.summaryRowsByHostId.get("u1"));
});
it("maps hidden rows only while their turn is closed", () => {
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", () => {
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")], {
@@ -168,15 +183,15 @@ describe("collapseTurns", () => {
reply("done"),
].map((item) => Object.assign(item, { turnId: "same-run" }));
const result = collapse(items);
expect(ids(result.tail)).toEqual(["u1", "finished", "compaction", "done"]);
expect(result.turnsByHostId.get("u1")?.summary.tools).toMatchObject({
expect(ids(result.tail)).toEqual(["u1", "turn-summary:u1", "finished", "compaction", "done"]);
expect(result.summaryRowsByHostId.get("u1")?.tools).toMatchObject({
commandCount: 1,
otherToolCount: 0,
paseoActions: { "Created agent": 1 },
});
});
it("folds a finished turn's work into its user message and keeps the reply", () => {
it("folds a finished turn's work into a summary row under its user message and keeps the reply", () => {
const result = collapse([
user("u1"),
thought("t1"),
@@ -186,14 +201,16 @@ describe("collapseTurns", () => {
reply("final"),
]);
expect(ids(result.tail)).toEqual(["u1", "final"]);
expect(result.turnsByHostId.get("u1")).toEqual({
expect(ids(result.tail)).toEqual(["u1", "turn-summary:u1", "final"]);
expect(result.tail[1]).toEqual({
kind: "turn_summary",
id: "turn-summary:u1",
hostId: "u1",
timestamp: at,
expanded: false,
summary: {
tools: expect.objectContaining({ commandCount: 2 }),
},
tools: expect.objectContaining({ commandCount: 2 }),
});
expect(result.summaryRowsByHostId.get("u1")).toBe(result.tail[1]);
expect(result.hostIdByFoldedMessageId.get("early")).toBe("u1");
});
@@ -202,9 +219,9 @@ describe("collapseTurns", () => {
expandedHostIds: new Set(["u1"]),
});
expect(ids(result.tail)).toEqual(["u1", "t1", "c1", "final"]);
expect(ids(result.tail)).toEqual(["u1", "turn-summary:u1", "t1", "c1", "final"]);
expect([...result.nestedItemIds]).toEqual(["t1", "c1"]);
expect(result.turnsByHostId.get("u1")?.expanded).toBe(true);
expect(result.summaryRowsByHostId.get("u1")?.expanded).toBe(true);
});
it("leaves the latest turn alone while it is still producing output", () => {
@@ -212,8 +229,8 @@ describe("collapseTurns", () => {
const result = collapse(tail, { isLatestTurnOpen: true });
expect(ids(result.tail)).toEqual(["u1", "r1", "u2", "c2", "r2"]);
expect([...result.turnsByHostId.keys()]).toEqual(["u1"]);
expect(ids(result.tail)).toEqual(["u1", "turn-summary:u1", "r1", "u2", "c2", "r2"]);
expect([...result.summaryRowsByHostId.keys()]).toEqual(["u1"]);
});
it("keeps rows after the reply, and turns with no work before the reply, as they are", () => {
@@ -226,8 +243,8 @@ describe("collapseTurns", () => {
reply("r2"),
]);
expect(ids(result.tail)).toEqual(["u1", "r1", "after", "u2", "r2"]);
expect([...result.turnsByHostId.keys()]).toEqual(["u1"]);
expect(ids(result.tail)).toEqual(["u1", "turn-summary:u1", "r1", "after", "u2", "r2"]);
expect([...result.summaryRowsByHostId.keys()]).toEqual(["u1"]);
});
it("does not fold a turn that only thought before its reply", () => {
@@ -236,7 +253,7 @@ describe("collapseTurns", () => {
const result = collapse(tail);
expect(result.tail).toBe(tail);
expect(result.turnsByHostId.size).toBe(0);
expect(result.summaryRowsByHostId.size).toBe(0);
});
it("does not fold a turn without a reply or whose prompt is above the loaded history", () => {
@@ -245,7 +262,7 @@ describe("collapseTurns", () => {
const result = collapse(tail);
expect(result.tail).toBe(tail);
expect(result.turnsByHostId.size).toBe(0);
expect(result.summaryRowsByHostId.size).toBe(0);
});
it("keeps a completed response folded when a promptless canonical turn starts", () => {
@@ -254,15 +271,15 @@ describe("collapseTurns", () => {
);
const next = { ...shell("heartbeat"), turnId: "second" };
const result = collapse([...first, next], { isLatestTurnOpen: true });
expect(ids(result.tail)).toEqual(["u1", "r1", "heartbeat"]);
expect(result.turnsByHostId.has("u1")).toBe(true);
expect(ids(result.tail)).toEqual(["u1", "turn-summary:u1", "r1", "heartbeat"]);
expect(result.tail[1]).toMatchObject({ kind: "turn_summary", turnId: "first" });
});
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", "steer", "done"]);
expect(ids(result.tail)).toEqual(["prompt", "turn-summary:prompt", "steer", "done"]);
expect(result.hostIdByFoldedMessageId.has("steer")).toBe(false);
});
@@ -272,7 +289,7 @@ describe("collapseTurns", () => {
expandedHostIds: new Set(["prompt"]),
getGroupedCalls: (id) => (id === "one" ? calls : undefined),
});
expect(ids(result.tail)).toEqual(["prompt", "one", "two", "done"]);
expect(ids(result.tail)).toEqual(["prompt", "turn-summary:prompt", "one", "two", "done"]);
expect([...result.nestedItemIds]).toEqual(["one", "two"]);
});
@@ -282,8 +299,6 @@ describe("collapseTurns", () => {
getGroupedCalls: (itemId) => (itemId === "c1" ? group : undefined),
});
expect(result.turnsByHostId.get("u1")?.summary).toMatchObject({
tools: { commandCount: 3 },
});
expect(result.summaryRowsByHostId.get("u1")?.tools).toMatchObject({ commandCount: 3 });
});
});
@@ -3,35 +3,25 @@ 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 turn a folded message belongs to, for surfaces that must reveal it. */
/** The host of 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>;
/**
* 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.
* 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.
*/
updatedHostIds: ReadonlySet<string>;
summaryRowsByHostId: ReadonlyMap<string, TurnSummaryItem>;
}
export interface CollapseTurnsInput {
@@ -41,39 +31,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, the baseline for `updatedHostIds`. */
previousTurnsByHostId: ReadonlyMap<string, CollapsedTurn>;
/** The previous fold of the same history, whose unchanged summary rows are reused. */
previousSummaryRowsByHostId: ReadonlyMap<string, TurnSummaryItem>;
}
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"],
): TurnSummary {
): OverviewSummary {
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 { tools: summarizeToolCalls(calls) };
return summarizeToolCalls(calls);
}
function hasWork(summary: TurnSummary): boolean {
const { paseoActions, thoughtCount: _thoughtCount, ...counts } = summary.tools;
function hasWork(tools: OverviewSummary): boolean {
const { paseoActions, thoughtCount: _thoughtCount, ...counts } = tools;
return Object.keys(paseoActions).length > 0 || Object.values(counts).some((count) => count > 0);
}
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 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 hasSameCounts(a: Record<string, number>, b: Record<string, number>): boolean {
@@ -81,19 +71,32 @@ 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 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);
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;
}
for (const hostId of previous.keys()) {
if (!next.has(hostId)) updated.add(hostId);
}
return updated.size === 0 ? EMPTY_IDS : updated;
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,
};
}
function staysVisibleWhenCollapsed(item: StreamItem): boolean {
@@ -124,15 +127,15 @@ function findLastReplyIndex(tail: StreamItem[], start: number, end: number): num
}
/**
* 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.
* 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.
*/
export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
const { tail } = input;
const output: StreamItem[] = [];
const turnsByHostId = new Map<string, CollapsedTurn>();
const summaryRowsByHostId = new Map<string, TurnSummaryItem>();
const nestedItemIds = new Set<string>();
const hostIdByFoldedMessageId = new Map<string, string>();
const hostIdByHiddenRowId = new Map<string, string>();
@@ -152,13 +155,19 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
return;
}
const folded = tail.slice(runStart, replyIndex);
const summary = summarizeTurn(folded, input.getGroupedCalls);
if (!hasWork(summary) && !folded.some(isAssistantReply)) {
const tools = summarizeTurn(folded, input.getGroupedCalls);
if (!hasWork(tools) && !folded.some(isAssistantReply)) {
output.push(...tail.slice(runStart, runEnd));
return;
}
const expanded = input.expandedHostIds.has(host.id);
turnsByHostId.set(host.id, { hostId: host.id, expanded, summary });
const summary = summaryRow(
host,
expanded,
tools,
input.previousSummaryRowsByHostId.get(host.id),
);
summaryRowsByHostId.set(host.id, summary);
const revealed = expanded
? folded.flatMap<StreamItem>((item) =>
item.kind === "tool_call" || item.kind === "thought"
@@ -173,6 +182,7 @@ 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));
@@ -190,26 +200,20 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
}
flushRun(tail.length);
const updatedHostIds = findUpdatedHosts(input.previousTurnsByHostId, turnsByHostId);
if (turnsByHostId.size === 0) {
if (summaryRowsByHostId.size === 0) {
return {
tail,
turnsByHostId: EMPTY_TURNS,
nestedItemIds: EMPTY_IDS,
hostIdByFoldedMessageId: EMPTY_HOSTS,
hostIdByHiddenRowId: EMPTY_HOSTS,
updatedHostIds,
summaryRowsByHostId: EMPTY_ROWS,
};
}
return {
tail:
output.length === tail.length && output.every((item, index) => item === tail[index])
? tail
: output,
turnsByHostId,
tail: output,
nestedItemIds,
hostIdByFoldedMessageId,
hostIdByHiddenRowId,
updatedHostIds,
summaryRowsByHostId,
};
}
@@ -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 { CollapsedTurn } from "./model";
import type { TurnSummaryItem } from "@/types/stream";
/**
* 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({
turn,
item,
onExpandedChange,
}: {
turn: CollapsedTurn;
item: TurnSummaryItem;
onExpandedChange: (hostId: string, expanded: boolean) => void;
}) {
const { t } = useTranslation();
const label = useOverviewSummaryLabel(turn.summary.tools) || t("turnSummary.details");
const label = useOverviewSummaryLabel(item.tools) || t("turnSummary.details");
const hold = useDisclosureHold();
const toggle = useCallback(
() => hold(() => onExpandedChange(turn.hostId, !turn.expanded)),
[hold, onExpandedChange, turn.expanded, turn.hostId],
() => hold(() => onExpandedChange(item.hostId, !item.expanded)),
[hold, onExpandedChange, item.expanded, item.hostId],
);
return (
@@ -30,7 +30,7 @@ export const TurnSummaryLine = memo(function TurnSummaryLine({
testID="turn-summary"
label={label}
icon={Summary}
isExpanded={turn.expanded}
isExpanded={item.expanded}
onToggle={toggle}
borderlessWhenExpanded
/>
@@ -0,0 +1,94 @@
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,
});
});
@@ -236,8 +236,8 @@ const stylesheet = StyleSheet.create((theme) => ({
turnFooterSlot: {
flexDirection: "row",
alignItems: "center",
alignSelf: "flex-start",
height: TURN_FOOTER_CONTENT_HEIGHT + TURN_FOOTER_BOTTOM_SPACING,
alignSelf: "stretch",
minHeight: TURN_FOOTER_CONTENT_HEIGHT + TURN_FOOTER_BOTTOM_SPACING,
paddingBottom: TURN_FOOTER_BOTTOM_SPACING,
},
turnFooterContent: {
@@ -0,0 +1,33 @@
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 };
}
+25 -45
View File
@@ -248,6 +248,8 @@ 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. */
@@ -345,6 +347,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
turnPresentation,
routeBottomAnchorRequest = null,
isAuthoritativeHistoryReady = true,
isInitialHistoryLoading,
revealInitialHistory,
bottomOverlayTailClearance = 0,
bottomOverlayControlClearance,
toast,
@@ -689,26 +693,23 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
}
return revealLoadedHistory(messageId);
});
// 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),
);
// Read through a stable event so a collapse rerun does not change the renderer identity;
// nested rows are revised through `historyRowRevision`.
const getTurnIndent = useStableEvent((layoutItem: StreamLayoutItem): TurnIndent => {
const { nestedItemIds } = presentation.turns;
if (nestedItemIds.has(layoutItem.item.id)) {
const { item } = layoutItem;
if (nestedItemIds.has(item.id)) {
const below = layoutItem.belowItem;
return below && nestedItemIds.has(below.id) ? "nested" : "lastNested";
}
return getCollapsedTurn(layoutItem.item.id)?.expanded ? "openedHost" : "none";
return item.kind === "turn_summary" && item.expanded ? "openedHost" : "none";
});
const setToolCallGroupExpanded = toolCallExpansion.setGroupExpanded;
const renderUserMessageItem = useCallback(
(layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "user_message" }>) => {
const collapsedTurn = getCollapsedTurn(item.id);
const message = (
return (
<UserMessage
serverId={resolvedServerId}
agentId={agentId}
@@ -728,25 +729,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
}
/>
);
if (!collapsedTurn) {
return message;
}
return (
<>
{message}
<TurnSummaryLine turn={collapsedTurn} onExpandedChange={setTurnExpanded} />
</>
);
},
[
context.capabilities,
agentId,
client,
getCollapsedTurn,
pendingClientMessageIds,
resolvedServerId,
setTurnExpanded,
],
[context.capabilities, agentId, client, pendingClientMessageIds, resolvedServerId],
);
const renderAssistantMessageItem = useCallback(
@@ -931,17 +915,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
return renderToolCallItem(layoutItem, item);
case "tool_call":
return (
<>
{renderToolCallItem(layoutItem, item)}
{getCollapsedTurn(item.id) ? (
<TurnSummaryLine
turn={getCollapsedTurn(item.id)!}
onExpandedChange={setTurnExpanded}
/>
) : null}
</>
);
return renderToolCallItem(layoutItem, item);
case "turn_summary":
return <TurnSummaryLine item={item} onExpandedChange={setTurnExpanded} />;
case "notification":
return <Notification level={item.level} message={item.message} />;
@@ -972,7 +949,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
renderUserMessageItem,
renderAssistantMessageItem,
renderToolCallItem,
getCollapsedTurn,
setTurnExpanded,
resolvedServerId,
],
@@ -1151,19 +1127,17 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
const streamScrollEnabled =
!streamRenderStrategy.shouldDisableParentScrollOnInlineDetailsExpansion() ||
expandedInlineToolCallIds.size === 0;
const { nestedItemIds, updatedHostIds: updatedTurnHostIds } = presentation.turns;
const { nestedItemIds } = presentation.turns;
const historyRowRevision = useMemo(
() => ({
contentById: {
has: (id: string) =>
presentation.historyGroupUpdatesByHostId.has(id) || updatedTurnHostIds.has(id),
has: (id: string) => presentation.historyGroupUpdatesByHostId.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,
@@ -1171,11 +1145,9 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
[
expandedToolCallGroups,
getToolCallGroup,
expandedTurnHostIds,
isMobile,
nestedItemIds,
presentation.historyGroupUpdatesByHostId,
updatedTurnHostIds,
],
);
@@ -1209,6 +1181,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
viewportRef,
routeBottomAnchorRequest,
isAuthoritativeHistoryReady,
isInitialHistoryLoading,
revealInitialHistory,
onNearBottomChange: nearBottom.set,
onReadingPositionChange: handleReadingPositionChange,
onNearHistoryStart: loadOlder,
@@ -1351,6 +1325,12 @@ 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,6 +65,7 @@ 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 -50
View File
@@ -1,3 +1,4 @@
import { SidebarViewSlot } from "@/plugins/sidebar-view";
import { useAddHost } from "@/hosts/add-host";
import { router } from "expo-router";
import {
@@ -642,31 +643,33 @@ function MobileSidebar({
)}
<SidebarSeparator />
{isInitialLoad && !hasActiveHostFilter ? (
<SidebarAgentListSkeleton />
) : (
<SidebarWorkspaceList
collapsedProjectKeys={collapsedProjectKeys}
onToggleProjectCollapsed={toggleProjectCollapsed}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
groupMode={groupMode}
workspaceGroups={workspaceGroups}
projectIconTargets={projectIconTargets}
pinnedGroups={pinnedGroups}
projects={projects}
hasProjectsBeforeFilter={hasProjectsBeforeFilter}
hasActiveProjectFilter={hasActiveProjectFilter}
workspaceEntriesByKey={workspaceEntriesByKey}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
onWorkspacePress={handleWorkspacePress}
onAddProject={handleOpenProject}
onImportSession={handleImportSession}
parentGestureRef={closeGestureRef}
dragGestureHostActive={active}
listHeaderComponent={workspacesSectionHeaderElement}
/>
)}
<SidebarViewSlot dismiss={handleWorkspacePress}>
{isInitialLoad && !hasActiveHostFilter ? (
<SidebarAgentListSkeleton />
) : (
<SidebarWorkspaceList
collapsedProjectKeys={collapsedProjectKeys}
onToggleProjectCollapsed={toggleProjectCollapsed}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
groupMode={groupMode}
workspaceGroups={workspaceGroups}
projectIconTargets={projectIconTargets}
pinnedGroups={pinnedGroups}
projects={projects}
hasProjectsBeforeFilter={hasProjectsBeforeFilter}
hasActiveProjectFilter={hasActiveProjectFilter}
workspaceEntriesByKey={workspaceEntriesByKey}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
onWorkspacePress={handleWorkspacePress}
onAddProject={handleOpenProject}
onImportSession={handleImportSession}
parentGestureRef={closeGestureRef}
dragGestureHostActive={active}
listHeaderComponent={workspacesSectionHeaderElement}
/>
)}
</SidebarViewSlot>
<SidebarFooter
handleOpenProject={handleOpenProject}
@@ -838,31 +841,33 @@ function DesktopSidebar({
<SidebarNavRows style={sidebarHeaderGroupStyle} onBeforeNavigate={closeSidebar} />
</View>
{isInitialLoad && !hasActiveHostFilter ? (
<SidebarAgentListSkeleton />
) : (
<SidebarWorkspaceList
collapsedProjectKeys={collapsedProjectKeys}
onToggleProjectCollapsed={toggleProjectCollapsed}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
groupMode={groupMode}
workspaceGroups={workspaceGroups}
projectIconTargets={projectIconTargets}
pinnedGroups={pinnedGroups}
projects={projects}
hasProjectsBeforeFilter={hasProjectsBeforeFilter}
hasActiveProjectFilter={hasActiveProjectFilter}
workspaceEntriesByKey={workspaceEntriesByKey}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
onAddProject={handleOpenProject}
onImportSession={handleImportSession}
onWorkspacePress={closeSidebar}
parentGestureRef={closeGestureRef}
dragGestureHostActive={active}
listHeaderComponent={workspacesSectionHeaderElement}
/>
)}
<SidebarViewSlot dismiss={closeSidebar}>
{isInitialLoad && !hasActiveHostFilter ? (
<SidebarAgentListSkeleton />
) : (
<SidebarWorkspaceList
collapsedProjectKeys={collapsedProjectKeys}
onToggleProjectCollapsed={toggleProjectCollapsed}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
groupMode={groupMode}
workspaceGroups={workspaceGroups}
projectIconTargets={projectIconTargets}
pinnedGroups={pinnedGroups}
projects={projects}
hasProjectsBeforeFilter={hasProjectsBeforeFilter}
hasActiveProjectFilter={hasActiveProjectFilter}
workspaceEntriesByKey={workspaceEntriesByKey}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
onAddProject={handleOpenProject}
onImportSession={handleImportSession}
onWorkspacePress={closeSidebar}
parentGestureRef={closeGestureRef}
dragGestureHostActive={active}
listHeaderComponent={workspacesSectionHeaderElement}
/>
)}
</SidebarViewSlot>
<SidebarCalloutSlot />
+58 -25
View File
@@ -557,6 +557,15 @@ interface AssistantTurnFooterProps {
const assistantTurnFooterStylesheet = StyleSheet.create((theme) => ({
container: {
flex: 1,
minWidth: 0,
minHeight: 24,
flexDirection: "row",
alignItems: "flex-start",
gap: theme.spacing[2],
},
actions: {
height: 24,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
@@ -568,21 +577,25 @@ const assistantTurnFooterStylesheet = StyleSheet.create((theme) => ({
marginTop: 0,
marginLeft: -theme.spacing[1],
},
labelWrapper: {
position: "relative",
labelButton: {
flex: 1,
minHeight: 24,
flexDirection: "row",
alignItems: "center",
minWidth: 0,
},
labelSizer: {
label: {
width: "100%",
flexShrink: 0,
color: theme.colors.foregroundMuted,
fontSize: STREAM_METADATA_FONT_SIZE,
},
overlappingLabel: {
marginLeft: "-100%",
},
hiddenLabel: {
opacity: 0,
},
labelOverlay: {
position: "absolute",
top: 0,
left: 0,
color: theme.colors.foregroundMuted,
fontSize: STREAM_METADATA_FONT_SIZE,
},
}));
const TIMESTAMP_REVEAL_MS = 3000;
@@ -649,31 +662,51 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({
return (
<View style={assistantTurnFooterStylesheet.container}>
{getContent ? (
<TurnCopyButton
getContent={getContent}
containerStyle={assistantTurnFooterStylesheet.copyButton}
/>
{getContent || canFork ? (
<View style={assistantTurnFooterStylesheet.actions}>
{getContent ? (
<TurnCopyButton
getContent={getContent}
containerStyle={assistantTurnFooterStylesheet.copyButton}
/>
) : null}
{canFork ? <AssistantForkMenu onFork={handleFork} /> : null}
</View>
) : null}
{canFork ? <AssistantForkMenu onFork={handleFork} /> : null}
{primaryLabel ? (
<Pressable
style={assistantTurnFooterStylesheet.labelButton}
onPress={handlePress}
onHoverIn={handleHoverIn}
onHoverOut={handleHoverOut}
accessibilityRole={canSwap ? "button" : undefined}
accessibilityLabel={canSwap ? `${durationLabel}, ended ${timestampLabel}` : primaryLabel}
>
<View style={assistantTurnFooterStylesheet.labelWrapper}>
{/* Sizer reserves space for whichever label is longer so the
container width is stable across hover transitions. */}
<Text style={assistantTurnFooterStylesheet.labelSizer} aria-hidden>
{primaryLabel.length >= timestampLabel.length ? primaryLabel : timestampLabel}
{/* Both labels participate in layout at the available width. Overlapping their
columns reserves the taller wrapped height without measuring text or characters. */}
<Text
testID={!showTimestamp ? "turn-footer-timing-label" : undefined}
aria-hidden={showTimestamp}
style={[
assistantTurnFooterStylesheet.label,
showTimestamp && assistantTurnFooterStylesheet.hiddenLabel,
]}
>
{primaryLabel}
</Text>
{canSwap ? (
<Text
testID={showTimestamp ? "turn-footer-timing-label" : undefined}
aria-hidden={!showTimestamp}
style={[
assistantTurnFooterStylesheet.label,
assistantTurnFooterStylesheet.overlappingLabel,
!showTimestamp && assistantTurnFooterStylesheet.hiddenLabel,
]}
>
{timestampLabel}
</Text>
<Text style={assistantTurnFooterStylesheet.labelOverlay}>
{showTimestamp ? timestampLabel : primaryLabel}
</Text>
</View>
) : null}
</Pressable>
) : null}
</View>
+3 -1
View File
@@ -1,3 +1,4 @@
import { useDisclosureHold } from "@/agent-stream/disclosure";
import { MarkdownRenderer } from "@/components/markdown/renderer";
import { isWeb } from "@/constants/platform";
import type { Theme } from "@/styles/theme";
@@ -50,7 +51,8 @@ function PlanCardContent({
() => (isWeb ? ({ "aria-expanded": expanded } as const) : null),
[expanded],
);
const toggleExpanded = useCallback(() => setExpanded((value) => !value), []);
const hold = useDisclosureHold();
const toggleExpanded = useCallback(() => hold(() => setExpanded((value) => !value)), [hold]);
const containerStyle = useMemo(
() => [styles.container, disableOuterSpacing && styles.containerCompact],
[disableOuterSpacing],
+57 -15
View File
@@ -1,6 +1,6 @@
import {
AdaptiveWorkspaceChrome,
type WorkspaceCompactHeaderActions,
type WorkspaceCompactHeaderTabs,
} from "@/screens/workspace/adaptive-workspace-chrome";
import { useWorkspaceTabDrag } from "./workspace-tab-drag-context";
import {
@@ -66,12 +66,13 @@ import { isNative } from "@/constants/platform";
interface SplitContainerProps {
layout: WorkspaceLayout;
/** The full workspace header: title, project row and workspace actions. */
renderMainHeader?: () => ReactNode;
renderCompactHeader: (
tabs: WorkspaceTabDescriptor[],
activeTabKey: string,
actions: WorkspaceCompactHeaderActions,
) => ReactNode;
/**
* The compact workspace header. With one presented pane it carries that pane's tab switcher in
* place of its tab rail; above a split it carries workspace controls only.
*/
renderCompactHeader: (paneTabs: WorkspaceCompactHeaderTabs | null) => ReactNode;
workspaceKey: string;
normalizedServerId: string;
normalizedWorkspaceId: string;
@@ -128,6 +129,11 @@ interface SplitNodeViewProps extends Omit<
windowChromeCorners: WindowChromeCorners;
maximizedPaneId: string | null;
workspaceHasMultiplePanes: boolean;
/**
* One presented pane draws the workspace header itself and may merge its tabs into the compact
* header. Split panes share one header above them and always show their tab rails.
*/
ownsWorkspaceHeader: boolean;
onTogglePaneMaximized: (paneId: string) => void;
}
@@ -244,11 +250,27 @@ export function SplitContainer({
: inheritedWindowChromeCorners;
const mainColumnStyle = styles.mainColumn;
const renderRoot = useMemo(() => wrapRootPaneForStableMount(splitRoot.root), [splitRoot.root]);
const panesShareWorkspaceHeader = hasMultipleVisiblePanes(splitRoot.root);
const sharedCompactHeader = useMemo(
() =>
panesShareWorkspaceHeader ? (
<WindowChromeSafeArea placement="inline">
<TitlebarDragRegion />
{renderCompactHeader(null)}
</WindowChromeSafeArea>
) : null,
[panesShareWorkspaceHeader, renderCompactHeader],
);
return (
<RenderProfile id="SplitContainer">
<View style={styles.workspaceShell}>
<WindowChromeRegion corners={mainColumnWindowChromeCorners}>
<View style={mainColumnStyle}>
{panesShareWorkspaceHeader ? (
<AdaptiveWorkspaceChrome compactHeader={sharedCompactHeader}>
{renderMainHeader?.()}
</AdaptiveWorkspaceChrome>
) : null}
{splitRoot.usesFallbackStrip && <WindowChromeSafeArea placement="below" />}
{renderRoot ? (
<SplitNodeView
@@ -284,7 +306,12 @@ export function SplitContainer({
showDropZones={activeDragTabId !== null}
dropPreview={dropPreview}
tabDropPreview={tabDropPreview}
windowChromeCorners={splitRoot.usesFallbackStrip ? "none" : windowChromeCorners}
windowChromeCorners={
splitRoot.usesFallbackStrip || panesShareWorkspaceHeader
? "none"
: windowChromeCorners
}
ownsWorkspaceHeader={!panesShareWorkspaceHeader}
maximizedPaneId={maximizedPaneId}
workspaceHasMultiplePanes={workspaceHasMultiplePanes}
onTogglePaneMaximized={handleTogglePaneMaximized}
@@ -423,6 +450,7 @@ function SplitNodeView({
onTogglePaneMaximized,
focusModeEnabled,
onExitFocusMode,
ownsWorkspaceHeader,
renderMainHeader,
renderCompactHeader,
}: SplitNodeViewProps) {
@@ -511,6 +539,7 @@ function SplitNodeView({
onTogglePaneMaximized={onTogglePaneMaximized}
focusModeEnabled={focusModeEnabled}
onExitFocusMode={onExitFocusMode}
ownsWorkspaceHeader={ownsWorkspaceHeader}
renderMainHeader={renderMainHeader}
renderCompactHeader={renderCompactHeader}
/>
@@ -578,6 +607,7 @@ function SplitNodeView({
onTogglePaneMaximized={onTogglePaneMaximized}
focusModeEnabled={focusModeEnabled}
onExitFocusMode={onExitFocusMode}
ownsWorkspaceHeader={ownsWorkspaceHeader}
renderMainHeader={renderMainHeader}
renderCompactHeader={renderCompactHeader}
/>
@@ -638,6 +668,7 @@ const SplitPaneView = memo(function SplitPaneView({
onTogglePaneMaximized,
focusModeEnabled,
onExitFocusMode,
ownsWorkspaceHeader,
renderMainHeader,
renderCompactHeader,
}: SplitPaneViewProps) {
@@ -770,13 +801,24 @@ const SplitPaneView = memo(function SplitPaneView({
createNewTab,
});
const compactHeader = useMemo(
() => (
<WindowChromeSafeArea placement="inline">
<TitlebarDragRegion />
{renderCompactHeader(paneTabs, activeTabDescriptor?.key ?? "", compactHeaderActions)}
</WindowChromeSafeArea>
),
[renderCompactHeader, paneTabs, activeTabDescriptor?.key, compactHeaderActions],
() =>
ownsWorkspaceHeader ? (
<WindowChromeSafeArea placement="inline">
<TitlebarDragRegion />
{renderCompactHeader({
tabs: paneTabs,
activeTabKey: activeTabDescriptor?.key ?? "",
actions: compactHeaderActions,
})}
</WindowChromeSafeArea>
) : null,
[
ownsWorkspaceHeader,
renderCompactHeader,
paneTabs,
activeTabDescriptor?.key,
compactHeaderActions,
],
);
return (
<RenderProfile id={`SplitPaneView:${pane.id}`}>
@@ -787,7 +829,7 @@ const SplitPaneView = memo(function SplitPaneView({
testID={`workspace-pane-${pane.id}`}
>
<AdaptiveWorkspaceChrome compactHeader={compactHeader}>
{renderMainHeader?.()}
{ownsWorkspaceHeader ? renderMainHeader?.() : null}
<WindowChromeRegion corners={focusModeEnabled ? "both" : "none"}>
<WindowChromeSafeArea placement="inline" style={styles.paneTabs}>
<TitlebarDragRegion />
+75 -71
View File
@@ -4,6 +4,7 @@ import {
useCallback,
useContext,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
@@ -17,23 +18,17 @@ import { StyleSheet } from "react-native-unistyles";
import { Portal } from "@gorhom/portal";
import { OverlayLayerProvider, useOverlayLayer } from "@/lib/overlay-root";
import { FloatingSurface } from "@/components/ui/floating";
import {
measureFloatingPanelPortalHost,
useFloatingPanelPortalHostName,
} from "@/components/ui/floating-panel-portal";
import { useFloatingPanelPortalHostName } from "@/components/ui/floating-panel-portal";
import { isWeb } from "@/constants/platform";
import { useHoverSafeZone } from "@/hooks/use-hover-safe-zone";
import {
computePosition,
measureElement,
type Alignment,
type Placement,
type Rect,
type Size,
} from "./anchor";
import { computePosition, type Alignment, type Placement, type Rect } from "./anchor";
const CLOSE_GRACE_MS = 100;
// One card per layer: moving onto a sibling trigger replaces the open card instead of stacking
// a second one beside it for the grace period. Nested cards open on a higher layer.
const openCardCloseByLayer = new Map<number, () => void>();
interface HoverCardContextValue {
open: boolean;
layer: number;
@@ -116,12 +111,26 @@ function WebHoverCard({
}, CLOSE_GRACE_MS);
}, [keyboardFocusInside]);
const closeNow = useCallback(() => {
clearGraceTimer();
setOpen(false);
}, [clearGraceTimer]);
const openNow = useCallback(() => {
clearGraceTimer();
if (!disabled) {
setOpen(true);
}
}, [clearGraceTimer, disabled]);
if (disabled) return;
const openSibling = openCardCloseByLayer.get(layer);
if (openSibling && openSibling !== closeNow) openSibling();
openCardCloseByLayer.set(layer, closeNow);
setOpen(true);
}, [clearGraceTimer, closeNow, disabled, layer]);
useEffect(() => {
if (!open) return;
return () => {
if (openCardCloseByLayer.get(layer) === closeNow) openCardCloseByLayer.delete(layer);
};
}, [closeNow, layer, open]);
const keyPressed = useCallback(
(event: KeyboardInputEvent): boolean => {
@@ -299,17 +308,29 @@ interface HoverCardContentProps {
export function HoverCardContent(props: HoverCardContentProps): ReactElement | null {
const ctx = useContext(HoverCardContext);
const portalHostName = useFloatingPanelPortalHostName();
if (!ctx?.open) return null;
return (
<HoverCardSurface
{...props}
layer={ctx.layer}
triggerRef={ctx.triggerRef}
contentRef={ctx.contentRef}
/>
<Portal hostName={portalHostName}>
<HoverCardSurface
{...props}
layer={ctx.layer}
triggerRef={ctx.triggerRef}
contentRef={ctx.contentRef}
/>
</Portal>
);
}
function readDomRect(view: View | null): Rect | null {
const element = view as unknown as HTMLElement | null;
if (!element) return null;
const { x, y, width, height } = element.getBoundingClientRect();
return { x, y, width, height };
}
// Rendered inside the portal so the card is placed in the commit that mounts it: the first
// painted frame already sits beside the trigger. Web-only, like the rest of the open card.
function HoverCardSurface({
layer,
triggerRef,
@@ -327,48 +348,29 @@ function HoverCardSurface({
triggerRef: RefObject<View | null>;
contentRef: RefObject<View | null>;
}): ReactElement {
const portalHostName = useFloatingPanelPortalHostName();
const [anchor, setAnchor] = useState<{ triggerRect: Rect; hostRect: Rect } | null>(null);
const [contentSize, setContentSize] = useState<Size | null>(null);
const overlayRef = useRef<View>(null);
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
useEffect(() => {
if (!triggerRef.current) return;
let cancelled = false;
void Promise.all([
measureElement(triggerRef.current),
measureFloatingPanelPortalHost(portalHostName),
]).then(([triggerRect, hostRect]) => {
if (!cancelled && hostRect) setAnchor({ triggerRect, hostRect });
return undefined;
});
return () => {
cancelled = true;
};
}, [portalHostName, triggerRef]);
useEffect(() => {
if (!anchor || !contentSize) return;
const place = useCallback(() => {
const triggerRect = readDomRect(triggerRef.current);
const overlayRect = readDomRect(overlayRef.current);
const content = contentRef.current as unknown as HTMLElement | null;
if (!triggerRect || !overlayRect || !content) return;
const { width, height } = Dimensions.get("window");
const { x, y } = computePosition({
triggerRect: anchor.triggerRect,
contentSize,
triggerRect,
contentSize: { width: content.offsetWidth, height: content.offsetHeight },
displayArea: { x: 0, y: 0, width, height },
placement,
flipHorizontal: true,
alignment,
offset,
});
setPosition({ x: x - anchor.hostRect.x, y: y - anchor.hostRect.y });
}, [alignment, anchor, contentSize, offset, placement]);
const next = { x: x - overlayRect.x, y: y - overlayRect.y };
setPosition((current) => (current?.x === next.x && current.y === next.y ? current : next));
}, [alignment, contentRef, offset, placement, triggerRef]);
const handleLayout = useCallback(
(event: { nativeEvent: { layout: { width: number; height: number } } }) => {
const { width, height } = event.nativeEvent.layout;
setContentSize({ width, height });
},
[],
);
useLayoutEffect(place, [place]);
const frameStyle = useMemo(
() => ({
@@ -381,24 +383,26 @@ function HoverCardSurface({
const surfaceStyle = useMemo(() => [styles.surface, style], [style]);
return (
<Portal hostName={portalHostName}>
<View pointerEvents="box-none" style={[styles.portalOverlay, { zIndex: layer }]}>
<FloatingSurface
ref={contentRef}
entering={FadeIn.duration(80)}
exiting={FadeOut.duration(80)}
collapsable={false}
onLayout={handleLayout}
role={role}
accessibilityLabel={accessibilityLabel}
testID={testID}
style={surfaceStyle}
frameStyle={frameStyle}
>
{children}
</FloatingSurface>
</View>
</Portal>
<View
ref={overlayRef}
pointerEvents="box-none"
style={[styles.portalOverlay, { zIndex: layer }]}
>
<FloatingSurface
ref={contentRef}
entering={FadeIn.duration(80)}
exiting={FadeOut.duration(80)}
collapsable={false}
onLayout={place}
role={role}
accessibilityLabel={accessibilityLabel}
testID={testID}
style={surfaceStyle}
frameStyle={frameStyle}
>
{children}
</FloatingSurface>
</View>
);
}
@@ -9,8 +9,11 @@ import { useProviderIcon } from "@/components/provider-icons";
import { useSessionStore } from "@/stores/session-store";
import { navigateToAgent } from "@/utils/navigate-to-agent";
import { isNative } from "@/constants/platform";
import { useDisclosureHold } from "@/agent-stream/disclosure";
const PREVIEW_HEIGHT = 240;
// Show more and show less sit under the text they open and close.
const TRIGGER_BELOW_CONTENT = { triggerEdge: "bottom" } as const;
// Native View still clips with "scroll", but Yoga measures its children without
// the height cap. "hidden" caps the measured body too, hiding the expand control.
const previewStyle: ViewStyle = {
@@ -26,7 +29,11 @@ export function MessagePreview({ children }: { children: ReactNode }) {
(event: LayoutChangeEvent) => setOverflowing(event.nativeEvent.layout.height > PREVIEW_HEIGHT),
[],
);
const toggle = useCallback(() => setExpanded((value) => !value), []);
const hold = useDisclosureHold();
const toggle = useCallback(
() => hold(() => setExpanded((value) => !value), TRIGGER_BELOW_CONTENT),
[hold],
);
const accessibilityState = useMemo(() => ({ expanded }), [expanded]);
return (
<>
@@ -1791,6 +1791,9 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
handleSendMessage,
handleQueueMessage,
});
if (hasFinePointer()) {
textInputRef.current?.focus();
}
}, [defaultSendBehavior, isAgentRunning, onQueue, handleQueueMessage, handleSendMessage]);
const handleAlternateSendAction = useCallback(() => {
+57 -12
View File
@@ -1,4 +1,4 @@
import type { OwnedSubscription, SubscriptionObserver } from "@getpaseo/client";
import type { DiffState, OwnedSubscription, SubscriptionObserver } from "@getpaseo/client";
import { QueryClient, QueryObserver, skipToken } from "@tanstack/react-query";
import { describe, expect, it } from "vitest";
import type { MutableDaemonConfig, SessionOutboundMessage } from "@getpaseo/protocol/messages";
@@ -55,8 +55,8 @@ function createFakeClient(config: { rejectCheckoutDiffSubscribe?: boolean } = {}
subscriptionId: string;
}>;
unsubscribeCheckoutDiffCalls: string[];
subscribeTerminalCalls: Array<{ cwd: string; workspaceId?: string }>;
unsubscribeTerminalCalls: Array<{ cwd: string; workspaceId?: string }>;
subscribeTerminalCalls: Array<{ cwd?: string; workspaceId?: string }>;
unsubscribeTerminalCalls: Array<{ cwd?: string; workspaceId?: string }>;
} {
const handlers: Record<RouterMessageType, RouterHandler[]> = {
providers_snapshot_update: [],
@@ -71,8 +71,8 @@ function createFakeClient(config: { rejectCheckoutDiffSubscribe?: boolean } = {}
subscriptionId: string;
}> = [];
const unsubscribeCheckoutDiffCalls: string[] = [];
const subscribeTerminalCalls: Array<{ cwd: string; workspaceId?: string }> = [];
const unsubscribeTerminalCalls: Array<{ cwd: string; workspaceId?: string }> = [];
const subscribeTerminalCalls: Array<{ cwd?: string; workspaceId?: string }> = [];
const unsubscribeTerminalCalls: Array<{ cwd?: string; workspaceId?: string }> = [];
function on<K extends RouterMessageType>(
type: K,
@@ -144,10 +144,21 @@ function createFakeClient(config: { rejectCheckoutDiffSubscribe?: boolean } = {}
["status", "providers_snapshot_update"],
() => {},
),
observeCheckoutDiff(cwd, compare) {
observeDiff(input) {
const cwd = input.cwd;
let mode: "uncommitted" | "base" | "all" = "uncommitted";
if (input.head.kind === "rev") mode = "base";
else if (input.mergeBase) mode = "all";
const compare = {
mode,
...(input.base.kind === "rev" && input.base.rev !== "HEAD"
? { baseRef: input.base.rev }
: {}),
ignoreWhitespace: input.ignoreWhitespace,
};
const subscriptionId = `server-diff-${subscribeCheckoutDiffCalls.length + 1}`;
subscribeCheckoutDiffCalls.push({ cwd, compare, subscriptionId });
const handle = observe(
const handle = observe<SubscribeCheckoutDiffResponseMessage["payload"]>(
{ subscriptionId, cwd, files: [], error: null, requestId: "subscribe-checkout-diff" },
["checkout_diff_update", "subscribe_checkout_diff_response"],
() => unsubscribeCheckoutDiffCalls.push(subscriptionId),
@@ -155,9 +166,43 @@ function createFakeClient(config: { rejectCheckoutDiffSubscribe?: boolean } = {}
"subscriptionId" in message.payload &&
message.payload.subscriptionId === subscriptionId,
);
if (!config.rejectCheckoutDiffSubscribe) return handle;
return {
const modern: OwnedSubscription<DiffState> = {
...handle,
ready: Promise.resolve({
phase: "ready",
revision: 1,
resolved: { baseSha: "", headSha: "", mergeBaseSha: "" },
files: [],
diffTooLarge: false,
subscriptionId,
}),
subscribe: (observer) =>
handle.subscribe({
snapshot: (payload) =>
observer.snapshot({
phase: "ready",
revision: 1,
resolved: { baseSha: "", headSha: "", mergeBaseSha: "" },
files: payload.files,
diffTooLarge: payload.diffTooLarge === true,
subscriptionId,
}),
update: (message) => {
if (message.type === "checkout_diff_update")
observer.snapshot({
phase: "ready",
revision: 2,
resolved: { baseSha: "", headSha: "", mergeBaseSha: "" },
files: message.payload.files,
diffTooLarge: message.payload.diffTooLarge === true,
subscriptionId,
});
},
}),
};
if (!config.rejectCheckoutDiffSubscribe) return modern;
return {
...modern,
subscribe: (observer) => {
queueMicrotask(() => observer.error?.(new Error("subscribe failed")));
return () => {};
@@ -328,7 +373,7 @@ describe("server data push router", () => {
const first = publish(files, "first");
const same = publish(structuredClone(files), "reopen");
expect(same.files).toBe(first.files);
expect(same.requestId).toBe("reopen");
expect(same.requestId).toBe("diff:1:ready");
const changed = structuredClone(files);
changed[0]!.hunks[0]!.lines[0]!.tokens![0]!.style = "variable";
const next = publish(changed, "edit");
@@ -430,7 +475,7 @@ describe("server data push router", () => {
files: [],
error: null,
diffTooLarge: true,
requestId: "diff-1",
requestId: "diff:1:ready",
});
fake.emit({
@@ -449,7 +494,7 @@ describe("server data push router", () => {
files: [],
error: null,
diffTooLarge: true,
requestId: `subscription:${serverSubscriptionId}`,
requestId: "diff:2:ready",
});
unsubscribeObserver();
+27 -30
View File
@@ -1,3 +1,5 @@
import { checkoutDiffSnapshot, type CheckoutDiffSnapshot } from "@/git/diff-snapshot";
import type { DiffState } from "@getpaseo/client";
import type { OwnedSubscription } from "@getpaseo/client";
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
import type { Query, QueryCacheNotifyEvent, QueryClient, QueryKey } from "@tanstack/react-query";
@@ -23,14 +25,9 @@ type ProvidersSnapshotUpdateMessage = Extract<
SessionOutboundMessage,
{ type: "providers_snapshot_update" }
>;
type SubscribeCheckoutDiffResponseMessage = Extract<
SessionOutboundMessage,
{ type: "subscribe_checkout_diff_response" }
>;
type StatusMessage = Extract<SessionOutboundMessage, { type: "status" }>;
type TerminalsChangedMessage = Extract<SessionOutboundMessage, { type: "terminals_changed" }>;
type CheckoutDiffResponsePayload = SubscribeCheckoutDiffResponseMessage["payload"];
type CheckoutDiffCachePayload = Omit<CheckoutDiffResponsePayload, "subscriptionId">;
type CheckoutDiffCachePayload = CheckoutDiffSnapshot;
type ListTerminalsPayload = ListTerminalsResponse["payload"];
interface CheckoutDiffCompare {
@@ -49,7 +46,7 @@ interface CheckoutDiffRoute {
}
interface CheckoutDiffRegistration extends CheckoutDiffRoute {
subscription: OwnedSubscription<CheckoutDiffResponsePayload>;
subscription: OwnedSubscription<DiffState>;
}
interface WorkspaceTerminalsRoute {
@@ -75,7 +72,7 @@ export type ProvidersSnapshotUpdate = ProvidersSnapshotUpdateMessage;
interface ServerDataPushClient {
getProvidersSnapshot: import("@getpaseo/client/internal/daemon-client").DaemonClient["getProvidersSnapshot"];
observeEvents: import("@getpaseo/client/internal/daemon-client").DaemonClient["observeEvents"];
observeCheckoutDiff: import("@getpaseo/client/internal/daemon-client").DaemonClient["observeCheckoutDiff"];
observeDiff: import("@getpaseo/client/internal/daemon-client").DaemonClient["observeDiff"];
observeTerminals: import("@getpaseo/client/internal/daemon-client").DaemonClient["observeTerminals"];
}
@@ -339,34 +336,34 @@ function reconcileCheckoutDiffSubscriptions(input: {
}
for (const [key, desired] of input.desired) {
if (input.active.has(key)) continue;
const subscription = input.client.observeCheckoutDiff(desired.cwd, desired.compare);
const { mode, baseRef, ignoreWhitespace } = desired.compare;
const revision = mode === "uncommitted" ? "HEAD" : baseRef;
if (!revision) continue;
const subscription = input.client.observeDiff({
cwd: desired.cwd,
base: { kind: "rev", rev: revision },
head: mode === "base" ? { kind: "rev", rev: "HEAD" } : { kind: "worktree" },
mergeBase: mode !== "uncommitted",
ignoreWhitespace,
});
const registration = { ...desired, subscription };
input.active.set(key, registration);
const apply = (
payload: Omit<CheckoutDiffResponsePayload, "requestId"> & { requestId?: string },
) => {
if (input.active.get(key) !== registration) return;
const { subscriptionId, ...snapshot } = payload;
setCheckoutDiffPayload({
activeCheckoutDiffSubscriptions: input.active,
queryClient: input.queryClient,
serverId: input.serverId,
subscriptionId: key,
payload: {
...snapshot,
files: orderCheckoutDiffFiles(payload.files),
requestId: payload.requestId ?? `subscription:${subscriptionId}`,
},
});
};
subscription.subscribe({
snapshot: apply,
update: (message) => {
if (message.type === "checkout_diff_update") apply(message.payload);
snapshot: (state) => {
if (input.active.get(key) !== registration) return;
const snapshot = checkoutDiffSnapshot(desired.cwd, state);
setCheckoutDiffPayload({
activeCheckoutDiffSubscriptions: input.active,
queryClient: input.queryClient,
serverId: input.serverId,
subscriptionId: key,
payload: { ...snapshot, files: orderCheckoutDiffFiles(snapshot.files) },
});
},
update: () => {},
error: (error) => {
if (input.active.get(key) === registration) input.active.delete(key);
console.error("[server-data] observeCheckoutDiff failed", {
console.error("[server-data] observeDiff failed", {
serverId: input.serverId,
cwd: desired.cwd,
error,
+5 -2
View File
@@ -41,8 +41,11 @@ export function useReplicaQuery<
TError = Error,
TData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey,
>(input: ReplicaQueryInput<TQueryFnData, TError, TData, TQueryKey>): UseQueryResult<TData, TError> {
return useQuery(replicaQueryOptions(input));
>(
input: ReplicaQueryInput<TQueryFnData, TError, TData, TQueryKey>,
queryClient?: QueryClient,
): UseQueryResult<TData, TError> {
return useQuery(replicaQueryOptions(input), queryClient);
}
export function useFetchQuery<
@@ -46,6 +46,31 @@ function makeHost(): HostProfile {
};
}
function makeSnapshot(host: HostProfile): HostRuntimeSnapshot {
return {
serverId: host.serverId,
activeConnectionId: "relay:relay.secret.test:443",
activeConnection: {
type: "relay",
endpoint: "relay.secret.test:443",
display: "relay",
},
connectionStatus: "online",
client: null,
lastError: null,
lastOnlineAt: "2026-06-25T00:00:00.000Z",
agentDirectoryStatus: "ready",
agentDirectoryError: null,
hasEverLoadedAgentDirectory: true,
probeByConnectionId: new Map([
["direct:secret.example.test:6767", { status: "available", latencyMs: 42 }],
["relay:relay.secret.test:443", { status: "available", latencyMs: 8 }],
]),
clientGeneration: 1,
connectionEpoch: 1,
};
}
describe("app diagnostics report", () => {
test("reports whether the connected daemon is managed by Paseo Desktop", () => {
const report = formatServerInfoSection({
@@ -61,31 +86,11 @@ describe("app diagnostics report", () => {
test("formats connection rows without raw connection details", () => {
const host = makeHost();
const snapshot: HostRuntimeSnapshot = {
serverId: host.serverId,
activeConnectionId: "relay:relay.secret.test:443",
activeConnection: {
type: "relay",
endpoint: "relay.secret.test:443",
display: "relay",
},
connectionStatus: "online",
client: null,
lastError: null,
lastOnlineAt: "2026-06-25T00:00:00.000Z",
agentDirectoryStatus: "ready",
agentDirectoryError: null,
hasEverLoadedAgentDirectory: true,
probeByConnectionId: new Map([
["direct:secret.example.test:6767", { status: "available", latencyMs: 42 }],
["relay:relay.secret.test:443", { status: "available", latencyMs: 8 }],
]),
clientGeneration: 1,
connectionEpoch: 1,
};
const snapshot = makeSnapshot(host);
const report = formatHostRuntimeSection({ host, snapshot });
expect(report).toContain("Last online: 2026-06-25T00:00:00.000Z");
expect(report).toContain("direct TCP");
expect(report).toContain("relay");
expect(report).toContain("local socket");
@@ -97,6 +102,58 @@ describe("app diagnostics report", () => {
expect(report).not.toContain("tcp-password");
});
test.each(["connecting", "offline", "error"] as const)(
"does not claim a previously connected host was never online while %s",
(connectionStatus) => {
const host = makeHost();
const snapshot = makeSnapshot(host);
const report = formatHostRuntimeSection({
host,
snapshot: { ...snapshot, connectionStatus, lastOnlineAt: null },
});
expect(report).toContain("Last online: unknown");
expect(report).not.toContain("Last online: never");
},
);
test("does not infer connection history when the runtime has not started", () => {
const report = formatHostRuntimeSection({ host: makeHost(), snapshot: null });
expect(report).toContain("Last online: unknown");
});
test.each(["online", "error"] as const)(
"identifies a routed connection while %s",
(connectionStatus) => {
const host = makeHost();
host.connections.push({ id: "routed-origin", type: "routed", originServerId: "srv-origin" });
const snapshot: HostRuntimeSnapshot = {
serverId: host.serverId,
activeConnectionId: "routed-origin",
activeConnection: { type: "routed", endpoint: "srv-origin", display: "via srv-origin" },
connectionStatus,
client: null,
lastError: connectionStatus === "error" ? "Host disconnected" : null,
lastOnlineAt: null,
agentDirectoryStatus: "ready",
agentDirectoryError: null,
hasEverLoadedAgentDirectory: true,
probeByConnectionId: new Map(),
clientGeneration: 1,
connectionEpoch: 1,
};
const report = formatHostRuntimeSection({ host, snapshot });
expect(report).toContain("Active connection: routed");
expect(report).toContain("Connection 5: routed, active, probe=unknown");
expect(report).toContain(`Status: ${connectionStatus}`);
expect(report).toContain(`Last error: ${snapshot.lastError ?? "none"}`);
expect(report).not.toContain("srv-origin");
},
);
test("redacts saved connection secrets from collected daemon and desktop text", () => {
const host = makeHost();
const redacted = redactAppDiagnosticReport(
@@ -45,7 +45,7 @@ export function formatHostRuntimeSection(input: {
? describeConnectionKind(snapshot.activeConnection.type)
: "none",
},
{ label: "Last online", value: snapshot?.lastOnlineAt ?? "never" },
{ label: "Last online", value: snapshot?.lastOnlineAt ?? "unknown" },
{ label: "Last error", value: snapshot?.lastError ?? "none" },
{
label: "Agent directory",
@@ -100,6 +100,8 @@ export function describeConnectionKind(type: HostConnection["type"] | string): s
return "local pipe";
case "relay":
return "relay";
case "routed":
return "routed";
default:
return "unknown";
}
+2 -2
View File
@@ -13,10 +13,10 @@ import {
type DiffTreeRow,
} from "@/git/diff-tree";
import { FileHeader } from "@/git/file-header";
import type { ParsedDiffFile } from "@/git/use-diff-query";
import type { DiffFileHeader } from "@/git/diff-snapshot";
export interface ChangedFilesTreeProps {
files: ParsedDiffFile[];
files: DiffFileHeader[];
mode: WorkingDiffMode;
onSelectFile: (path: string) => void;
collapsedFolderPaths: string[];
+1 -1
View File
@@ -13,6 +13,6 @@ export function orderCheckoutDiffFiles(files: ParsedDiffFile[]): ParsedDiffFile[
// chains, so it is skipped; the collapsed set is empty because the flat list
// always carries every file, whatever the rail has collapsed.
return flattenDiffTree(buildDiffTree(files), new Set()).flatMap((row) =>
row.kind === "file" ? [row.file] : [],
row.kind === "file" ? [files[row.fileIndex]] : [],
);
}
+19 -6
View File
@@ -1,3 +1,4 @@
import type { DiffFileHeader } from "@/git/diff-snapshot";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { floatingActionsClearance } from "@/components/ui/floating-action-button";
import { useState, useCallback, useMemo, type ReactElement, type ReactNode } from "react";
@@ -1094,6 +1095,7 @@ interface DiffBodyContentProps {
emptyMessage: string;
emptyAction: ChangesEmptyAction | null;
children: ReactElement;
loadingLabel: string;
checkingRepositoryLabel: string;
notRepositoryLabel: string;
}
@@ -1110,11 +1112,16 @@ function DiffBodyContent({
emptyAction,
children,
checkingRepositoryLabel,
loadingLabel,
notRepositoryLabel,
}: DiffBodyContentProps) {
if (isStatusLoading) {
return (
<View style={styles.loadingContainer}>
<View
style={styles.loadingContainer}
accessibilityRole="progressbar"
accessibilityLabel={loadingLabel}
>
<ThemedLoadingSpinner size="large" uniProps={foregroundMutedIconColorMapping} />
<Text style={styles.loadingText}>{checkingRepositoryLabel}</Text>
</View>
@@ -1136,12 +1143,16 @@ function DiffBodyContent({
}
if (isDiffLoading) {
return (
<View style={styles.loadingContainer}>
<View
style={styles.loadingContainer}
accessibilityRole="progressbar"
accessibilityLabel={loadingLabel}
>
<ThemedLoadingSpinner size="large" uniProps={foregroundMutedIconColorMapping} />
</View>
);
}
if (diffTooLarge) {
if (diffTooLarge && !hasChanges) {
return <DiffTooLargeState />;
}
if (diffErrorMessage) {
@@ -1320,7 +1331,7 @@ function ChangesTreeRail({
}: {
shown: boolean;
children: ReactElement;
files: ParsedDiffFile[];
files: DiffFileHeader[];
mode: WorkingDiffMode;
onSelectFile: (path: string) => void;
treeWidth?: number;
@@ -1360,7 +1371,7 @@ function ChangesBody({
children: ReactElement;
desktopTreeVisible: boolean;
isMobile: boolean;
files: ParsedDiffFile[];
files: DiffFileHeader[];
mode: WorkingDiffMode;
onSelectFile: (path: string) => void;
treeWidth?: number;
@@ -1585,6 +1596,7 @@ export function ChangesSurface({
selectBase: handleSelectBase,
selectAll: handleSelectAll,
files,
treeFiles,
diffPayloadError,
diffTooLarge,
isDiffLoading,
@@ -1834,6 +1846,7 @@ export function ChangesSurface({
hasChanges={hasChanges}
emptyMessage={emptyMessage}
emptyAction={emptyAction}
loadingLabel={t("common.loading")}
checkingRepositoryLabel={t("workspace.git.diff.checkingRepository")}
notRepositoryLabel={t("workspace.git.diff.notRepository")}
>
@@ -1864,7 +1877,7 @@ export function ChangesSurface({
presentation={presentation}
desktopTreeVisible={desktopTreeVisible}
isMobile={isMobile}
files={files}
files={treeFiles}
mode={workingMode}
onSelectFile={handleSelectTreeFile}
treeWidth={instanceState.treeWidth}
+4 -2
View File
@@ -1,7 +1,8 @@
import { replaceEqualDeep } from "@tanstack/react-query";
import type { ParsedDiffFile, SubscribeCheckoutDiffResponse } from "@getpaseo/protocol/messages";
import type { CheckoutDiffSnapshot } from "./diff-snapshot";
import type { ParsedDiffFile } from "@getpaseo/protocol/messages";
type DiffPayload = Omit<SubscribeCheckoutDiffResponse["payload"], "subscriptionId">;
type DiffPayload = CheckoutDiffSnapshot;
/** File identity owns text-model reuse. Never reconstruct the hunk/token graph. */
export function retainDiffFiles(
@@ -30,6 +31,7 @@ export function shareCheckoutDiff(previous: unknown, incoming: unknown): DiffPay
error === old.error &&
old.cwd === next.cwd &&
old.requestId === next.requestId &&
old.entries === next.entries &&
old.diffTooLarge === next.diffTooLarge
? old
: { ...next, files, error };
+74
View File
@@ -0,0 +1,74 @@
import type { DiffState, DiffEntry } from "@getpaseo/client";
import type {
DiffError,
ParsedDiffFile,
SubscribeCheckoutDiffRequest,
} from "@getpaseo/protocol/messages";
export type DiffFileHeader = Omit<ParsedDiffFile, "hunks">;
export interface CheckoutDiffSnapshot {
requestId: string;
cwd: string;
files: ParsedDiffFile[];
entries?: DiffEntry[];
error: DiffError | null;
diffTooLarge?: boolean;
}
export function checkoutDiffSnapshot(cwd: string, state: DiffState): CheckoutDiffSnapshot {
const common = { cwd, requestId: `diff:${state.revision}:${state.phase}` };
if (state.phase === "loading")
return { ...common, files: [], entries: state.entries, error: null };
if (state.phase === "error") return { ...common, files: [], error: state.error };
return { ...common, files: state.files, error: null, diffTooLarge: state.diffTooLarge };
}
export function diffEntryHeaders(entries: DiffEntry[]): DiffFileHeader[] {
return entries.map((entry) => ({
path: entry.path,
...(entry.oldPath === null ? {} : { oldPath: entry.oldPath }),
isNew: entry.change === "added",
isDeleted: entry.change === "deleted",
additions: entry.additions,
deletions: entry.deletions,
status: entry.content === "binary" ? "binary" : "ok",
}));
}
export interface CheckoutDiffResult {
files: ParsedDiffFile[];
entries: DiffEntry[] | null;
payloadError: CheckoutDiffSnapshot["error"];
diffTooLarge: boolean;
isLoading: boolean;
isFetching: boolean;
isError: boolean;
error: null;
}
/**
* The result derives from the payload alone. Until a payload arrives there is no
* diff to describe, so the boundary reports loading rather than an empty diff —
* whether the query is idle (an inactive retained panel), in flight, or the host
* is disconnected. Reporting `files: []` as settled made callers render "+0 -0"
* and "No changes" for a diff nobody has fetched yet.
*/
export function deriveCheckoutDiffResult(payload: CheckoutDiffSnapshot | null): CheckoutDiffResult {
const payloadError = payload?.error ?? null;
return {
files: payload?.files ?? [],
entries: payload?.entries ?? null,
payloadError,
diffTooLarge: payload?.diffTooLarge === true,
isLoading: payload === null || payload.entries !== undefined,
isFetching: false,
isError: Boolean(payloadError),
error: null,
};
}
export function checkoutDiffComparisonError(
compare: Pick<SubscribeCheckoutDiffRequest["compare"], "mode" | "baseRef">,
): DiffError | null {
if (compare.mode === "uncommitted" || compare.baseRef?.trim()) return null;
return {
code: "INVALID_COMPARE",
message: "Comparison base is unavailable. Select Uncommitted to view pending changes.",
};
}
+5 -5
View File
@@ -1,6 +1,6 @@
import type { ParsedDiffFile } from "@/git/use-diff-query";
import type { DiffFileHeader } from "@/git/diff-snapshot";
// Builds a directory hierarchy from the `ParsedDiffFile[]` the Changes view
// Builds a directory hierarchy from the `DiffFileHeader[]` the Changes view
// renders. The tree renders on every form factor, consistent with the Files
// explorer.
//
@@ -17,7 +17,7 @@ import type { ParsedDiffFile } from "@/git/use-diff-query";
export interface DiffTreeFileNode {
kind: "file";
file: ParsedDiffFile;
file: DiffFileHeader;
fileIndex: number;
/** basename, e.g. "diff-pane.tsx" */
name: string;
@@ -47,7 +47,7 @@ export interface DiffTreeFolderRow {
export interface DiffTreeFileRow {
kind: "file";
file: ParsedDiffFile;
file: DiffFileHeader;
fileIndex: number;
depth: number;
}
@@ -73,7 +73,7 @@ function sortTree(node: DiffTreeDirNode): void {
}
/** Build the (uncompressed) directory tree. Returns the virtual root (dirPath ""). */
export function buildDiffTree(files: ParsedDiffFile[]): DiffTreeDirNode {
export function buildDiffTree(files: DiffFileHeader[]): DiffTreeDirNode {
const root: DiffTreeDirNode = { kind: "dir", dirPath: "", name: "", children: [] };
const dirByPath = new Map<string, DiffTreeDirNode>([["", root]]);
+4 -4
View File
@@ -28,11 +28,11 @@ import {
fileNameForPath,
formatDiffCount,
} from "@/git/file-header-presentation";
import type { ParsedDiffFile } from "@/git/use-diff-query";
import type { DiffFileHeader } from "@/git/diff-snapshot";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
export interface FileHeaderProps {
file: ParsedDiffFile;
file: DiffFileHeader;
workspaceFileDragScope?: { serverId: string; workspaceId: string };
bodyVisible: boolean;
showsBodyState?: boolean;
@@ -73,7 +73,7 @@ function expandedAriaValue(showsBodyState: boolean, bodyVisible: boolean): boole
return showsBodyState ? bodyVisible : undefined;
}
function fileHeaderAccessibilityLabel(canvasRendered: boolean, file: ParsedDiffFile) {
function fileHeaderAccessibilityLabel(canvasRendered: boolean, file: DiffFileHeader) {
if (!canvasRendered) return undefined;
return `${file.path}, +${formatDiffCount(file.additions)}, -${formatDiffCount(file.deletions)}`;
}
@@ -130,7 +130,7 @@ function fileHeaderNameStyle(showsBodyState: boolean, isHovered: boolean) {
];
}
function fileChange(file: ParsedDiffFile): "added" | "deleted" | "modified" {
function fileChange(file: DiffFileHeader): "added" | "deleted" | "modified" {
return diffFileChangeKind(file);
}
+26 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { ParsedDiffFile } from "@getpaseo/protocol/messages";
import { deriveCheckoutDiffResult } from "./use-diff-query";
import { deriveCheckoutDiffResult } from "./diff-snapshot";
function createParsedDiffFile(path: string): ParsedDiffFile {
return {
@@ -59,6 +59,7 @@ describe("deriveCheckoutDiffResult", () => {
expect(result).toEqual({
files: [],
entries: null,
payloadError: error,
diffTooLarge: true,
isLoading: false,
@@ -68,3 +69,27 @@ describe("deriveCheckoutDiffResult", () => {
});
});
});
it("shows the announced tree while file bodies are still loading", () => {
const entries = [
{
path: "src/app.ts",
oldPath: null,
change: "modified",
content: "text",
additions: 3,
deletions: 1,
},
] as const;
const result = deriveCheckoutDiffResult({ ...createPayload([]), entries: [...entries] });
expect(result.isLoading).toBe(true);
expect(result.entries).toEqual(entries);
expect(result.files).toEqual([]);
});
it("settles a comparison without a base and leaves Uncommitted available", async () => {
const { checkoutDiffComparisonError } = await import("./diff-snapshot");
expect(checkoutDiffComparisonError({ mode: "all" })).toMatchObject({ code: "INVALID_COMPARE" });
expect(checkoutDiffComparisonError({ mode: "base" })).toMatchObject({ code: "INVALID_COMPARE" });
expect(checkoutDiffComparisonError({ mode: "uncommitted" })).toBeNull();
});
+19 -42
View File
@@ -5,7 +5,13 @@ import { useRetainedPanelActive } from "@/components/retained-panel";
import { useReplicaQuery } from "@/data/query";
import { checkoutDiffPushRoute } from "@/data/push-router";
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import type { ParsedDiffFile, SubscribeCheckoutDiffResponse } from "@getpaseo/protocol/messages";
import {
checkoutDiffComparisonError,
deriveCheckoutDiffResult,
type CheckoutDiffSnapshot,
} from "./diff-snapshot";
export { deriveCheckoutDiffResult } from "./diff-snapshot";
import type { ParsedDiffFile } from "@getpaseo/protocol/messages";
import { checkoutDiffQueryKey } from "@/git/query-keys";
interface UseCheckoutDiffQueryOptions {
@@ -18,7 +24,7 @@ interface UseCheckoutDiffQueryOptions {
queryScope?: string;
}
type CheckoutDiffQueryPayload = Omit<SubscribeCheckoutDiffResponse["payload"], "subscriptionId">;
type CheckoutDiffQueryPayload = CheckoutDiffSnapshot;
// Re-export the canonical protocol type so all consumers share one definition.
export type { ParsedDiffFile };
@@ -77,7 +83,11 @@ export function useCheckoutDiffQuery({
return normalizedScope ? [...comparisonKey, "scope", normalizedScope] : comparisonKey;
}, [serverId, cwd, compareMode, compareBaseRef, compareIgnoreWhitespace, queryScope]);
const subscriptionId = useMemo(() => `checkoutDiff:${JSON.stringify(queryKey)}`, [queryKey]);
const routeEnabled = Boolean(queryEnabled && isConnected && cwd && !requiresUpdate);
const comparisonError =
queryEnabled && isConnected ? checkoutDiffComparisonError(normalizedCompare) : null;
const routeEnabled = Boolean(
queryEnabled && isConnected && cwd && !requiresUpdate && !comparisonError,
);
const query = useReplicaQuery<CheckoutDiffQueryPayload>({
queryKey,
@@ -97,46 +107,13 @@ export function useCheckoutDiffQuery({
}),
});
return deriveCheckoutDiffResult(
const boundaryError =
requiresUpdate && isConnected
? {
requestId: "unsupported",
cwd,
files: [],
error: { code: "UNKNOWN", message: "Update the host to view All changes" },
}
? { code: "UNKNOWN" as const, message: "Update the host to view All changes" }
: comparisonError;
return deriveCheckoutDiffResult(
boundaryError
? { requestId: "unavailable", cwd, files: [], error: boundaryError }
: (query.data ?? null),
);
}
export interface CheckoutDiffResult {
files: ParsedDiffFile[];
payloadError: CheckoutDiffQueryPayload["error"];
diffTooLarge: boolean;
isLoading: boolean;
isFetching: boolean;
isError: boolean;
error: null;
}
/**
* The result derives from the payload alone. Until a payload arrives there is no
* diff to describe, so the boundary reports loading rather than an empty diff —
* whether the query is idle (an inactive retained panel), in flight, or the host
* is disconnected. Reporting `files: []` as settled made callers render "+0 -0"
* and "No changes" for a diff nobody has fetched yet.
*/
export function deriveCheckoutDiffResult(
payload: CheckoutDiffQueryPayload | null,
): CheckoutDiffResult {
const payloadError = payload?.error ?? null;
return {
files: payload?.files ?? [],
payloadError,
diffTooLarge: payload?.diffTooLarge === true,
isLoading: payload === null,
isFetching: false,
isError: Boolean(payloadError),
error: null,
};
}
+10 -1
View File
@@ -9,6 +9,8 @@ import {
useReviewAttachmentSnapshot,
useReviewFeedback,
} from "@/review";
import { useSessionStore } from "@/stores/session-store";
import { diffEntryHeaders } from "./diff-snapshot";
import { useCheckoutDiffQuery } from "@/git/use-diff-query";
import { useCheckoutStatusQuery } from "@/git/use-status-query";
import { useWorkingDiffComparison } from "@/git/working-diff-comparison";
@@ -44,7 +46,11 @@ export function useWorkingDiff({
const statusErrorMessage =
status?.error?.message ??
(isStatusError && statusError instanceof Error ? statusError.message : null);
const baseRef = gitStatus?.baseRef ?? undefined;
const modernDiff = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.features?.diffSubscription === true,
);
// COMPAT(diffSubscription): added in v0.15.0, remove after 2027-05-06.
const baseRef = (modernDiff ? gitStatus?.comparisonBaseRef : gitStatus?.baseRef) ?? undefined;
const currentBranchName =
gitStatus?.currentBranch && gitStatus.currentBranch !== "HEAD" ? gitStatus.currentBranch : null;
@@ -59,6 +65,7 @@ export function useWorkingDiff({
const {
files,
entries,
payloadError: diffPayloadError,
diffTooLarge,
isLoading: isDiffLoading,
@@ -71,6 +78,7 @@ export function useWorkingDiff({
enabled: enabled && isGit,
queryScope,
});
const treeFiles = useMemo(() => (entries ? diffEntryHeaders(entries) : files), [entries, files]);
const reviewDraftKey = useMemo(
() =>
buildReviewDraftKey({
@@ -112,6 +120,7 @@ export function useWorkingDiff({
selectBase,
selectAll,
files,
treeFiles,
diffPayloadError,
diffTooLarge,
isDiffLoading,
+13 -12
View File
@@ -4,10 +4,14 @@ import type {
FetchAgentHistoryPageInfo,
} from "@getpaseo/client/internal/daemon-client";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useCallback, useMemo, useSyncExternalStore } from "react";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { getHostRuntimeStore, isHostRuntimeConnected, useHosts } from "@/runtime/host-runtime";
import {
getHostRuntimeStore,
useHostRuntimeConnectionStatuses,
useHosts,
} from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
import { buildAgentDirectoryState } from "@/utils/agent-directory-sync";
import { agentHistoryQueryKey, allAgentHistoryQueryKey } from "./agent-history-query-key";
@@ -222,32 +226,29 @@ export function useAgentHistory(options: {
const { t } = useTranslation();
const daemons = useHosts();
const runtime = getHostRuntimeStore();
const runtimeVersion = useSyncExternalStore(
(onStoreChange) => runtime.subscribeAll(onStoreChange),
() => runtime.getVersion(),
() => runtime.getVersion(),
);
const serverId = useMemo(() => {
const value = options.serverId;
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}, [options.serverId]);
const enabled = options.enabled ?? true;
const serverIds = useMemo(
() => (serverId ? [serverId] : daemons.map((daemon) => daemon.serverId)),
[daemons, serverId],
);
const connectionStatuses = useHostRuntimeConnectionStatuses(serverIds);
// A host the user asked about splits two ways: one this fetch can reach, and
// one whose sessions will be missing from the answer. Both come out of here,
// because dropping the unreachable ones silently is what lets the list — and
// worse, "No sessions match" — overstate what was actually searched.
const { targetHosts, unreachableHosts } = useMemo(() => {
void runtimeVersion;
const serverLabelById = new Map(daemons.map((daemon) => [daemon.serverId, daemon.label]));
const serverIds = serverId ? [serverId] : daemons.map((daemon) => daemon.serverId);
const hosts: AgentHistoryHost[] = [];
const unreachable: AgentHistoryHostError[] = [];
for (const targetServerId of serverIds) {
const snapshot = runtime.getSnapshot(targetServerId);
const client = runtime.getClient(targetServerId);
const serverName = serverLabelById.get(targetServerId) ?? targetServerId;
if (!client || !isHostRuntimeConnected(snapshot)) {
if (!client || connectionStatuses.get(targetServerId) !== "online") {
unreachable.push({ serverId: targetServerId, serverName });
continue;
}
@@ -255,7 +256,7 @@ export function useAgentHistory(options: {
}
return { targetHosts: hosts, unreachableHosts: unreachable };
}, [daemons, runtime, runtimeVersion, serverId]);
}, [daemons, runtime, connectionStatuses, serverIds]);
const targetServerIds = useMemo(() => targetHosts.map((host) => host.serverId), [targetHosts]);
// One gate, checked before the field is offered: a fleet where any host
// predates search has no search, rather than a list that silently omits that
@@ -129,7 +129,7 @@ describe("loadOlderAgentHistory", () => {
expect(client.calls).toEqual([]);
expect(inFlight.values).toEqual([true]);
expect(started).toBe(true);
expect(started).toBe(false);
});
it("requests the page before the current start cursor and clears in-flight on success", async () => {
@@ -153,6 +153,7 @@ describe("loadOlderAgentHistory", () => {
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "projected",
wholeTurns: true,
timeout: 10_000,
},
},
]);
@@ -169,7 +170,7 @@ describe("loadOlderAgentHistory", () => {
const toast = createToast();
const logger = createLogger();
await loadOlderAgentHistory(agentId, {
const applied = await loadOlderAgentHistory(agentId, {
client,
cursor: someCursor,
hasOlder: true,
@@ -179,6 +180,7 @@ describe("loadOlderAgentHistory", () => {
logger,
});
expect(applied).toBe(false);
expect(client.calls).toHaveLength(1);
expect(toast.shown).toEqual([
{
@@ -18,6 +18,7 @@ export interface LoadOlderAgentHistoryClient {
cursor: { epoch: string; seq: number };
limit: number;
projection: "projected";
timeout: number;
},
) => Promise<unknown>;
}
@@ -44,7 +45,7 @@ export async function loadOlderAgentHistory(
const { client, cursor, hasOlder, isLoadingOlder, setInFlight, toast, logger, failedMessage } =
deps;
if (isLoadingOlder) {
return true;
return false;
}
if (!client || !cursor || !hasOlder) {
return false;
@@ -52,16 +53,17 @@ export async function loadOlderAgentHistory(
setInFlight(true);
try {
await client.fetchAgentTimeline(
agentId,
planTimelineOlderFetch({ epoch: cursor.epoch, seq: cursor.startSeq }),
);
await client.fetchAgentTimeline(agentId, {
...planTimelineOlderFetch({ epoch: cursor.epoch, seq: cursor.startSeq }),
timeout: 10_000,
});
} catch (error) {
(logger ?? console).warn("[Timeline] failed to load older agent history", agentId, error);
toast?.show(failedMessage ?? i18n.t("loadOlderHistory.failed"), {
durationMs: 2200,
testID: "agent-load-older-history-toast",
});
return false;
} finally {
setInFlight(false);
}
+14
View File
@@ -1179,6 +1179,14 @@ export const ar: TranslationResources = {
},
},
sidebar: {
view: {
failed: "تعذر تحميل عرض الشريط الجانبي.",
useDefault: "استخدام العرض الافتراضي",
readFailed: "تعذر تحميل تفضيل عرض الشريط الجانبي.",
agentUnavailable: "لم يعد الوكيل متاحًا",
terminalUnavailable: "لم تعد الطرفية متاحة",
projectUnavailable: "لم يعد المشروع متاحًا",
},
statusGroupAccessibility: "مجموعة {{label}}",
statusBucket: {
needsInput: "تحتاج إدخالاً",
@@ -2607,6 +2615,12 @@ export const ar: TranslationResources = {
description: "عرض مخطط للتنقل بين المطالبات",
},
sidebar: {
view: {
title: "عرض الشريط الجانبي",
label: "العرض",
default: "العرض الافتراضي",
unavailable: "العرض المحدد غير متاح أو معطل. يتم عرض العرض الافتراضي.",
},
header: {
title: "الرأس",
description: "اختر العناصر التي تظهر أعلى الشريط الجانبي وترتيبها",
+14
View File
@@ -1187,6 +1187,14 @@ export const en = {
},
},
sidebar: {
view: {
failed: "Sidebar view failed to load.",
useDefault: "Use default view",
readFailed: "Could not load your sidebar view preference.",
agentUnavailable: "Agent is no longer available",
terminalUnavailable: "Terminal is no longer available",
projectUnavailable: "Project is no longer available",
},
statusGroupAccessibility: "{{label}} group",
statusBucket: {
needsInput: "Needs input",
@@ -2698,6 +2706,12 @@ export const en = {
description: "Show an outline for jumping between prompts",
},
sidebar: {
view: {
title: "Sidebar view",
label: "View",
default: "Default view",
unavailable: "The selected view is unavailable or disabled. Showing Default view.",
},
header: {
title: "Header",
description: "Choose which items appear at the top of the sidebar and in what order",
+15
View File
@@ -1215,6 +1215,14 @@ export const es: TranslationResources = {
},
},
sidebar: {
view: {
failed: "No se pudo cargar la vista de la barra lateral.",
useDefault: "Usar vista predeterminada",
readFailed: "No se pudo cargar tu preferencia de vista de la barra lateral.",
agentUnavailable: "El agente ya no está disponible",
terminalUnavailable: "La terminal ya no está disponible",
projectUnavailable: "El proyecto ya no está disponible",
},
statusGroupAccessibility: "Grupo {{label}}",
statusBucket: {
needsInput: "Necesita datos",
@@ -2627,6 +2635,13 @@ export const es: TranslationResources = {
description: "Muestra un esquema para saltar entre instrucciones",
},
sidebar: {
view: {
title: "Vista de la barra lateral",
label: "Vista",
default: "Vista predeterminada",
unavailable:
"La vista seleccionada no está disponible o está desactivada. Se muestra la vista predeterminada.",
},
header: {
title: "Encabezado",
description:
+15
View File
@@ -1208,6 +1208,14 @@ export const fr: TranslationResources = {
},
},
sidebar: {
view: {
failed: "Impossible de charger la vue de la barre latérale.",
useDefault: "Utiliser la vue par défaut",
readFailed: "Impossible de charger votre préférence de vue de la barre latérale.",
agentUnavailable: "Cet agent n’est plus disponible",
terminalUnavailable: "Ce terminal n’est plus disponible",
projectUnavailable: "Ce projet n’est plus disponible",
},
statusGroupAccessibility: "Groupe {{label}}",
statusBucket: {
needsInput: "Attend une réponse",
@@ -2628,6 +2636,13 @@ export const fr: TranslationResources = {
description: "Afficher un plan pour naviguer entre les prompts",
},
sidebar: {
view: {
title: "Vue de la barre latérale",
label: "Vue",
default: "Vue par défaut",
unavailable:
"La vue sélectionnée est indisponible ou désactivée. La vue par défaut est affichée.",
},
header: {
title: "En-tête",
description:
+15
View File
@@ -1192,6 +1192,14 @@ export const ja: TranslationResources = {
},
},
sidebar: {
view: {
failed: "サイドバーのビューを読み込めませんでした。",
useDefault: "デフォルトのビューを使用",
readFailed: "サイドバーのビュー設定を読み込めませんでした。",
agentUnavailable: "このエージェントは利用できなくなりました",
terminalUnavailable: "このターミナルは利用できなくなりました",
projectUnavailable: "このプロジェクトは利用できなくなりました",
},
statusGroupAccessibility: "{{label}} グループ",
statusBucket: {
needsInput: "入力待ち",
@@ -2594,6 +2602,13 @@ export const ja: TranslationResources = {
description: "プロンプト間を移動するためのアウトラインを表示します",
},
sidebar: {
view: {
title: "サイドバーのビュー",
label: "ビュー",
default: "デフォルトのビュー",
unavailable:
"選択したビューは利用できないか無効です。デフォルトのビューを表示しています。",
},
header: {
title: "ヘッダー",
description: "サイドバー上部に表示する項目とその順序を選択します",
+15
View File
@@ -1186,6 +1186,14 @@ export const ko: TranslationResources = {
},
},
sidebar: {
view: {
failed: "사이드바 보기를 불러오지 못했습니다.",
useDefault: "기본 보기 사용",
readFailed: "사이드바 보기 설정을 불러오지 못했습니다.",
agentUnavailable: "에이전트를 더 이상 사용할 수 없습니다",
terminalUnavailable: "터미널을 더 이상 사용할 수 없습니다",
projectUnavailable: "프로젝트를 더 이상 사용할 수 없습니다",
},
statusGroupAccessibility: "{{label}} 그룹",
statusBucket: {
needsInput: "입력 필요",
@@ -2585,6 +2593,13 @@ export const ko: TranslationResources = {
description: "프롬프트 사이를 이동하기 위한 개요 표시",
},
sidebar: {
view: {
title: "사이드바 보기",
label: "보기",
default: "기본 보기",
unavailable:
"선택한 보기를 사용할 수 없거나 비활성화되어 있습니다. 기본 보기를 표시합니다.",
},
header: {
title: "헤더",
description: "사이드바 상단에 표시할 항목과 순서를 선택하세요",
+15
View File
@@ -1205,6 +1205,14 @@ export const ptBR: TranslationResources = {
},
},
sidebar: {
view: {
failed: "Não foi possível carregar a visualização da barra lateral.",
useDefault: "Usar visualização padrão",
readFailed: "Não foi possível carregar sua preferência de visualização da barra lateral.",
agentUnavailable: "O agente não está mais disponível",
terminalUnavailable: "O terminal não está mais disponível",
projectUnavailable: "O projeto não está mais disponível",
},
statusGroupAccessibility: "Grupo {{label}}",
statusBucket: {
needsInput: "Precisa de resposta",
@@ -2609,6 +2617,13 @@ export const ptBR: TranslationResources = {
description: "Mostrar uma estrutura para navegar entre prompts",
},
sidebar: {
view: {
title: "Visualização da barra lateral",
label: "Visualização",
default: "Visualização padrão",
unavailable:
"A visualização selecionada está indisponível ou desativada. Exibindo a visualização padrão.",
},
header: {
title: "Cabeçalho",
description: "Escolha quais itens aparecem no topo da barra lateral e em que ordem",
+14
View File
@@ -1196,6 +1196,14 @@ export const ru: TranslationResources = {
},
},
sidebar: {
view: {
failed: "Не удалось загрузить вид боковой панели.",
useDefault: "Использовать стандартный вид",
readFailed: "Не удалось загрузить настройку вида боковой панели.",
agentUnavailable: "Агент больше недоступен",
terminalUnavailable: "Терминал больше недоступен",
projectUnavailable: "Проект больше недоступен",
},
statusGroupAccessibility: "Группа «{{label}}»",
statusBucket: {
needsInput: "Ожидает ввода",
@@ -2615,6 +2623,12 @@ export const ru: TranslationResources = {
description: "Показывать оглавление для перехода между запросами",
},
sidebar: {
view: {
title: "Вид боковой панели",
label: "Вид",
default: "Стандартный вид",
unavailable: "Выбранный вид недоступен или отключён. Показан стандартный вид.",
},
header: {
title: "Верх",
description:
+14
View File
@@ -1171,6 +1171,14 @@ export const zhCN: TranslationResources = {
},
},
sidebar: {
view: {
failed: "无法加载侧边栏视图。",
useDefault: "使用默认视图",
readFailed: "无法加载侧边栏视图偏好设置。",
agentUnavailable: "此智能体已不可用",
terminalUnavailable: "此终端已不可用",
projectUnavailable: "此项目已不可用",
},
statusGroupAccessibility: "{{label}} 分组",
statusBucket: {
needsInput: "需要输入",
@@ -2544,6 +2552,12 @@ export const zhCN: TranslationResources = {
description: "显示用于在提示词之间跳转的大纲",
},
sidebar: {
view: {
title: "侧边栏视图",
label: "视图",
default: "默认视图",
unavailable: "所选视图不可用或已禁用。正在显示默认视图。",
},
header: {
title: "顶部",
description: "选择侧边栏顶部显示的项目及其顺序",
+15
View File
@@ -1012,6 +1012,7 @@ function ChatAgentContent({
effectiveAgent={effectiveAgent}
routeBottomAnchorRequest={routeBottomAnchorRequest}
hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory}
hasCachedHistory={replicaTimelineStatus === "painted"}
toastApi={toastApi}
toast={toastState}
dismiss={dismissToast}
@@ -1043,6 +1044,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
effectiveAgent,
routeBottomAnchorRequest,
hasAppliedAuthoritativeHistory,
hasCachedHistory,
toastApi,
toast,
dismiss,
@@ -1071,6 +1073,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
effectiveAgent: AgentScreenAgent | null;
routeBottomAnchorRequest: RouteBottomAnchorRequest;
hasAppliedAuthoritativeHistory: boolean;
hasCachedHistory: boolean;
toastApi: ToastApi;
toast: ToastState | null;
dismiss: () => void;
@@ -1198,6 +1201,12 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
draft={draft}
routeBottomAnchorRequest={routeBottomAnchorRequest}
hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory}
isInitialHistoryLoading={
routeBottomAnchorRequest?.reason === "initial-entry" &&
!hasCachedHistory &&
!showHistorySyncError
}
revealInitialHistory={hasCachedHistory || showHistorySyncError}
hasActiveComposer={hasActiveComposer}
toast={toastApi}
onOpenWorkspaceFile={onOpenWorkspaceFile}
@@ -1359,6 +1368,8 @@ const AgentStreamSection = memo(function AgentStreamSection({
draft,
routeBottomAnchorRequest,
hasAppliedAuthoritativeHistory,
isInitialHistoryLoading,
revealInitialHistory,
hasActiveComposer,
toast,
onOpenWorkspaceFile,
@@ -1370,6 +1381,8 @@ const AgentStreamSection = memo(function AgentStreamSection({
draft: DraftConversation | null;
routeBottomAnchorRequest: RouteBottomAnchorRequest;
hasAppliedAuthoritativeHistory: boolean;
isInitialHistoryLoading: boolean;
revealInitialHistory: boolean;
hasActiveComposer: boolean;
toast: ReturnType<typeof useToastHost>["api"];
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
@@ -1460,6 +1473,8 @@ const AgentStreamSection = memo(function AgentStreamSection({
pendingPermissions={pendingPermissions}
routeBottomAnchorRequest={routeBottomAnchorRequest}
isAuthoritativeHistoryReady={hasAppliedAuthoritativeHistory}
isInitialHistoryLoading={isInitialHistoryLoading}
revealInitialHistory={revealInitialHistory}
bottomOverlayTailClearance={bottomOverlayTailClearance}
bottomOverlayControlClearance={bottomOverlayControlClearance}
toast={toast}
@@ -26,6 +26,7 @@ import {
providerSubagentLifecycleStatus,
refreshProviderSubagents,
observeProviderSubagentTimeline,
loadOlderProviderSubagentTimeline,
useProviderSubagentStore,
} from "@/subagents/provider-store";
import { useTranslation } from "react-i18next";
@@ -177,26 +178,24 @@ function ProviderSubagentPanel() {
});
}, [client, supported, serverId, target.parentAgentId, target.subagentId]);
const loadOlder = useCallback((): boolean => {
const loadOlder = useCallback(async (): Promise<boolean> => {
if (!client || !supported || isLoadingOlder || !timeline?.hasOlder || !timeline.epoch) {
return false;
}
const firstSeq = timeline.cursor?.startSeq ?? null;
if (firstSeq === null) return false;
setIsLoadingOlder(true);
void client
.fetchProviderSubagentTimeline(target.parentAgentId, target.subagentId, {
direction: "before",
cursor: { epoch: timeline.epoch, seq: firstSeq },
try {
return await loadOlderProviderSubagentTimeline({
client,
serverId,
parentAgentId: target.parentAgentId,
subagentId: target.subagentId,
limit: TIMELINE_FETCH_PAGE_SIZE,
})
.then((payload) => {
useProviderSubagentStore.getState().replaceTimeline(serverId, payload);
return undefined;
})
.catch(() => undefined)
.finally(() => setIsLoadingOlder(false));
return true;
});
} catch {
return false;
} finally {
setIsLoadingOlder(false);
}
}, [
client,
isLoadingOlder,
+2 -1
View File
@@ -1,3 +1,4 @@
import { getPluginClientApi } from "./client-state/api";
import { callPluginRpc } from "@getpaseo/plugin/client/host";
import type {
PluginAgentCommandContext,
@@ -32,7 +33,7 @@ export function createPluginCapabilities(
navigation.openSurface(plugin.id, screenId, params);
}
return {
paseo: plugin.paseo,
paseo: getPluginClientApi(plugin).paseo,
rpc: (contract, input) => callPluginRpc(contract, plugin.invoke, input),
openSettings(screenId) {
if (!plugin.settingsScreens.some((screen) => screen.id === screenId))
@@ -23,6 +23,7 @@ function installation(): InstalledPlugin {
cleanup: () => undefined,
settingsScreens: [],
surfaces: [],
sidebarViews: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
filePanels: [],
@@ -1,3 +1,4 @@
import { getPluginClientApi } from "./client-state/api";
import appPackage from "../../package.json";
import { afterEach, expect, it } from "vitest";
import { QueryObserver } from "@tanstack/react-query";
@@ -139,7 +140,7 @@ function harness() {
},
installation.lifetime.signal,
),
paseo: installation.paseo,
paseo: getPluginClientApi(installation).paseo,
i18n: installation.i18n,
rpc: async () => {
throw new Error("Unexpected runtime RPC");
+2 -3
View File
@@ -1,6 +1,5 @@
import { createPlayAudio, type AudioEngine } from "@/audio";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { createPluginHosts } from "./hosts";
import { getPluginClientApi } from "./client-state/api";
import type { PluginClientOpenPanelOptions } from "@getpaseo/plugin/client";
import { subscribePluginRpc } from "@getpaseo/plugin/client/host";
import {
@@ -27,7 +26,7 @@ export function createPluginClientRuntime(
...capabilities,
i18n: installation.i18n,
playAudio: createPlayAudio(audio, installation.lifetime.signal),
hosts: createPluginHosts(getHostRuntimeStore(), installation.lifetime.signal),
hosts: getPluginClientApi(installation).hosts,
addComposerPill(contribution) {
return pluginButtonStore.addComposerPill(installation, contribution);
},
@@ -0,0 +1,68 @@
import { createPluginReferenceApi } from "@getpaseo/plugin/client/host";
import { router } from "expo-router";
import { i18n } from "@/i18n/i18next";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
import { usePanelStore } from "@/stores/panel-store";
import { buildNewWorkspaceRoute } from "@/utils/host-routes";
import { createPluginHosts } from "../hosts";
import type { InstalledPlugin } from "../types";
import { createPluginClientStateSource } from "./source";
interface InstallationApi {
paseo: ReturnType<typeof createPluginReferenceApi>;
hosts: ReturnType<typeof createPluginHosts>;
}
// Registry presentation clones share the installation lifetime and its client pool.
const apis = new WeakMap<AbortSignal, InstallationApi>();
export function getPluginClientApi(plugin: InstalledPlugin): InstallationApi {
const cached = apis.get(plugin.lifetime.signal);
if (cached) return cached;
const state = createPluginClientStateSource();
const hosts = createPluginHosts(getHostRuntimeStore(), plugin.lifetime.signal);
const paseo = createPluginReferenceApi({
paseo: plugin.paseo,
resolveHost: hosts.getPaseoClient,
readProject: state.getProject,
navigation: {
openWorkspace(ref) {
navigateToWorkspace({ serverId: ref.hostId, workspaceId: ref.id });
},
openAgent(ref) {
const agent = state.getAgent(ref);
if (!agent) throw new Error(i18n.t("sidebar.view.agentUnavailable"));
navigateToWorkspace({
serverId: ref.hostId,
workspaceId: agent.workspace.id,
target: { kind: "agent", agentId: ref.id },
});
},
openTerminal(ref) {
const terminal = state.getTerminal(ref);
if (!terminal) throw new Error(i18n.t("sidebar.view.terminalUnavailable"));
navigateToWorkspace({
serverId: ref.hostId,
workspaceId: terminal.workspace.id,
target: { kind: "terminal", terminalId: ref.id },
});
},
openProject(ref) {
const project = state.getProject(ref);
if (!project) throw new Error(i18n.t("sidebar.view.projectUnavailable"));
usePanelStore.getState().showMobileAgent();
router.push(
buildNewWorkspaceRoute({
serverId: ref.hostId,
projectId: ref.id,
sourceDirectory: project.rootPath,
displayName: project.displayName,
}),
);
},
},
});
const api = { paseo, hosts };
apis.set(plugin.lifetime.signal, api);
return api;
}
@@ -0,0 +1,72 @@
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import type { PluginConnectedHostsSource } from "./source";
interface PluginHostsRuntime {
getHosts(): readonly { serverId: string }[];
getSnapshot(serverId: string): { connectionStatus: string } | null | undefined;
subscribeAll(listener: () => void): () => void;
subscribeHostList(listener: () => void): () => void;
acquireDirectoryDemand(serverId: string): () => void;
}
export function createConnectedPluginHosts(
getRuntime: () => PluginHostsRuntime,
): PluginConnectedHostsSource {
let snapshot: readonly string[] = [];
const listeners = new Set<() => void>();
let release: (() => void) | null = null;
const source: PluginConnectedHostsSource = {
getSnapshot() {
const runtime = getRuntime();
const next = runtime
.getHosts()
.filter((host) => runtime.getSnapshot(host.serverId)?.connectionStatus === "online")
.map((host) => host.serverId);
if (next.length !== snapshot.length || next.some((id, index) => id !== snapshot[index]))
snapshot = Object.freeze(next);
return snapshot;
},
subscribe(listener) {
listeners.add(listener);
if (!release) {
const runtime = getRuntime();
const demands = new Map<string, () => void>();
const reconcile = () => {
const ids = new Set(source.getSnapshot());
for (const [id, releaseDemand] of demands) {
if (!ids.has(id)) {
demands.delete(id);
releaseDemand();
}
}
for (const id of ids) {
if (!demands.has(id)) demands.set(id, runtime.acquireDirectoryDemand(id));
}
};
const notify = () => {
reconcile();
for (const subscriber of listeners) subscriber();
};
const releaseRuntime = runtime.subscribeAll(notify);
const releaseHosts = runtime.subscribeHostList(notify);
reconcile();
release = () => {
releaseRuntime();
releaseHosts();
for (const releaseDemand of demands.values()) releaseDemand();
demands.clear();
};
}
return () => {
listeners.delete(listener);
if (listeners.size === 0 && release) {
release();
release = null;
}
};
},
};
return source;
}
export const connectedPluginHosts = createConnectedPluginHosts(getHostRuntimeStore);
@@ -0,0 +1,53 @@
import type { Agent, WorkspaceDescriptor } from "@/stores/session-store";
export const workspace: WorkspaceDescriptor = {
createdAt: null,
lastUserMessageAt: null,
id: "workspace-1",
projectId: "project-1",
projectDisplayName: "Paseo",
projectRootPath: "/repo/paseo",
workspaceDirectory: "/repo/paseo/review",
projectKind: "git",
workspaceKind: "worktree",
name: "Review",
title: "Plugin review",
pinnedAt: null,
status: "running",
statusEnteredAt: new Date("2026-08-16T10:00:00.000Z"),
archivingAt: null,
diffStat: { additions: 12, deletions: 3 },
scripts: [],
};
export const agent: Agent = {
serverId: "host-1",
id: "agent-1",
provider: "codex",
status: "running",
turn: { phase: "idle", cancellationRequestId: null },
createdAt: new Date("2026-08-16T10:01:00.000Z"),
updatedAt: new Date("2026-08-16T10:02:00.000Z"),
lastUserMessageAt: null,
lastActivityAt: new Date("2026-08-16T10:03:00.000Z"),
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
currentModeId: "code",
availableModes: [],
pendingPermissions: [],
persistence: null,
title: "Implement plugin panels",
cwd: "/repo/paseo/review",
workspaceId: "workspace-1",
model: "gpt-5.6",
thinkingOptionId: "high",
requiresAttention: true,
attentionReason: "permission",
parentAgentId: null,
labels: { phase: "implementation" },
};
@@ -0,0 +1,3 @@
import { createEntityRefs } from "@getpaseo/plugin/client/host";
export const entityRefs = createEntityRefs();
@@ -1,66 +1,20 @@
import { i18n } from "@/i18n/i18next";
import { fr } from "@/i18n/resources/fr";
import { describe, expect, it } from "vitest";
import type { Agent, WorkspaceDescriptor } from "@/stores/session-store";
import { createPluginAgentSnapshot, createPluginWorkspaceSnapshot } from "./snapshots";
const workspace: WorkspaceDescriptor = {
createdAt: null,
lastUserMessageAt: null,
id: "workspace-1",
projectId: "project-1",
projectDisplayName: "Paseo",
projectRootPath: "/repo/paseo",
workspaceDirectory: "/repo/paseo/review",
projectKind: "git",
workspaceKind: "worktree",
name: "Review",
title: "Plugin review",
pinnedAt: null,
status: "running",
statusEnteredAt: new Date("2026-08-16T10:00:00.000Z"),
archivingAt: null,
diffStat: { additions: 12, deletions: 3 },
scripts: [],
};
const agent: Agent = {
serverId: "host-1",
id: "agent-1",
provider: "codex",
status: "running",
turn: { phase: "idle", cancellationRequestId: null },
createdAt: new Date("2026-08-16T10:01:00.000Z"),
updatedAt: new Date("2026-08-16T10:02:00.000Z"),
lastUserMessageAt: null,
lastActivityAt: new Date("2026-08-16T10:03:00.000Z"),
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
currentModeId: "code",
availableModes: [],
pendingPermissions: [],
persistence: null,
title: "Implement plugin panels",
cwd: "/repo/paseo/review",
workspaceId: "workspace-1",
model: "gpt-5.6",
thinkingOptionId: "high",
requiresAttention: true,
attentionReason: "permission",
parentAgentId: null,
labels: { phase: "implementation" },
};
import type { WorkspaceDescriptor } from "@/stores/session-store";
import {
createPluginAgentSnapshot,
createPluginWorkspaceSnapshot,
createPluginProjectSnapshot,
} from "./snapshots";
import { workspace, agent } from "./fixtures";
describe("plugin context snapshots", () => {
it("commits a deeply immutable normalized workspace snapshot synchronously", () => {
const snapshot = createPluginWorkspaceSnapshot(workspace);
expect(createPluginWorkspaceSnapshot(workspace)).toBe(snapshot);
const snapshot = createPluginWorkspaceSnapshot(workspace, "host-1");
expect(createPluginWorkspaceSnapshot(workspace, "host-1")).toBe(snapshot);
expect(snapshot).toEqual({
expect(snapshot).toMatchObject({
id: "workspace-1",
projectId: "project-1",
projectDisplayName: "Paseo",
@@ -90,7 +44,7 @@ describe("plugin context snapshots", () => {
const snapshot = createPluginAgentSnapshot(agent, workspace.id);
expect(createPluginAgentSnapshot(agent, workspace.id)).toBe(snapshot);
expect(snapshot).toEqual({
expect(snapshot).toMatchObject({
id: "agent-1",
workspaceId: "workspace-1",
provider: "codex",
@@ -119,3 +73,82 @@ describe("plugin context snapshots", () => {
expect(agent.labels).toEqual({ phase: "implementation" });
});
});
describe("sidebar entity snapshots", () => {
it("interns cross-host references and preserves identity across unrelated reads", () => {
const first = createPluginWorkspaceSnapshot(workspace, "host-1");
const second = createPluginWorkspaceSnapshot(workspace, "host-2");
expect(first.ref).not.toBe(second.ref);
expect(first.ref.hostId).toBe("host-1");
expect(first.project.id).toBe(workspace.projectId);
expect(Object.isFrozen(first.labels)).toBe(true);
expect(createPluginWorkspaceSnapshot(workspace, "host-1")).toBe(first);
expect(createPluginAgentSnapshot(agent, workspace.id).workspace).toBe(first.ref);
});
it("invalidates a done workspace when its effective root-agent status changes", () => {
const idle: WorkspaceDescriptor = { ...workspace, status: "done" };
const before = createPluginWorkspaceSnapshot(idle, "host-1");
const activity = new Map([
[idle.id, { agentId: agent.id, status: "needs_input" as const, enteredAt: null }],
]);
const after = createPluginWorkspaceSnapshot(idle, "host-1", {
workspaceAgentActivity: activity,
});
expect(before.indicator).toBe("done");
expect(after.status).toBe("done");
expect(after.indicator).toBe("needs_input");
expect(after.indicatorLabel).toBe("Needs input");
expect(after.ref).toBe(before.ref);
expect(
createPluginWorkspaceSnapshot(idle, "host-1", { workspaceAgentActivity: activity }),
).toBe(after);
});
it("reads projects without requiring a workspace", () => {
const project = {
projectId: "empty",
projectDisplayName: "Empty",
projectCustomName: null,
projectRootPath: "/empty",
projectKind: "git" as const,
};
const snapshot = createPluginProjectSnapshot(project, "host-1");
expect(createPluginProjectSnapshot(project, "host-1")).toBe(snapshot);
expect(snapshot.displayName).toBe("Empty");
expect(snapshot.ref.id).toBe("empty");
expect(Object.isFrozen(snapshot)).toBe(true);
});
});
it("uses the app status labels and invalidates a snapshot on language change", async () => {
i18n.addResourceBundle("fr", "translation", fr);
const previousLanguage = i18n.language;
try {
await i18n.changeLanguage("en");
const english = createPluginWorkspaceSnapshot(workspace, "host-1");
expect(english.indicatorLabel).toBe("Working");
await i18n.changeLanguage("fr");
const french = createPluginWorkspaceSnapshot(workspace, "host-1");
expect(french).not.toBe(english);
expect(french.ref).toBe(english.ref);
expect(french.indicatorLabel).toBe("En cours");
expect(createPluginWorkspaceSnapshot(workspace, "host-1")).toBe(french);
} finally {
await i18n.changeLanguage(previousLanguage);
}
});
it("preserves the host-qualified parent of a remotely delegated agent", () => {
const child = {
...agent,
serverId: "host-2",
parentAgentId: null,
labels: { "paseo.parent-server-id": "host-1", "paseo.parent-agent-id": "parent" },
};
expect(createPluginAgentSnapshot(child, workspace.id).parentAgent).toMatchObject({
hostId: "host-1",
id: "parent",
kind: "agent",
});
});
@@ -1,16 +1,57 @@
import type { PluginAgentSnapshot, PluginWorkspaceSnapshot } from "@getpaseo/plugin";
import type { Agent, WorkspaceDescriptor } from "@/stores/session-store";
import { getParentAddressFromLabels } from "@getpaseo/protocol/agent-labels";
import { i18n } from "@/i18n/i18next";
import type {
PluginAgentSnapshot,
PluginWorkspaceSnapshot,
PluginProjectSnapshot,
} from "@getpaseo/plugin";
import type { Agent, WorkspaceDescriptor, ProjectDescriptor } from "@/stores/session-store";
const workspaceSnapshots = new WeakMap<WorkspaceDescriptor, PluginWorkspaceSnapshot>();
import { entityRefs } from "./refs";
import { deriveEffectiveWorkspaceStatus } from "@/workspace-status";
interface CachedWorkspace {
language: string;
snapshot: PluginWorkspaceSnapshot;
}
const workspaceSnapshots = new WeakMap<WorkspaceDescriptor, Map<string, CachedWorkspace>>();
const agentSnapshots = new WeakMap<Agent, Map<string, PluginAgentSnapshot>>();
export function createPluginWorkspaceSnapshot(
workspace: WorkspaceDescriptor,
hostId: string,
context: Omit<Parameters<typeof deriveEffectiveWorkspaceStatus>[0], "workspace" | "serverId"> & {
parentAgent?: PluginWorkspaceSnapshot["parentAgent"];
} = {},
): PluginWorkspaceSnapshot {
const cached = workspaceSnapshots.get(workspace);
if (cached) return cached;
const indicator = deriveEffectiveWorkspaceStatus({
...context,
workspace,
serverId: hostId,
}).status;
const parentAgent = context.parentAgent ?? null;
const cached = workspaceSnapshots.get(workspace)?.get(hostId);
if (
cached &&
cached.language === i18n.language &&
cached.snapshot.indicator === indicator &&
cached.snapshot.parentAgent === parentAgent
)
return cached.snapshot;
const diffStat = workspace.diffStat ? Object.freeze({ ...workspace.diffStat }) : null;
const snapshot = Object.freeze({
ref: entityRefs.workspace(hostId, workspace.id),
hostId,
project: entityRefs.project(hostId, workspace.projectId),
pinnedAt: workspace.pinnedAt ?? null,
labels: Object.freeze([...(workspace.labels ?? [])]),
background: workspace.background ?? false,
branch: workspace.gitRuntime?.currentBranch ?? null,
createdAt: serializeTimestamp(workspace.createdAt),
lastPromptAt: serializeTimestamp(workspace.lastUserMessageAt),
parentAgent,
indicator,
indicatorLabel: i18n.t(indicatorLabelKeys[indicator]),
id: workspace.id,
projectId: workspace.projectId,
projectDisplayName: workspace.projectDisplayName,
@@ -21,18 +62,25 @@ export function createPluginWorkspaceSnapshot(
name: workspace.name,
title: workspace.title ?? null,
status: workspace.status,
statusEnteredAt: workspace.statusEnteredAt?.toISOString() ?? null,
statusEnteredAt: serializeTimestamp(workspace.statusEnteredAt),
archivingAt: workspace.archivingAt,
diffStat,
});
workspaceSnapshots.set(workspace, snapshot);
const byHost = workspaceSnapshots.get(workspace) ?? new Map<string, CachedWorkspace>();
byHost.set(hostId, { language: i18n.language, snapshot });
workspaceSnapshots.set(workspace, byHost);
return snapshot;
}
export function createPluginAgentSnapshot(agent: Agent, workspaceId: string): PluginAgentSnapshot {
const cached = agentSnapshots.get(agent)?.get(workspaceId);
if (cached) return cached;
const parent = getParentAddressFromLabels(agent.labels, agent.serverId);
const snapshot = Object.freeze({
ref: entityRefs.agent(agent.serverId, agent.id),
hostId: agent.serverId,
workspace: entityRefs.workspace(agent.serverId, workspaceId),
parentAgent: parent ? entityRefs.agent(parent.serverId, parent.agentId) : null,
id: agent.id,
workspaceId,
provider: agent.provider,
@@ -55,3 +103,36 @@ export function createPluginAgentSnapshot(agent: Agent, workspaceId: string): Pl
agentSnapshots.set(agent, byWorkspace);
return snapshot;
}
const indicatorLabelKeys = {
needs_input: "sidebar.statusBucket.needsInput",
failed: "sidebar.statusBucket.failed",
running: "sidebar.statusBucket.working",
attention: "sidebar.statusBucket.readyToReview",
done: "sidebar.statusBucket.done",
} as const;
const projectSnapshots = new WeakMap<ProjectDescriptor, Map<string, PluginProjectSnapshot>>();
export function createPluginProjectSnapshot(
project: ProjectDescriptor,
hostId: string,
): PluginProjectSnapshot {
const cached = projectSnapshots.get(project)?.get(hostId);
if (cached) return cached;
const snapshot = Object.freeze({
ref: entityRefs.project(hostId, project.projectId),
hostId,
displayName: project.projectCustomName ?? project.projectDisplayName,
rootPath: project.projectRootPath,
kind: project.projectKind,
iconRevision: project.projectCustomIconRevision ?? project.projectIconRevision ?? null,
});
const byHost = projectSnapshots.get(project) ?? new Map<string, PluginProjectSnapshot>();
byHost.set(hostId, snapshot);
projectSnapshots.set(project, byHost);
return snapshot;
}
function serializeTimestamp(value: Date | null): string | null {
return value ? value.toISOString() : null;
}
@@ -0,0 +1,55 @@
import { setFlagsFromString } from "node:v8";
import { runInNewContext } from "node:vm";
import { setImmediate } from "node:timers/promises";
import { expect, it } from "vitest";
import { useSessionStore } from "@/stores/session-store";
import { createPluginClientStateSource } from "./source";
import { workspace } from "./fixtures";
setFlagsFromString("--expose_gc");
const collectGarbage: () => void = runInNewContext("gc");
setFlagsFromString("--no-expose_gc");
function observeHost() {
const hostId = "removed-plugin-host";
let hosts = [hostId];
const store = useSessionStore.getState();
store.initializeSession(hostId, null);
store.setWorkspaces(hostId, new Map([[workspace.id, { ...workspace }]]));
const source = createPluginClientStateSource(undefined, {
hosts: { getSnapshot: () => hosts, subscribe: () => () => {} },
});
const release = source.subscribe(() => source.getSnapshot());
source.getSnapshot();
const reference = new WeakRef(useSessionStore.getState().sessions[hostId]!.workspaces);
const remove = () => {
hosts = [];
store.clearSession(hostId);
};
return { reference, release, remove };
}
it("releases removed hosts' projected data while other plugin readers remain", async () => {
const { reference, release, remove } = observeHost();
remove();
try {
for (let attempt = 0; attempt < 3; attempt++) {
await setImmediate();
collectGarbage();
}
expect(reference.deref()).toBeUndefined();
} finally {
release();
}
});
it("releases previous projections when the last plugin reader leaves", async () => {
const { reference, release, remove } = observeHost();
release();
remove();
for (let attempt = 0; attempt < 3; attempt++) {
await setImmediate();
collectGarbage();
}
expect(reference.deref()).toBeUndefined();
});
@@ -0,0 +1,420 @@
import { Nested } from "../../../../../plugin-examples/sidebar-views/client/nested";
import { lightTheme } from "@/styles/theme";
import { toPluginTheme } from "../theme";
import { createConnectedPluginHosts } from "./connected-hosts";
import { i18n } from "@/i18n/i18next";
import { fr } from "@/i18n/resources/fr";
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it } from "vitest";
import { createEntityRefs, PluginClientStateProvider } from "@getpaseo/plugin/client/host";
import {
useAgents,
useWorkspaces,
useWorkspace,
useAgent,
type WorkspaceRef,
type AgentRef,
} from "@getpaseo/plugin/client";
import { useSessionStore, type Agent } from "@/stores/session-store";
import { createPluginClientStateSource, type PluginConnectedHostsSource } from "./source";
import { agent, workspace } from "./fixtures";
let root: Root | null = null;
let container: HTMLDivElement | null = null;
const renders = new Map<string, number>();
function count(key: string) {
renders.set(key, (renders.get(key) ?? 0) + 1);
}
function WorkspaceRow({ reference }: { reference: WorkspaceRef }) {
const item = useWorkspace(reference);
count(reference.key);
return (
<div>
{item?.name}:{item?.indicator}
</div>
);
}
function Workspaces() {
count("workspaces");
const items = useWorkspaces({ orderBy: "name" });
return (
<div>
{items.map((reference) => (
<WorkspaceRow key={reference.key} reference={reference} />
))}
</div>
);
}
function SelectedWorkspace({ reference }: { reference: WorkspaceRef }) {
const name = useWorkspace(reference, (item) => ({ name: item.name }));
const custom = useWorkspace(reference, undefined, (a, b) => a?.name === b?.name);
count("selected");
return (
<div>
{name?.name}:{custom?.name}
</div>
);
}
function AgentRow({ reference }: { reference: AgentRef }) {
const item = useAgent(reference);
return <div>{item?.title}</div>;
}
function AgentSection({ section }: { section: string }) {
const agents = useAgents({
where: (item) => (item.requiresAttention ? "waiting" : item.status) === section,
});
count(section);
return (
<div>
{agents.map((reference) => (
<AgentRow key={reference.key} reference={reference} />
))}
</div>
);
}
function Agents() {
return (
<div>
<AgentSection section="running" />
<AgentSection section="waiting" />
<AgentSection section="idle" />
</div>
);
}
function seedHost(hostId: string) {
const store = useSessionStore.getState();
store.initializeSession(hostId, null);
store.setWorkspaces(
hostId,
new Map([
["workspace-1", { ...workspace, id: "workspace-1", status: "done", name: "First" }],
["workspace-2", { ...workspace, id: "workspace-2", status: "done", name: "Second" }],
]),
);
store.setProjects(hostId, [
{
projectId: "empty",
projectDisplayName: "Empty project",
projectCustomName: null,
projectRootPath: "/empty",
projectKind: "git",
},
]);
store.setAgents(
hostId,
new Map<string, Agent>([
[
"agent-1",
{
...agent,
serverId: hostId,
id: "agent-1",
workspaceId: "workspace-1",
status: "running",
requiresAttention: false,
attentionReason: null,
turn: { phase: "open", turnId: null, startedAt: null, cancellationRequestId: null },
},
],
[
"agent-2",
{
...agent,
serverId: hostId,
id: "agent-2",
workspaceId: "workspace-2",
status: "running",
requiresAttention: false,
attentionReason: null,
turn: { phase: "open", turnId: null, startedAt: null, cancellationRequestId: null },
},
],
]),
);
}
it("keeps archived agents readable individually while excluding them from collections", () => {
seedHost("host-1");
const source = createPluginClientStateSource("host-1", { hosts: new ConnectedHosts() });
const reference = source.getSnapshot().agents[0]!.ref;
useSessionStore.getState().setAgents(
"host-1",
new Map([
[
agent.id,
{
...agent,
archivedAt: new Date("2026-11-01T00:00:00.000Z"),
},
],
]),
);
expect(source.getSnapshot().agents).toEqual([]);
expect(source.getAgent(reference)?.title).toBe(agent.title);
expect(source.getAgent(agent.id)).toBe(source.getAgent(reference));
});
it("demands directories for connected hosts and releases them with its last reader", () => {
const hostId = "plugin-demand-host";
let online = true;
let demands = 0;
const listeners = new Set<() => void>();
const hosts = createConnectedPluginHosts(() => ({
getHosts: () => [{ serverId: hostId }],
getSnapshot: () => ({ connectionStatus: online ? "online" : "offline" }),
subscribeAll: (listener) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
subscribeHostList: () => () => {},
acquireDirectoryDemand: () => {
demands++;
seedHost(hostId);
return () => {
demands--;
};
},
}));
const source = createPluginClientStateSource(undefined, { hosts });
const first = source.subscribe(() => {});
const second = source.subscribe(() => {});
expect(demands).toBe(1);
expect(source.getSnapshot().workspaces.map((item) => item.hostId)).toEqual([hostId, hostId]);
useSessionStore
.getState()
.setWorkspaces(
hostId,
(previous) =>
new Map([...previous].map(([id, item]) => [id, { ...item, name: "Live update" }])),
);
expect(source.getSnapshot().workspaces.map((item) => item.name)).toEqual([
"Live update",
"Live update",
]);
online = false;
for (const listener of listeners) listener();
expect(demands).toBe(0);
expect(source.getSnapshot().workspaces).toEqual([]);
online = true;
for (const listener of listeners) listener();
expect(demands).toBe(1);
first();
expect(demands).toBe(1);
second();
expect(demands).toBe(0);
expect(listeners.size).toBe(0);
});
function waitForInput() {
useSessionStore.getState().setAgents("host-1", (previous) => {
const current = previous.get("agent-1");
if (!current) throw new Error("Missing test agent");
const next = new Map(previous);
next.set(current.id, {
...current,
status: "idle",
turn: { phase: "idle", cancellationRequestId: null },
requiresAttention: true,
attentionReason: "permission",
});
return next;
});
}
class ConnectedHosts implements PluginConnectedHostsSource {
private ids: readonly string[] = ["host-1"];
private listeners = new Set<() => void>();
getSnapshot = () => this.ids;
subscribe = (listener: () => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
connect(id: string) {
this.ids = [...this.ids, id];
for (const listener of this.listeners) listener();
}
}
function mount(children: React.ReactNode, hosts = new ConnectedHosts()) {
const source = createPluginClientStateSource("host-1", { hosts });
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
act(() =>
root?.render(<PluginClientStateProvider source={source}>{children}</PluginClientStateProvider>),
);
return source;
}
afterEach(() => {
act(() => root?.unmount());
container?.remove();
root = null;
container = null;
useSessionStore.getState().clearSession("host-1");
useSessionStore.getState().clearSession("host-2");
renders.clear();
});
it("shows a restored child in the nested example while its cached parent remains archived", () => {
seedHost("host-1");
const parent = {
...agent,
id: "archived-parent",
title: "Archived parent",
archivedAt: new Date("2026-11-01T00:00:00Z"),
};
const child = {
...agent,
id: "restored-child",
title: "Restored child",
parentAgentId: parent.id,
labels: { "paseo.parent-agent-id": parent.id },
archivedAt: parent.archivedAt,
};
const store = useSessionStore.getState();
store.setProjects("host-1", [
{
projectId: workspace.projectId,
projectDisplayName: "Project",
projectCustomName: null,
projectRootPath: workspace.projectRootPath,
projectKind: "git",
},
]);
store.setAgents(
"host-1",
new Map<string, Agent>([
[parent.id, parent],
[child.id, child],
]),
);
const view = {
theme: toPluginTheme(lightTheme),
layout: { compact: false, platform: "web" as const },
active: { workspace: null, agent: null },
navigation: { openWorkspace() {}, openAgent() {}, openProject() {}, openTerminal() {} },
dismiss() {},
};
const source = mount(<Nested {...view} />);
expect(source.getAgent(parent.id)?.title).toBe(parent.title);
expect(container?.textContent).not.toContain(child.title);
act(() =>
store.setAgents(
"host-1",
new Map<string, Agent>([
[parent.id, parent],
[child.id, { ...child, archivedAt: null }],
]),
),
);
expect(container?.querySelector('[aria-label="Open agent Restored child"]')).not.toBeNull();
expect(container?.querySelector('[aria-label="Open agent Archived parent"]')).toBeNull();
});
describe("sidebar data hooks through the app store and plugin provider", () => {
it("renders only one workspace row when its root agent starts waiting", () => {
seedHost("host-1");
const source = mount(<Workspaces />);
const first = source.getSnapshot().workspaces[0]!;
const second = source.getSnapshot().workspaces[1]!;
const before = source.getSnapshot();
act(waitForInput);
expect(renders.get("workspaces")).toBe(1);
expect(renders.get(first.ref.key)).toBe(2);
expect(renders.get(second.ref.key)).toBe(1);
expect(source.getSnapshot().workspaces[0]?.indicator).toBe("needs_input");
expect(source.getSnapshot().workspaces[1]).toBe(before.workspaces[1]);
});
it("moves the agent and renders only its two affected sections without memo", () => {
seedHost("host-1");
mount(<Agents />);
act(waitForInput);
expect(renders.get("running")).toBe(2);
expect(renders.get("waiting")).toBe(2);
expect(renders.get("idle")).toBe(1);
});
it("uses shallow selected equality and custom equality without a selector", () => {
seedHost("host-1");
const source = createPluginClientStateSource("host-1", { hosts: new ConnectedHosts() });
mount(<SelectedWorkspace reference={source.getSnapshot().workspaces[0]!.ref} />);
act(waitForInput);
expect(renders.get("selected")).toBe(1);
});
it("refreshes translated labels through the subscribed source when language changes", async () => {
seedHost("host-1");
i18n.addResourceBundle("fr", "translation", fr);
const source = mount(<Workspaces />);
const before = source.getSnapshot().workspaces[0]!;
const previousLanguage = i18n.language;
try {
await act(async () => {
await i18n.changeLanguage("fr");
});
const after = source.getSnapshot().workspaces[0]!;
expect(after).not.toBe(before);
expect(after.indicatorLabel).toBe("En cours");
expect(after.ref).toBe(before.ref);
expect(renders.get(before.ref.key)).toBe(2);
expect(renders.get("workspaces")).toBe(1);
} finally {
await act(async () => {
await i18n.changeLanguage(previousLanguage);
});
}
});
it("adds a newly connected host and exposes projects with no workspaces", () => {
seedHost("host-1");
seedHost("host-2");
const hosts = new ConnectedHosts();
const source = mount(<Workspaces />, hosts);
expect(source.getSnapshot().workspaces).toHaveLength(2);
expect(source.getSnapshot().projects[0]?.displayName).toBe("Empty project");
act(() => hosts.connect("host-2"));
expect(source.getSnapshot().workspaces).toHaveLength(4);
expect(source.getSnapshot().projects).toHaveLength(2);
expect(new Set(source.getSnapshot().workspaces.map((item) => item.ref.key)).size).toBe(4);
});
});
it("observes terminals only while a terminal reader is subscribed", () => {
let active = 0;
const source = createPluginClientStateSource(undefined, {
hosts: { getSnapshot: () => [], subscribe: () => () => {} },
terminals: {
getSnapshot: () => [],
subscribe: () => {
active++;
return () => {
active--;
};
},
},
});
const releaseWorkspace = source.subscribe(() => {});
expect(active).toBe(0);
const releaseTerminal = source.subscribe(() => {}, { terminals: true });
const releaseSecond = source.subscribe(() => {}, { terminals: true });
expect(active).toBe(1);
releaseTerminal();
expect(active).toBe(1);
releaseSecond();
expect(active).toBe(0);
releaseWorkspace();
});
it("terminal row reads propagate only the referenced host's observation error", () => {
const error = new Error("Update the host");
const source = createPluginClientStateSource(undefined, {
hosts: { getSnapshot: () => [], subscribe: () => () => {} },
terminals: {
getSnapshot: () => [],
subscribe: () => () => {},
getErrors: () => new Map([["unsupported", error]]),
},
});
const refs = createEntityRefs();
expect(() => source.getTerminal(refs.terminal("unsupported", "retained"))).toThrow(error);
expect(source.getTerminal(refs.terminal("supported", "missing"))).toBeNull();
});

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