Compare commits

...
Author SHA1 Message Date
Mohamed Boudra 4847149428 fix(app): keep a healthy host connection when latency evidence is stale
The adaptive selector counted three consecutive wins by re-reading the
cached probe map on every completed cycle, including the ten-second
active-only ticks. One idle sample of an alternate connection could
reach the switch threshold in about 20 seconds. After a reconnect, the
map also kept the previous socket's RTT, so a recovered connection was
compared using a measurement from a socket that no longer existed.

A streak hit now needs a candidate sampled in this cycle and a heartbeat
RTT from the socket that is connected now. Startup selection and failover
still use the probe map, because they need the last known state.
2026-11-08 14:26:20 +01: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
paseo-bot[bot] aac228e0a8 test(server): separate legacy plugin setup from migration deadline (#8983)
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-07 09:01:32 +00:00
paseo-bot[bot] 0e40022519 Preserve linked resources when pasting prompts (#8972)
* fix(app): preserve linked resources when pasting prompts

Rich clipboard links carried their destination only in HTML, while the composer let textarea default paste insert the plain label. Convert linked fragments to Markdown at ingestion and replace the selected draft range through the existing editor command.

* fix(app): keep pasted links undoable while drafting offline

Use the browser edit command for converted paste so selection replacement participates in Undo and normal input publication. Keep complete Markdown clipboard text when it already carries the link destinations, and limit connection gating to image uploads.

* fix(app): preserve copied Markdown with normalized links

Copy renders normalized URLs in HTML while retaining original Markdown in plain text. Compare parsed destinations using the same Markdown parser so pasting complete responses keeps tables and formatting intact.

* fix(app): retain external linked tables when pasting prompts

Recovering missing link destinations must retain table rows and columns. Use the existing table converter and keep cell delimiters and line breaks inside their cells.

* test(app): name rich clipboard paste arguments

* fix(app): preserve pasted links around rowless tables

The table converter requires a first row. Convert rowless table content separately so empty markup cannot abort recovery of surrounding destinations.

* test(app): wait for workspace restore before undo teardown

The directory appears before git finishes recreating the worktree. Wait for the host to publish its active workspace before cleanup removes the source repository.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-07 07:04:10 +00:00
paseo-bot[bot]andAbdulhakeem Mustapha 751dc82e6c Keep browser evaluation working on strict-policy pages without shadowing page globals (#8973)
* fix(desktop): run browser_evaluate on pages whose CSP omits 'unsafe-eval'

* test(desktop): browser_evaluate on pages that allow and forbid eval

* test(desktop): e2e opens a page under a CSP without 'unsafe-eval' and evaluates on it

* fix(desktop): preserve page globals in browser evaluation

Define the supplied function outside every wrapper binding so strict-policy evaluation keeps the original page scope.

Co-authored-by: Abdulhakeem Mustapha <abdulhakeemmustapha@gmail.com>

---------

Co-authored-by: Abdulhakeem Mustapha <abdulhakeemmustapha@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-07 06:03:12 +00:00
paseo-bot[bot] daf26892d3 Keep the provider's actual model after unavailable selections (#8957)
* fix(server): keep the model selected by the provider

A live model setter can retain or resolve a different model than the requested ID. Read its effective selection before persisting manager state so an ignored selection cannot overwrite the actual model. Preserve explicit preference resets.

* fix(server): preserve completed model selections

Return the effective selection from the provider mutation so a separate status request cannot reject an already completed switch or leave configured and runtime models inconsistent.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-07 04:18:27 +00:00
paseo-bot[bot] c5cf6cbb4c test(server): await shared fetch observation readiness (#8975)
Snapshot refresh completion does not mean both workspaces have joined the repository fetch target. Wait for observation setup before releasing the first fetch, and retain delayed checkout resolution to exercise that ordering.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-07 03:01:45 +00:00
Abdulhakeem Adetunji Mustapha 78a089a14e fix(desktop): browser_logs reports the HTTP status of each request (#8956)
* fix(desktop): browser_logs reports the HTTP status of each request

* test(desktop): browser_logs reports the status the browser recorded

* test(desktop): e2e checks browser_logs reports a 404 and a 200

* docs(browser): browser_logs also reports the HTTP status
2026-11-07 01:00:56 +00:00
paseo-bot[bot] ec3ae09926 Keep the native model when importing sessions (#8948)
* fix(server): preserve the selected model when importing sessions

Import preparation resolved a new-session catalog default and sent it as an explicit resume override. Keep import selections with the resumed provider and persist its reported model for subsequent reloads.

* fix(codex): allow the first reply after importing a session

Resolve collaboration settings again when default model and effort are applied so imported sessions send a complete first-turn request.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-07 00:08:25 +00:00
paseo-bot[bot]andSebastian Arena 9f68af0274 Keep uploaded terminal file paths separate (#8946)
* M1: terminal input-sink bridge, upload button in tab-bar slot and key bar, picked-file routing, space-separated pasted paths

* M2: terminal dictation: mic in tab-bar slot and key bar, recording pill and overlay, per-terminal recording lifetime incl. pending mic acquisition, Cmd+D on macOS by keyboard focus, i18n

* M2: review fixes: retry attempt guard, cancellable native pending start, measured terminal slot width, compact-web Cmd+D for terminal dictation

* fix(terminal): separate uploaded file paths when pasting

Keep the remote file-drop regression and its test coverage from the contributor change, without introducing new terminal controls. Each uploaded path needs a trailing separator because adjacent quoted paths are one shell word.

Co-authored-by: Sebastian Arena <sebastian@walkofcode.io>

---------

Co-authored-by: Sebastian Arena <sebastian@walkofcode.io>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-06 18:57:29 +00:00
paseo-bot[bot] 79f2db8d56 test(server): retry locked provider fixture cleanup on Windows (#8942)
Availability probes already await process completion. Allow bounded filesystem cleanup retries for transient executable locks, matching the neighboring Windows launch tests.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-06 18:30:36 +00:00
1efcb31a6c Give agents their own workspace identity through create and resume (#8939)
* fix(server): give agents their workspace id as PASEO_WORKSPACE_ID

Agents got PASEO_AGENT_ID and PASEO_AGENT_CWD but not their workspace id, so an
agent couldn't rename or archive its own workspace, or name it to create_agent,
when several workspaces share its cwd. The launch env now sets
PASEO_WORKSPACE_ID whenever the agent has a workspace, including on resume after
a daemon restart, where the id now comes from the stored record. OpenCode treats
it as a per-session key, so it doesn't force a dedicated server.

Refs #5098

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

* fix(server): keep the workspace when resuming an agent by handle

resume_agent_request matched the stored agent but dropped its workspace id, so
the resumed agent had no PASEO_WORKSPACE_ID and no workspace. Pass the matched
record's workspace id into the resume.

resumeAgentFromPersistence now resolves the workspace once and gives it to both
the launch env and the registered agent, so a caller that omits it no longer
erases the stored value on the next snapshot.

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

* test(server): exercise current daemon creation and permission contracts

The workspace resume regression shares this suite with creation checks that still waited for legacy status frames and permission listeners without an owned subscription. Verify the returned error snapshot and acquire event demand so the complete suite can validate workspace identity changes.

Co-authored-by: Darrell <DT@Terastar.biz>

---------

Co-authored-by: Darrell <DT@Terastar.biz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-06 18:06:59 +00:00
paseo-bot[bot]andluke ea30d2616a Restore Mermaid source toggles and bound inline previews (#8932)
* fix(app): keep Mermaid controls usable in inline previews

* test(app): verify Mermaid controls through user journeys

Keep source-toggle mechanics in diagram helpers and compare preview geometry with its screen and controls instead of requiring exact pixel heights.

Co-authored-by: luke <luopeihuan@gmail.com>

---------

Co-authored-by: luke <luopeihuan@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-06 16:58:28 +00:00
Mohamed Boudra f21699a37a chore(release): reconcile 0.15.3 metadata (#8943) 2026-11-06 23:10:33 +07:00
Mohamed Boudra a9bce9a18d Keep the chat still when an agent's turn ends (#8944)
* Land a turn's reply, completed footer, and tool summary in one frame

The stream handler closed turn liveness as soon as turn_completed arrived,
while the final reply waited in the frame-coalesced reducer queue. For one
commit the turn was finished with no reply: the footer vanished and the
timeline jumped twice, and tool turns folded into their summary later.

The viewed timeline owner now takes stream events whole: a turn boundary
commits the queued content before liveness changes. Running and completed
footers share one fixed content height so the phase switch moves nothing.

* Document the turn-boundary commit and stop racing the re-pin in the turn-open test

The turn-open test scrolled the received message into view with a
programmatic scroll, which leaves the chat following its end. Pressing
the turn summary swaps its icon, and that DOM change re-pins the chat to
the end between pointerdown and pointerup. The summary had moved 14 px,
which left the pointer 2 px inside the button; with the completed footer
now 2 px taller, it moved 16 px and the click landed on the reply below.
The message's fade and Show more are already on screen at the end, so
the test no longer scrolls.
2026-11-06 23:10:29 +07:00
paseo-bot[bot] 73448d483e test(app): scope command-center stability checks to the panel (#8927)
Document-wide layout shifts could fail a stable file-search panel. Count only shifts attributed to the panel or its descendants, and exercise both unrelated and in-panel movement in the existing browser journey.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-06 15:55:09 +00:00
Mohamed BoudraandClaude 161076a853 Show loading and errors in the slash command menu, and stop defaulting to /exit (#8905)
* feat(app): show loading and errors in the composer command menu

Rows are single lines with a kind icon, the matched part of the name, the
argument hint, the description and a source tag. The detail card is gone.

The menu used to stay hidden while commands loaded and then show
"Error: ..." over a list whose options Enter could still pick, so a failed
load plus Enter archived the agent through a hidden /exit. Loading and
error are now status rows with no options; the error row names the reason
and offers Retry. Loading appears after 300ms so a warm cache paints the
results first, as before.

An empty query used to select /exit. Provider commands now come first, so
the bottom row the input selects is a provider command.

useAutocomplete derives the selection during render. When results replace
the loading row, the selection is valid in the same render, so the popover
stays mounted at its anchor instead of remounting.

Claude Code's `__`-prefixed commands (/__remote-workflow) only run in its
own server-launched sessions, so the Claude provider no longer lists them.

* fix(app): never default the command menu to a Paseo action

An arrow-key choice was tied to the query it was made for, so returning to
that query brought it back: arrow to /exit on `/`, type `com`, delete it, and
Enter archived the agent. A choice now clears whenever the query changes.

An empty query still selected /exit when the agent had no provider or plugin
commands, because Paseo's actions were the only entries. With nothing else to
list, an empty query now lists nothing and waits for a search; typing `/ex`
still finds /exit.

* fix(app): sort empty-query commands without toSorted

Hermes has no Array.prototype.toSorted, so typing `/` threw "undefined is
not a function" on iOS and Android and the app showed its error screen.
Browsers and Node have it, which is why web and unit tests passed.

* test(app): cover the stale command choice in Playwright and wait out the loading row on devices

The renderHook test mounted React under JSDOM, which docs/testing.md forbids.
The Playwright test drives the same sequence in a real browser and fails
without the reset in useAutocomplete: Enter chose the stale /exit.

The composer-keyboard harnesses treated a visible popover as a ready list.
The popover now shows a loading row first, and a cold Claude command list
takes several seconds, so both harnesses wait for the rows.

* fix(app): size the command menu loading spinner to the row icons

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-06 22:15:10 +07:00
paseo-bot[bot]anda-sh 14845d3ff3 Keep ACP mode selections valid after config updates (#8912)
* fix(acp): preserve session mode identity across config updates

* test(acp): verify mode identity through public sessions

Exercise initialization, queued notifications, mode selection, and rejection over stdio instead of replacing private connection state.

Co-authored-by: a-sh <846704+a-sh@users.noreply.github.com>

---------

Co-authored-by: a-sh <846704+a-sh@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-06 15:10:43 +00:00
Mohamed Boudra 616f5e155c fix(app): show branch loading and fetch errors (#8938)
* fix(app): distinguish branch loading from empty results

Preserve branch query pending and error states through the switcher so first open does not report missing branches before the response. Show request failures with a retry action.

* fix(app): keep branch retry progress and keyboard focus

Verify failed cached refresh and keyboard retry through the real browser and daemon. Keep focus in the footer while Retry is disabled so repeated Enter cannot select a cached branch.

* fix(app): preserve picker interaction when retrying branch loads

Use the existing close-and-reopen retry behavior with clear feedback rather than introducing an action that competes with native list keyboard ownership.
2026-11-06 21:35:20 +07:00
Mohamed Boudra 471db4bdb5 Keep workspace tabs predictable and clear attention on deliberate focus (#8935)
* fix(app): preserve workspace tabs and acknowledge deliberate agent focus

Workspace navigation no longer chooses an attention agent. Preserve repeated focus actions so an already selected chat can be acknowledged, and stop consuming attention on departure.

* fix(app): retain deliberate focus until fresh agent data arrives

Connection readiness permits a pending focus acknowledgement without creating a new focus action. Keep that intent across client changes and wait for the current timeline synchronization generation before consuming it.

* fix(app): preserve reconnect feedback during attention acknowledgement
2026-11-06 20:55:16 +07:00
Mohamed Boudra 68fc16b855 fix(terminal): run terminal workers without process identity queries (#8818)
* fix(terminal): run terminals when process identity cannot be read

On some Windows machines Windows PowerShell cannot run Get-CimInstance, or
cannot be started at all. The terminal worker refused to start without its own
birth time and command line, so those machines had no terminals.

Process identity only authorizes signals. The worker now starts anyway and
records its identity as unavailable, and programs without a birth time. Cleanup
never signals what it cannot identify. It asks such a worker to shut down over
the socket the secret authenticates, and reports survivors as unconfirmed.

So records of crashed workers do not block launches, the process table falls
back to process.kill(pid, 0) when the query fails. When a live pid cannot be
identified, a refused connection on the worker's socket marks it as another
process, because a worker listens until it exits.

* fix(terminal): ask unidentified workers to shut down on every cleanup

Retries of an unconfirmed cleanup and startup cleanup run without a grace
period, which skipped the shutdown request. For a worker whose identity is
unavailable that request is the only way to stop it, so it is now always sent.

A gone program recorded without a birth time is no longer walked through the
process table: the table was unreadable when it started, and the walk only kept
the cleanup unconfirmed.

Also test the socket probe on every platform, restore a test of the daemon
starting when the worker cannot, and keep one pid liveness helper.

* test(terminal): fail the worker launch the same way on every platform

Windows opens a directory sitting at the launcher's log path, so the
worker started there and the #8810 test never saw a failed launch. An
invalid protocol override makes the worker exit before it writes its
record on every platform.

* fix(terminal): identify workers through their recorded sockets

* fix(terminal): authenticate legacy sockets before forced cleanup

* test(terminal): exercise closing records without process queries

* test(terminal): identify stale lifecycle records by their sockets

* refactor(terminal): drop the unreleased unavailable-identity record shape

No release wrote identity: { unavailable }; it existed only between
commits of this change. Records keep reading the identity workers up to
0.15.1 wrote, and nothing consults it.
2026-11-06 20:20:18 +07:00
Mohamed Boudra ae157dbfb6 Even out chat footer spacing and show the Changes base only on worktrees (#8934)
* fix(app): space the turn footer like the rest of the timeline

The footer sat 13px under the last line of text while the text sat about
21px under a tool or thinking row, so the footer looked glued to the
message. The message drops its bottom padding when a footer follows, so the
footer's top margin alone sets that gap; 20 makes the centre-to-centre
distance match the tool-to-text gap.

* fix(app): show the Changes base only on Paseo worktrees

The header rendered "branch -> base" whenever checkout status carried a
baseRef, and the daemon sends one for every git checkout, so plain
checkouts showed "-> main" with no way to change it. Only Paseo worktrees
store a base, so only they show it. The arrow is extra muted so it reads as
a separator, and the base no longer shrinks, so a long branch name truncates
instead of the base.

* fix(app): let a long Changes base truncate instead of overflowing
2026-11-06 20:20:11 +07:00
geoqiao dc51a3b6a9 fix(app): preserve plugin-rendered replies when collapsing turns (#8811)
* fix(app): preserve plugin-rendered replies when collapsing turns

* test(app): exercise plugin reply folding in the browser
2026-11-06 13:12:44 +00:00
77798b2e46 Show and stop agent background work (#6220)
* feat(server): add an in-memory store for agent background work

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

* feat(claude): report background tasks as background work

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

* feat(server): track agent background work in the agent manager

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

* feat(protocol): add agent background work messages

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

* feat(server): serve and push agent background work

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

* feat(client): expose agent background work on agent handles

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

* docs(sdk): document agent background work

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

* test(claude): cover background shells in background work end to end

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

* fix(client): resync background work subscriptions on every snapshot

subscribe() now lists the agent's background work each time the event
observation is established, so the listener starts with the current list
and catches up after a reconnect. A list answered after a newer update
is dropped.

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

* fix(server): answer background work lists without loading dormant agents

Background work lives only in memory, so a stored agent that is not
loaded has none. The list handler now answers [] from storage instead of
resuming the agent, and still rejects unknown, internal, and archived
agents. A new test pins that old apps never receive
agent.background_work.update.

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

* fix(server): read Claude background tasks defensively

The user's claude binary can differ from the SDK typings, so a task's
description is read as unknown and an empty or missing one becomes null.
Drop the monitor kind: Claude Monitor watches report as local_bash, so
kind is "shell" or "other" today. The store now copies only the wire
fields of each input.

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

* test(server): prove internal background work was emitted before checking globals

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

* docs(sdk): describe background work resync and feature gating

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

* test(claude): cover background work reported by a retired query

The daemon stops reading a Claude query it no longer owns, so work that a
retired process still had queued cannot reach the background work list.

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

* fix(client): let background work subscribers handle a failed list read

When the list read that follows each subscription snapshot failed, the
error was only logged and the caller got neither the list nor a failure.
subscribe() now takes an optional onError that receives it; without one
the error is still logged. The read is not retried, since the next
reconnect reads the list again.

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

* feat(agents): show background work as active

Keep background activity visible after a foreground turn ends while preserving independent send and interrupt behavior.

* fix(agents): protect background work subscriptions

Keep internal work out of public updates and restore observation after a subscription failure.

* feat(agents): show Codex and OpenCode background work

Keep provider-owned shell activity visible after a turn ends while leaving foreground turn controls independent.

* feat(agents): stop individual background work

Wait for provider confirmation so stopping one background item leaves sibling work and foreground turn controls independent.

* refactor(agents): name background stop parameters

* test(website): isolate browser tests from live metadata

* test(agents): clarify background stop journeys

* fix(sdk): report background work subscription failures

Report setup failures so subscribers can replace an observation that the host released.

* fix(app): restore background work after reconnect

Wait for the host handshake before selecting the retained client so an offline pane starts observing when the connection returns.

* fix(agents): retain background shells across rewind

Preserve known live shell ownership when conversation history is rewound, and revoke it when the provider runtime exits.

* fix(website): launch test seeder across platforms

Invoke Wrangler through Node so local website test startup does not depend on an executable npm command shim.

* feat(agents): clarify background task details and tracks

Keep launch descriptions and commands distinct so background work remains identifiable, and allow composer tracks to scroll when space is limited.

* fix(agents): preserve active background work during archive

Keep background-running children out of bulk cleanup and warn before an individual archive. Preserve first taps on composer tracks while the keyboard is open.

* fix(codex): retain background terminals across rewind

Keep live terminal inventories attached to their originating threads so a fork does not hide or misroute their stop actions.

* fix(codex): scope background terminal identities to threads

Prevent commands on separate threads with the same provider process ID from sharing a stop target.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-11-06 19:49:49 +07:00
paseo-bot[bot] c8bd4717c9 test(server): wait for Git ref observation before injecting events (#8924)
Zero in-flight fetches also describes observation that has not started. Wait for the first fetch to start and settle, and exercise delayed watcher setup so missing callbacks cannot silently skip the test events.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-06 12:25:42 +00:00
Mohamed Boudra 6c1b48d296 Archive a workspace from its sidebar row, with undo (#8904)
* feat(app): archive a workspace from its sidebar row and undo it

Hovering a workspace row on desktop shows an archive button next to the
row menu. Archiving shows a toast with Undo and a Cmd/Ctrl+Z shortcut so a
mis-click costs one keystroke instead of a trip through History.

The daemon serializes archive and undo per workspace across every
connection. An undo that lands while an archive is still tearing down
aborts it at the next checkpoint and restores only the agents that archive
took down; deleting the worktree directory is the point of no return, after
which undo recreates the worktree.

* fix(server,app): close archive and undo races found in review

- An archive request now claims the workspace's archive queue before
  looking up the workspace, so an undo sent right behind it cancels it
  instead of running first and losing to the archive.
- A repeat archive of an already archived workspace no longer wipes the
  agents the earlier archive saved for undo.
- Undo no longer brings back an agent that was archived before the
  workspace archive and only loaded to read its history.
- Each local archive or undo request owns its own intent token, so a
  late response to an earlier request cannot release a newer request's
  hold on the row. An archive canceled by another client's undo puts the
  row back.
- The undo failure toast shows translated copy; the cause goes to the log.

* fix(app): keep a workspace's layout when another client restores it during the undo window

The undo window purged tabs and layout whenever it closed after a
finished archive. If another client restored the workspace before this
client's toast closed, the restored workspace lost its saved
arrangement. The archive now commits only if the workspace is still gone
when the window closes.

* fix(app): show the archived toast's Undo as a button

* fix(app): keep a workspace's agent tabs when it is opened while its undo runs

* fix(app): keep a row whose undo is running when a directory snapshot omits it
2026-11-06 16:16:11 +07:00
paseo-bot[bot]andluke 1a626230d9 Keep clients current when parent archive detaches an unloaded child (#8903)
* fix(server): retain archived parent provenance on detach

* fix(server): publish stored child detach updates on parent archive

Keep subscribed clients current when an unloaded child survives its parent archive. Narrow the contributor change to the missing update; archived-parent provenance remains a separate feature.

Co-authored-by: luke <luopeihuan@gmail.com>

* test(server): clean up the detach fixture static directory

Keep the test daemon static directory inside the fixture root so cleanup removes all test-owned directories.

* fix(server): retain runtime settings in stored agent updates

Parent archive publishes stored survivors through the manager. Preserve their saved runtime model and thinking settings so the update matches stored fetches and subscription filters.

---------

Co-authored-by: luke <luopeihuan@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-06 09:16:01 +00:00
Mohamed Boudra 4b285a113d Space the touch composer toolbar by what it paints (#8909)
* feat(app): tighten the touch composer toolbar onto the text column

On touch, the toolbar's icon-only glyphs were 20pt muted outlines at a 1.5px
stroke in loosely spaced slots, and the row sat on a flat -6 margin, so the
attach + and the trailing control floated inside the text column. Touch now
places 44pt slots edge to edge, draws icon-only glyphs at 24pt with a 2px
foreground stroke, keeps label-paired glyphs at 20pt, and runs the context
ring at 26pt with a 3px stroke. The row overhangs into the composer padding
by each end control's ink inset (composerToolbarOverhang), so the + ink and
the trailing glyph or send/stop disc sit on the text column's edges.
Pointer sizing is unchanged.

The folded model control now drops its thinking label before the model
label truncates, and the model label truncates on a word boundary
("Ten second…") instead of leaving a space before the ellipsis. Both are
decided from measured widths. The web reports layout widths rounded to whole
pixels, so a tie counts as not fitting.

* fix(app): measure replacement model labels instead of hiding them

The folded model label and TailEllipsisText kept their measuring views mounted
when the label text changed, while discarding the cached widths. A retained
measuring node whose size does not change emits no new layout event on web or
native, so after switching to a model whose label (or a shortened prefix of it)
has the same width, the widths stayed unknown and the label stayed transparent.
Keying each measurer by what it measures remounts it, so every width is
measured again.

Also move the label width sweep into a named helper so the test body states
the expected label order.

* feat(app): space the touch composer toolbar by what it paints

- Lay each control out at its painted width, 20pt apart, with a disc-sized circle that is both its frame and its fully rounded pressed highlight. Pickers are 36pt pills.
- Match the header icons: 20pt glyphs at 1.5px, an 18pt context ring at 2px.
- Always end the row in a disc (voice mode, send or stop) so it does not shift, nested into the composer's corner, with a concentric 26pt corner radius on touch.
- Stop the picker row's clip from flattening the picker highlight's left edge.
- Budget the density ladder on laid-out widths.

* fix(app): disable the stand-in send disc and keep form model fields in their box

- An empty new chat's stand-in Send is disabled until there is content, as in fullscreen.
- A custom model selector trigger, such as the schedule form's field, drops the toolbar slot's margins.
2026-11-06 15:56:09 +07:00
Mohamed BoudraandDavid Greenspan 4281372bee fix(app): keep the chat readable with the keyboard open on iOS 26+ (#8918)
iOS 26 adds a scroll edge effect, a blur at each edge of every scroll view,
and places it in the scroll view's own coordinates without applying
transforms. The keyboard lifts the chat with a translateY, so on iPhones the
blur lands over the readable conversation and stays until the next scroll.
The app draws its own header fade, so the app's ScrollView component class,
which backs every React Native ScrollView, hides all four edge effects.

Co-authored-by: David Greenspan <155814+dgreensp@users.noreply.github.com>
2026-11-06 15:14:23 +07:00
Mohamed Boudra e79e826e23 Stop re-rendering every chat message on each agent update (#8916)
* fix(app): keep unchanged agent fields when an agent update is accepted

* fix(app): share unchanged agent fields at the store write, not in the update policy
2026-11-06 14:48:10 +07:00
Mohamed Boudra 4928500e1f fix(app): keep chat rows their size while their tab is hidden on Android (#8911) 2026-11-06 14:08:09 +07:00
Mohamed Boudra 1e2ab3d596 Keep the first prompt on screen when a new agent starts on Android (#8898)
* refactor(app): stop resetting the chat timeline when its agent id changes

A mounted timeline only ever shows one agent: every host keys its panel by tab id,
and a tab keeps its id across a target change only when a draft becomes the agent
it created. The per-agent resets inside the timeline therefore never had a different
agent to reset for. They only fired at that create handoff, where they hid and
re-placed a timeline that was already on screen.

Delete them: LegendList's dataKey and the placed agent id, the web viewport key,
the bottom anchor's resetForAgent, the history window's agent id, and the
expansion and path-opener resets in the stream view.

* fix(app): keep one chat timeline across the draft-to-agent handoff

A draft tab and the agent its first prompt creates rendered two different panels:
DraftPanel mounted its own dock and list, and the retarget swapped it for AgentPanel's.
On Android, with the LegendList timeline, the swap blanked the first turn and re-placed it.

Render both from the agent panel. A draft supplies its timeline from the pending create
in the create-flow store (composer/draft/timeline.ts) and its composer in the composer
slot. The retarget then re-renders the same stream view with the agent's timeline, so the
list instance and its measured rows stay mounted. Agent gating is unchanged.

Float the compact header over draft tabs as it already floats over agent tabs. The
header sat in flow above a draft and over an agent, so the list viewport grew 92 dp at
the retarget and the turn moved.

The handoff E2E now fails if the first row disappears, hides, or shifts down while the
draft becomes the agent.

* fix(app): load subagents and tasks only for an agent, not a draft tab
2026-11-06 13:38:10 +07:00
paseo-bot[bot] 2716bd96de test(terminal): observe original process completion after exit (#8892)
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-06 06:27:04 +00:00
Mohamed Boudra 5abfdf7107 Record workspace creation parentage (#8890)
* feat(workspaces): record workspace creation parentage

Retain the caller workspace as informational ancestry for future sidebar nesting, independently of workspace placement and lifecycle.

* fix(app): preserve workspace parentage in replica cache
2026-11-06 10:55:56 +07:00
Mohamed Boudra 6355b339c8 fix(app): paint a smaller send and stop disc inside the touch hit frame (#8891)
Under touch sizing the composer's send and stop buttons filled their whole
44pt hit frame with color, so they outweighed every unpainted control in the
toolbar. The trigger keeps the 44pt frame and now paints a 36pt disc
(TOOLBAR_DISC_SIZE) centered inside it. Pointer sizing paints 28 inside 28, so
desktop is unchanged. The voice button's recording fill follows the same rule.
2026-11-06 10:28:42 +07:00
Mohamed Boudra 8b9c42c266 Keep every sidebar footer action reachable in a narrow sidebar (#8885)
* fix(app): move sidebar footer actions that don't fit behind a caret

Touch sizing makes each footer icon a 44pt target, so five of them plus
the row's padding need 236pt. A docked sidebar can be 200pt and a fold
column about as narrow, so Settings was cut off at the sidebar's edge.

The row now measures itself and keeps as many full-size buttons as fit;
the rest move behind a caret that opens a menu-engine popover. Help and
Usage leave first, Settings and Add project last, and Hosts and Help
open as pages of the overflow menu rather than second popovers.

* fix(app): keep the sidebar footer empty until it has measured

Laying the footer out before its first onLayout put all five buttons in
the row, so a narrow sidebar painted Settings clipped for a few frames
before the caret replaced it. The unmeasured row now places nothing and
holds its height, so the first painted row is already the measured one.

* fix(app): keep the footer overflow menu on a narrow, short screen

The overflow menu asked for 240pt, and edge clamping can move a surface
but not shrink it, so on a 220pt screen its right edge and submenu
arrows hung off the display. Anchored surfaces now cap their width to
the display inside the same edge padding that clamping uses.

The Hosts page lists every host, so the overflow menu is scrollable:
on a short screen the last hosts and Add host were below the fold with
no way to reach them.
2026-11-06 10:28:38 +07:00
Okan YILDIRIM a68dc55490 fix(app): refresh visible usage summaries periodically (#7806) 2026-11-06 03:03:57 +00:00
Mohamed BoudraandTianyu Yang 3ed57e9dd7 Change a worktree workspace base from Changes (#8642)
Change a worktree workspace's base from the Changes pane. The choice persists and diffs, counts, commits and Git actions follow it.

Supersedes #6872.

Co-authored-by: Tianyu Yang <yangtianyu.cl@bytedance.com>
2026-11-05 22:30:14 +07:00
Mohamed BoudraandElsa Granger e9f897a66a fix(app): rest the iPhone composer at the screen's bottom clearance (#8823)
* fix(app): rest the iPhone composer at the screen's bottom clearance

The composer sat 50pt above the bottom edge on every iPhone: the dock
padded the rectangular home-indicator safe area (34pt) and the composer
stacked another 16pt under it. On iPhone Duo that safe area is also
taller than needed; iOS 27.1's bottom-bar layout guide, which follows
the screen's corners, rests 26pt from the edge.

The native window-layout probe now publishes that clearance, and
WindowLayoutProvider overrides the safe-area context's bottom with it,
so every useSafeAreaInsets() consumer reads one bottom value and null
falls back to the system inset. The docked composer applies
max(bottom, 16) once and keeps 16pt above the keyboard through the
translate view's transform; the gap is not animated as padding because
layout updates through the keyboard transition are expensive on Android
Fabric.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* fix(app): keep the Import session gap under the wide new workspace composer

The composer's own bottom spacing used to separate it from the Import
session pill on the wide form. With that spacing moved to the dock, the
centered slot padded below the pill instead, so the pill touched the
composer. The pill now owns its gap above.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

---------

Co-authored-by: Elsa Granger <zeyugao@outlook.com>
2026-11-05 21:33:17 +07:00
Mohamed Boudra b0a8a5db6d Stop the double reconnect when returning to the app (#8855)
* fix(app): keep the selected connection when it reconnects during a probe cycle

When the app returns from the background with more than one saved
connection and the selected socket was closed meanwhile, the probe cycle
marks the selected connection unavailable at probe start, while its client
is still reconnecting. The alternative probe lands later, after the
selected client has already reconnected, and finalizing the cycle switched
to the alternative using that stale verdict. That disposed the live client
(rejecting the in-flight timeline catch-up) and reconnected a second time.

The cycle now reconciles the selected connection's verdict with its
client's current state before deciding to fail over. A client that is
still down at that point still fails over as before.

* test(app): cover reconnect with and without a heartbeat RTT
2026-11-05 21:30:09 +07:00
Mohamed Boudra 412550c36f Keep active transfers connected when the app returns to foreground (#8651)
* fix(client): keep active transfers alive during foreground checks

Foreground used an independent three-second probe that closed live connections while a pong waited behind bulk data. Share the heartbeat clock and refresh its outstanding deadline on validated inbound frames, including incomplete chunk assemblies.

* test(client): deliver bulk response before the queued pong

* fix(client): preserve latency deadlines when sharing heartbeat pings

* fix(client): keep heartbeat cadence during active downloads
2026-11-05 20:57:13 +07:00
paseo-ai[bot] e6064bb374 fix: update lockfile signatures and Nix hash [skip ci] 2026-11-05 11:58:31 +00:00
Mohamed Boudra 59be8975a4 Dismiss a failed agent's red dot by reading it (#8831)
* fix(sidebar): clear the failed status once the error is read

Reading an errored agent already cleared its error attention, but the
state bucket also mapped lifecycle status "error" to failed. Lifecycle
only leaves "error" when the next turn starts, so the red dot and the
Failed group stuck until the agent was prompted again. The failed bucket
now follows unread error attention, the same way the green dot follows
unread finished attention; lifecycle status stays "error".

Because failed now depends on attention, a failure must replace unread
finished attention, otherwise a reload that fails on an unread agent
would show green.

* fix(sidebar): keep failed subagents red and let read failures be marked unread

The failed bucket now follows unread error attention, which broke three
places that relied on lifecycle "error" alone:

- Subagent track rows and provider-subagent tabs have no read state of
  their own, so failed children lost their red mark. They now map the
  child's lifecycle directly.
- A read errored workspace became "done" and offered Mark as unread, but
  the daemon only accepted idle or closed agents. Marking it unread now
  restores error attention.
- A reload that failed again while the lifecycle was already "error"
  raised no new attention. A failed reload now raises error attention
  directly instead of relying on a lifecycle transition.
2026-11-05 18:49:12 +07:00
paseo-bot[bot] cdd7800fed test(app): wait for workspace hover cards to settle (#8846)
Keyboard focus can briefly mount the next card while the previous card closes. Poll uniqueness and visibility together so the assertion tolerates that transition but still rejects persistent duplicates.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-05 09:46:14 +00:00
Mohamed Boudra 902d43da7c chore(release): reconcile v0.15.2 into main (#8845)
* fix(app): hold the loading screen until first-run setup is decided

A new user saw the empty home, the sidebar and a logo jump before setup
popped in. Startup now keeps the splash while setup may still open over
the empty home, for at most 3 s and only while the setup host is
reachable. Remembered-workspace restore does not wait. Web dialogs now
fade in as they already fade out.

(cherry picked from commit a0a7620b62)
(cherry picked from commit 72d7c0bf26)

* fix(app): fade the dialog card in and land the dim at once

(cherry picked from commit 6ef05705a0)
(cherry picked from commit 71c28b431c)

* fix(app): keep the loading screen up while startup redirects

(cherry picked from commit 0b28d4cbff)
(cherry picked from commit 13cc485e6f)

* test(app): cover a setup host that drops while startup waits

(cherry picked from commit 2a906ef83e)
(cherry picked from commit 7b6e21207a)

* fix(app): type the web-only dialog enter animation style

The CSS animation props are not part of ViewStyle. CI typechecks without the
generated expo-env.d.ts, which declares them, so the cast is required there.

(cherry picked from commit b887d979d2)

* fix(app): keep the setup window mounted when the theme changes

The shell's appearance boundary remounts its dialog hosts whenever a theme
color changes, so picking a theme in setup, or a system light/dark switch,
replaced the open setup window and replayed its fade. Setup draws colors
through CSS variables and themed props, which update in place, and changes
no font size, so it now sits outside that remount.

The dialog enter fade is skipped with reduced motion, and its style is typed
for CI, which typechecks without the generated expo-env.d.ts.

(cherry picked from commit b24c6eacf8)
(cherry picked from commit cb0e9907f5)

* docs(changelog): prepare 0.15.2

(cherry picked from commit 5af1a8ff66)

* chore(release): cut 0.15.2

(cherry picked from commit e896c05b09)
2026-11-05 16:40:53 +07:00
92c8c55f31 Fix registry plugin installs through npm mirrors (#8756)
* fix(plugins): install registry npm pins through the configured registry

Registry npm pins carry an npmjs.org tarball URL. Passing that URL to npm made it rewrite the host onto the configured registry and drop any path, so mirrors such as https://mirrors.tencent.com/npm/ returned 404. Install the pinned version instead and identify the reviewed artifact by version and integrity, since a mirror records its own tarball URL.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(plugins): address review on npm artifact comparison

Take an object parameter in isSameNpmArtifact, and clean up the mirror fixture when test setup fails.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(plugins): verify mirror artifacts with a warm npm cache

A shared npm cache lets the mirror regression reuse tarballs from earlier
fixture installations, so successful installs made no tarball requests.
Check the installed artifact's mirror URL, version and integrity instead
of requiring network transfers.

Co-authored-by: finofliu <finofliu@tencent.com>

---------

Co-authored-by: finofliu <finofliu@tencent.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-05 08:59:05 +00:00
Mohamed Boudra b920243c74 docs(release): dispatch the instant-admit flip only after the release is published (#8816)
A draft release has no tag, so dispatching desktop-rollout.yml before finalize-rollout publishes it fails with "release not found". Seen on v0.15.1.
2026-11-05 11:00:04 +07:00
paseo-ai[bot] 9667ec5576 fix: update lockfile signatures and Nix hash [skip ci] 2026-11-05 03:35:49 +00:00
Mohamed Boudra 72955cef60 chore(release): reconcile 0.15.1 into main (#8814)
Brings the shipped 0.15.1 changelog entries, version, and F-Droid changelog files from release/v0.15.1 into main.
2026-11-05 10:26:47 +07:00
Mohamed Boudra 8dfdb479ab fix(terminal): start the daemon when the terminal worker cannot start (#8810)
On 0.15.0 the daemon launches the detached terminal worker while it is
being created, and the worker refuses to start unless it can read its own
birth time and command line (Windows PowerShell 5.1 `Get-CimInstance`,
`ps` elsewhere). Any failure of that query used to escape
startWorkerTerminalManager and take the whole daemon down with
"Daemon failed to start (exit 1)", as two Windows machines hit after the
update: one whose PowerShell 5.1 cannot load CimCmdlets, one where
powershell.exe cannot be spawned at all. 0.14.0 never ran that query at
startup.

The launch error is now logged and the daemon starts without a current
worker. The pool already launches one lazily on the first terminal
creation, so that request reports the same error while the machine still
cannot run the query, and terminals come back once it can. The identity
requirement itself is unchanged: a worker without a verified identity is
never written or adopted.
2026-11-05 08:59:04 +07:00
Mohamed Boudra 5d1a2c10f8 test(app): wheel the thinking group into view before opening it (#8791)
The "reasoning follows Expand thinking (false)" spec clicked a tool-call
group above the viewport. Playwright's scroll-into-view is programmatic,
and the web timeline only leaves sticky-bottom on user input (wheel, key,
touch, scrollbar), so it snapped back to the bottom while the click was
in flight and the group never opened. Wheel the group into view first,
reusing the helper the timeline pagination specs already use for
prompts.
2026-11-04 16:35:23 +01:00
paseo-ai[bot] a91bb4d406 fix: update lockfile signatures and Nix hash [skip ci] 2026-11-04 15:01:56 +00:00
Mohamed Boudra 15997a06d0 fix(ci): pin semver away from incomplete attestations (#8787)
The pkijs lockfile regeneration bumped tiny-async-pool's semver to 5.7.3
and app-builder-lib's @electron/get semver to 6.3.2, both published
2026-11-02 in one batch. Some Cloudflare edges cached the attestation
document for 5.7.3 before the publish attestation attached, so
`npm audit signatures` fails with EMISSINGSIGNATUREKEY depending on the
runner's region. Pin both back to the 2023 releases with per-parent,
range-keyed overrides so global-agent's semver ^7 under @electron/get
is left alone.
2026-11-04 15:54:29 +01:00
paseo-bot[bot]andsino 2d5b8eb1fd Handle web terminal finger scrolling once (#8776)
* fix(app): scroll web terminal history with a finger swipe

A phone browser claims the vertical drag and drops it, because the canvas covers the scrollable viewport and the page itself does not scroll. Keep the gesture in the touch handler and move one line per cell.

* fix(app): handle terminal finger scrolling once

Keep the vertical gesture in the existing terminal handler so xterm does not also scroll it at document level. Retain only the confirmed propagation correction from the contributor patch and cover the actual phone-sized app with trusted touch input.

Co-authored-by: sino <sino2322@gmail.com>

* fix(app): let xterm own terminal touch input

Remove the duplicate gesture implementation so history scrolls once and terminal applications retain arrow-key and mouse-report input. Cover live application modes with trusted touch gestures and a real raw-stdin process.

---------

Co-authored-by: sino <sino2322@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-04 13:17:32 +00:00
Mohamed BoudraandElsa Granger ac775a2904 Reveal collapsed navigation as a touch overlay on wide layouts (#8739)
* feat(app): swipe open collapsed tablet navigation

Reuse the retained sidebar and mobile panel motion for a partial-width
navigation overlay when the wide dock is hidden. Preserve the dock
preference, project gestures through the sidebar width, and dismiss the
overlay when revealing workspace content.

Reset the animated frame width explicitly across dock transitions and
keep the resize origin on the UI thread.

Validated on Duo portrait and landscape, iPad portrait and landscape,
iPhone full-width navigation, and browser sidebar interactions.
Typecheck, lint, targeted formatting, and 31 relevant tests passed.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* fix(app): refine touch navigation overlays

Enable navigation swipes only on touch-capable runtimes and reject mouse
and pen input per gesture event. Allow swiping from anywhere in the
workspace instead of requiring an edge start.

Cover the full application window with the backdrop, including safe-area
padding, and remove the wide overlay's inline close button. Preserve
swipe, backdrop, and destination-selection dismissal.

Validated interior swipes on Duo and iPad, mouse exclusion in a browser,
and full-window dimming and dismissal in Duo portrait and landscape.
Typecheck, lint, and targeted formatting passed.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* refactor(app): let the panel store drop the overlay target on dock

Docking the sidebar, or any layout that no longer presents it as an
overlay, now drops the transient agent-list target inside the panel
store. App chrome publishes one left sidebar presentation (overlay,
dock, hidden) instead of an effect in SidebarChrome watching flags.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* refactor(app): answer fine pointer and touch screen from one module

Merge use-fine-pointer and use-has-touch-screen into
hooks/pointer-capabilities, which reads both answers from the media
queries it subscribes to. The touch answer now comes from
(any-pointer: coarse) instead of navigator.maxTouchPoints, which that
query's change events never reported.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* fix(app): keep the phone web drawer on its edge swipe

Restore the 32px left-edge start for the compact web navigation drawer,
so interior rightward swipes over terminals, code blocks, and diffs stay
with their horizontal scrollers. The wide navigation overlay still opens
from anywhere. The edge rule moves into resolveMobilePanelGestureIntent
so both drawers share it and it has a test.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* refactor(app): one sidebar handler per destination

Settings, host settings, add host, add project, and every navigation
row now use one handler that closes the navigation overlay before it
navigates. Closing is idempotent, so the docked sidebar shares it and
the overlay ? mobile : desktop handler pairs go away.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* refactor(app): resolve the left panel width in one place

MobilePanelsProvider now resolves the left sidebar width (full window
on compact, the saved width clamped to the safe viewport on wide) and
exposes it through the panel runtime. The open and close gestures, the
overlay frame, and the docked sidebar all read that value, so gesture
travel and panel width cannot disagree. MobileGestureWrapper no longer
recomputes it, and the panelWidth/width parameters go away.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* docs: fold the wide navigation overlay into the mobile panel rules

Move the facts the overlay PR appended into the sections that own them:
the destinations intro, store ownership of the agent-list target, the
projection widths, gesture pointer and edge rules, and the sidebar
presentation bullets.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* fix(app): keep the sidebar pans live across dock and overlay switches

On an Android tablet, opening the dock with the header toggle and collapsing
it again left the touch swipe unable to open the overlay until the app
reloaded. Swipe-to-close from the sidebar also stopped working after the
same toggle cycle.

Both pans took the dock/overlay state through RNGH's `.enabled()`. On
Android, RNGH 2.32 cancels the native handler when `isEnabled` changes and
resets its config on every update. After a false -> true flip the handler
stayed inert. `.enabled()` now carries only the device capability (touch
screen present), which does not change at runtime. The dock/overlay state
moves to a UI-thread derived value that fails the gesture in
onTouchesDown, next to the existing non-touch pointer check.

The wide sidebar's workspace list now always receives the close gesture
ref, as the compact sidebar already does. Toggling it between undefined
and the ref rewired the list's simultaneous handlers on every dock switch,
and that also left the close pan dead after a toggle cycle.

This is a native RNGH handler behavior, so there is no unit repro. It was
verified on the Android fold emulator: toggle open, toggle closed, swipe
opens the overlay, swipe on an empty sidebar strip closes it, no reload.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* fix(app): keep the workspace full width after collapsing the dock

On an Android tablet, opening the dock with the header toggle and
collapsing it again could leave the workspace at the docked width, with a
blank strip on the right, until the overlay was next opened. Elsa's
original head showed the same.

The sidebar's panel host flips between an in-flow dock and an absolute
overlay. Its width came from a Reanimated style that read the React
`overlay` flag, so the width reached the native view on the UI thread
after React had already committed the new presentation. Layout logs on
the fold emulator show the lag on every toggle-open: the workspace laid
out at width 0 while the docked host still had the overlay's full window
width, then a second pass laid out the 320dp dock. When the late update
was lost on collapse, the workspace kept its dock width.

The host no longer has a width of its own:
- The dock sizes to the inner sidebar view, which already carries the
  animated resize width.
- The overlay host is absoluteFill, which covers the whole row including
  the safe-area padding.

With no animated layout prop on the host, a presentation switch is one
React commit, and the host and the workspace lay out in the same pass.

Device evidence on the fold emulator:
- Toggle open and collapse cycles: the host and the workspace lay out
  together (320/521dp, then 841/841dp).
- A swipe opens the 320dp overlay with a full-window backdrop, and a
  swipe on the sidebar closes it.
- Dragging the dock edge still resizes the dock and the workspace
  together.

There is no unit repro: the defect is the ordering of Reanimated's
UI-thread layout updates against React commits.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* fix(app): cancel pending sidebar swipes when docking

An opening swipe still has the agent target until release. Leaving overlay presentation must advance its revision even then, so motion ownership and queued commits reject the obsolete swipe.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* fix(app): keep mouse selection free of the touch-only pan's capture

On a touch-capable browser the navigation pan registers pointer listeners
over the whole workspace. Gesture Handler's web pointer manager captured
the pressed element before the handler saw the pointer, and captured it
again on every move, so a mouse drag that the pan rejects stayed captured
and the browser's selection stopped at the starting paragraph.

The patched manager now captures after pointer-down, and only a pointer the
handler still tracks. The resize handle and touch swipes keep capturing.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* fix(app): drop the compact Explorer target when the layout widens

Opening the Explorer on a compact layout selects the `file-explorer` mobile panel target. Widening
the window (unfolding a foldable, rotating a tablet) kept that target, because the store only
heard the left sidebar presentation, which is `overlay` on both compact layouts and wide layouts
with a collapsed dock. The navigation swipe begins only from `agent`, so on the wide layout every
swipe was rejected until something else reset the target.

App chrome now publishes the whole panel layout (compact or wide, plus the left sidebar
presentation) and the store drops any target that layout does not present.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* fix(app): keep wide overlay sidebar content inside the left safe area

The overlay starts at the window's left edge, over the workspace's
safe-area padding, so its content now takes the left inset itself. The
dock already sits inside app chrome's padding and stays unchanged.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* fix(app): return an interrupted swipe to its anchor when docking cancels it

Docking mid-swipe bumps the panel revision while keeping the agent
target. The command treated the unchanged target as motion already in
progress and skipped the animation, leaving the dragged position
stranded between anchors. A command that interrupts a drag now always
animates to its target.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* fix(app): synchronize startup panel selection and gate pending swipes

A child layout effect can publish panel layout before the motion provider subscribes. Establish the current selection when subscribing, then observe later commands. Recheck presentation eligibility before gesture activation so docking also cancels touches that have not started dragging.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* test(app): cover panel subscription through its public contract

Verify initial snapshot delivery, later commands, and unsubscription directly in the existing panel-store suite instead of mounting test-only React components.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* test(app): reopen compact navigation after leaving settings

Destination navigation clears the transient overlay selection. Reopen the compact drawer before asserting its configured footer rather than relying on a stale open target surviving a wide layout.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* test(app): open navigation by keyboard in composer focus checks

Navigation pans intentionally reject mouse drags. Use the keyboard sidebar command to exercise focus and fullscreen Escape behavior without relying on the former mouse swipe helper.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* fix(app): complete sidebar integration after main rebase

Retain the resize width resolver and format the shared division-width and hybrid touch-sizing integration.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* test(desktop): give split browser panes room for tab rails

After #8740, narrow panes replace tab rails with compact dropdowns. The desktop browser journey counts and selects rail tabs, so widen the real Electron content window before splitting instead of treating missing narrow-pane rails as missing panes.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

---------

Co-authored-by: Elsa <zeyugao@outlook.com>
2026-11-04 13:30:39 +01:00
paseo-bot[bot] 0e7f452c2f Keep duplicate-path setup out of the Git filter CI budget (#8753)
* test(server): prepare duplicate paths before measuring Git filtering

* test(server): clarify the observer path preparation boundary

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-04 12:25:42 +00:00
Mohamed BoudraandElsa Granger cab5eedfe3 Keep workspace layouts usable in narrow columns across platforms (#8740)
* fix(app): retain Explorer resize width until acknowledged

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* fix(app): choose workspace header by measured column width

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* fix(app): separate iOS composer clipping from its shadow

Keep explicit coverage of the existing keyboard rotation guard.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* fix(app): choose workspace chrome within each desktop pane

The outer width wrapper added a compact header while the desktop split container kept its full header and pane rails. Each pane now owns the complete choice, replacing its header and rail together.

Each split pane uses its own measured width: below 500 points it shows its own tab dropdown, even if the neighboring pane is wide. Pane width determines usable header space. Keep new-tab keyboard ownership outside the switched chrome and retain positive measurements while hidden.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* fix(app): reserve readable width for the compact tab popover

Native scroll content does not contribute intrinsic width to the anchored frame. The count badge alone allowed the popover to compress its tab list and footer labels.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* fix(app): preserve compact pane actions and window clearance

Carry the owning pane into compact launch actions, keep split and restore controls in its menu, and distribute window corners through visible split children. Keep tab rails below header-owned window controls.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* fix(app): retain window dragging in compact panes

Keep the shared titlebar drag region when pane width replaces full desktop chrome. Cover two compact panes with both sidebars closed through the desktop host adapter.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* test(app): align pane journeys with measured workspace chrome

Give rail-specific split journeys enough column width, assert launched Diff content independently of header shape, and retain pane identity across compact transitions. Expect the compact selector in the half-screen Explorer workflow.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* refactor(app): share pane tab-switcher rendering after header rebase

Keep compact and vertical-bar header actions on the same pane-aware switcher contract while preserving the merged control geometry and script ownership.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* fix(app): retain Explorer acknowledgements across consecutive drags

Keep earlier released widths as expected allocations while a newer gesture owns the frame, and restore the latest release if that gesture is canceled.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* fix(app): keep focus mode exit in the compact workspace menu

Carry the workspace focus state into its compact action menu so a narrow header retains an exit without keyboard shortcuts.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* fix(app): distinguish resize rollback from pending acknowledgements

Restore the last released width when repeated widths precede a canceled gesture, and retain ownership when an unchanged old allocation matches a newer release before its predecessors arrive.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* test(app): allow software WebGL time to finish atlas growth coverage

The 256-batch pixel regression crosses more than 500 render boundaries and exceeds its 30-second total budget under CPU contention. Keep the corpus, every pixel assertion, and the two-second per-frame deadline; allow 60 seconds for the complete workload. Reproduced the timeout with Chromium confined to one CPU and verified two successful constrained runs after the adjustment.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

---------

Co-authored-by: Elsa Granger <zeyugao@outlook.com>
2026-11-04 12:23:41 +01:00
642 changed files with 38218 additions and 6573 deletions
+29
View File
@@ -1,5 +1,34 @@
# Changelog
## 0.15.3 - 2026-11-06
### Fixed
- Fixed conversation text becoming blurred when opening or dismissing the keyboard on iOS 26+ ([#8918](https://github.com/getpaseo/paseo/pull/8918) by [@dgreensp](https://github.com/dgreensp))
## 0.15.2 - 2026-11-05
### Added
- Added a first-run setup on Paseo Desktop and web that walks new users through theme, providers, projects, agent orchestration and the mobile app, with Settings → General → Show setup again to reopen it ([#8363](https://github.com/getpaseo/paseo/pull/8363))
- Added project suggestions from the folders where you ran Claude Code, Codex, OpenCode or Pi, shown in the setup Projects step ([#8363](https://github.com/getpaseo/paseo/pull/8363))
### Improved
- Changed the theme picker to a grid of tiles with a preview of each theme ([#8363](https://github.com/getpaseo/paseo/pull/8363))
- Replaced the "Loading…" text in Providers settings with skeleton rows ([#8363](https://github.com/getpaseo/paseo/pull/8363))
- Changed controls to two sizes: default controls are 32px tall on desktop and 44px on phones, and compact controls are 28px ([#8363](https://github.com/getpaseo/paseo/pull/8363))
- Changed first launch to go from the loading screen straight into setup instead of showing the empty home first ([#8824](https://github.com/getpaseo/paseo/pull/8824))
- Changed dialogs on desktop and web to fade in over about 160ms ([#8824](https://github.com/getpaseo/paseo/pull/8824))
- Changed modal and sheet backgrounds to match the screen background ([#8363](https://github.com/getpaseo/paseo/pull/8363))
- Changed documentation links in Settings sections to a `Label ↗` link beside the section title ([#8363](https://github.com/getpaseo/paseo/pull/8363))
## 0.15.1 - 2026-11-05
### Fixed
- Fixed the daemon failing to start when the terminal worker cannot read its own process identity, which left Paseo Desktop without a daemon on Windows machines where Windows PowerShell 5.1 cannot run `Get-CimInstance` ([#8810](https://github.com/getpaseo/paseo/pull/8810))
## 0.15.0 - 2026-11-03
Daemon restarts now keep terminal programs running. Interrupted agents can resume in their existing conversations when you turn on agent recovery.
+29
View File
@@ -1,5 +1,34 @@
# Changelog
## 0.15.3 - 2026-11-06
### 修复
- 修复了 iOS 26 及更高版本中打开或收起键盘时聊天内容变模糊的问题 ([#8918](https://github.com/getpaseo/paseo/pull/8918),贡献者:[@dgreensp](https://github.com/dgreensp))
## 0.15.2 - 2026-11-05
### 新增
- 在 Paseo 桌面版和网页版新增首次运行引导,依次带新用户设置主题、提供商、项目、Agent 编排和移动端应用,并可通过“设置 → 通用 → Show setup again”重新打开 ([#8363](https://github.com/getpaseo/paseo/pull/8363))
- 在引导的“项目”步骤中,根据你运行过 Claude Code、Codex、OpenCode 或 Pi 的文件夹推荐项目 ([#8363](https://github.com/getpaseo/paseo/pull/8363))
### 改进
- 将主题选择器改为带各主题预览的图块网格 ([#8363](https://github.com/getpaseo/paseo/pull/8363))
- 将“提供商”设置中的“Loading…”文字替换为骨架占位行 ([#8363](https://github.com/getpaseo/paseo/pull/8363))
- 将控件统一为两种尺寸:默认控件在桌面端高 32px、在手机上高 44px,紧凑控件高 28px ([#8363](https://github.com/getpaseo/paseo/pull/8363))
- 将首次启动改为从加载界面直接进入引导,不再先显示空白的主页 ([#8824](https://github.com/getpaseo/paseo/pull/8824))
- 将桌面端和网页版的对话框改为约 160 毫秒的淡入 ([#8824](https://github.com/getpaseo/paseo/pull/8824))
- 将弹窗和面板的背景改为与屏幕背景一致 ([#8363](https://github.com/getpaseo/paseo/pull/8363))
- 将“设置”各分区的文档链接改为分区标题旁的 `Label ↗` 链接 ([#8363](https://github.com/getpaseo/paseo/pull/8363))
## 0.15.1 - 2026-11-05
### 修复
- 修复终端工作进程无法读取自身进程标识时 daemon 无法启动的问题;此前在 Windows PowerShell 5.1 无法运行 `Get-CimInstance` 的 Windows 机器上,Paseo 桌面版因此没有可用的 daemon ([#8810](https://github.com/getpaseo/paseo/pull/8810))
## 0.15.0 - 2026-11-03
daemon 重启后,终端中的程序继续运行。开启 Agent 恢复后,被中断的 Agent 会在原有会话中恢复工作。
+42 -5
View File
@@ -136,6 +136,12 @@ the marker. Closing a tab sets that client's label to `false`. Any `true` client
open. Detach clears the parent and every open-tab label. The surviving child therefore becomes a
normal root agent immediately, and closing its still-open tab archives it.
Agent-scoped workspace creation records the caller's workspace as `parentWorkspaceId`.
This is informational ancestry for future workspace grouping. It does not change placement,
visibility, status, or archive behavior. Archiving a parent workspace preserves its children's
records and parentage. Unscoped creation and existing workspaces have no parent; ancestry is
never inferred from directories, projects, or Git branches.
Runtime ownership is resolved from explicit workspace ID and caller context, never from `cwd`. Workspace creation is a separate operation with `local | worktree` isolation; agent creation reuses that workspace unless a new workspace is explicitly requested.
Setup belongs to the workspace, including combined workspace-plus-agent creation and the supported agent `worktree` request. Run setup through the workspace workflow before launching configured terminals or scripts. Setup output is read in the Setup panel from per-command snapshots; never produce setup chat timeline rows or concatenate command logs. Legacy agent `git`/`worktreeName` inputs and hidden MCP worktree placement are rejected with workspace-creation guidance. Existing wire schemas retain an empty combined-log string for parsing compatibility.
@@ -253,6 +259,27 @@ archived workspace. History navigation must not infer workspace lifecycle from `
or mutate either lifecycle. The workspace route asks the daemon for authoritative recovery state;
only the route's explicit Unarchive or Restore action changes the archived workspace.
Workspace archive is undoable from the archived toast, or with Cmd/Ctrl+Z, while the toast is up.
The daemon runs one archive lane per workspace, shared by every connection
(`packages/server/src/server/workspace-archive-operations.ts`):
| A request arrives while | Result |
| ------------------------ | --------------------------------------------------------- |
| an archive runs: archive | joins the running archive |
| an archive runs: undo | aborts the archive, waits for it to settle, then restores |
| an undo runs: archive | waits for the undo, then archives |
| an undo runs: undo | joins the running undo |
A request takes its place in the lane before its first `await`. Arrival order decides, so an undo
sent right behind its archive still cancels it.
An aborted archive stops at its next checkpoint and reports `canceled`; the undo unarchives only
the agents that archive itself archived. Deleting the worktree directory is the point of no
return: an undo after it recreates the worktree through workspace recovery. The client shows or
hides the row by the latest local intent, so a late remove or upsert from a superseded request
does not flip it back. Tabs and layout are purged only once the undo window closes with the
archive committed.
History navigation opens the selected agent without changing either archive state. Workspace
**Restore** recovers only the workspace; the selected archived agent stays open with its callout.
The agent's **Unarchive** runs the provider's native unarchive hook before interactive resume and
@@ -305,17 +332,27 @@ Workspace status is an aggregate activity signal computed **per `workspaceId`**.
Running provider-native subagents contribute `running` to the workspace owned by their parent agent. Their completed, failed, and canceled states stay in the parent's subagents track.
A finished workspace can be marked unread after it has been reviewed. The daemon restores
`finished` attention on its newest eligible workspace-root agent without sending a new completion
notification. Opening the workspace clears that attention through the normal focus flow.
A finished or failed workspace can be marked unread after it has been reviewed. The daemon restores
attention on its newest eligible workspace-root agent, `error` for an agent whose lifecycle is
`error` and `finished` otherwise, without sending a new notification. Opening a workspace restores
its selected tab; attention on another agent remains unread until that agent is focused or the
workspace is explicitly marked read.
Attention is set by the agent finishing or failing and cleared by the client's
`workspace.clear_attention`, which fires when the user reads the chat. Loading an agent's runtime is
Attention is set by the agent finishing or failing and acknowledged by deliberate focus or
interaction with its chat, including repeat clicks in an already focused pane. A completion
arriving while focused stays unread until the next interaction; leaving does not acknowledge it. Loading an agent's runtime is
neither, so resuming carries the stored attention and the stored last-activity time through
untouched. Forging either makes a background resume look like the user read a workspace and like the
agent worked in it just now, which rewrites the sidebar timestamp permanently — persisted
`updatedAt` is what workspace `statusEnteredAt` is re-derived from on the next daemon start.
The `failed` bucket follows unread `error` attention, not lifecycle status. An agent stays `error`
until its next turn starts, so reading the chat dismisses the red dot the same way it dismisses the
green one. For the same reason every new failure raises `error` attention, including a failed reload
that leaves the lifecycle at `error`, and replaces unread `finished` attention; otherwise the failed
agent would show the green dot or none. Subagents track marks are the exception: they follow the
child's lifecycle, because the track is where a failed child stays visible.
## The subagents track
The track is a pill at the foot of an agent's pane (`packages/app/src/subagents/track.tsx`): a count you can read at a glance, and a panel behind it — a popover on wide screens, a sheet on compact ones — holding the rows. It floats over the transcript rather than sitting in a band above the composer, so the timeline scrolls underneath it; `packages/app/src/panels/agent-tracks.tsx` owns that placement, and the pill frame is shared with the task list in `packages/app/src/composer/tracks.tsx`.
+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
View File
@@ -34,6 +34,7 @@ So arrival sets a _target_ and the reveal rate is derived from the backlog inste
- **First sight of a text is revealed whole.** Only growth is paced. This is what makes history hydration, timeline replay, a virtualized row remounting on scroll, and an already-finished message all render complete on first paint without a special case for each.
- **Leaving `phase: "streaming"` snaps the reveal.** A completed turn must never be left holding characters. `layoutStream` sets the phase, so anything outside the live head with an active turn is already complete.
- **The reducer queue commits on a frame, with a timer as the ceiling.** A frame callback never fires in a hidden tab, so a timer races it and wins when nothing is painting — the store has to keep advancing either way.
- **A turn boundary commits the queue before turn liveness changes.** The final reply arrives a few milliseconds before `turn_completed`, so it is still queued when the turn ends. `applyStreamEvent` in `viewed-timeline-sync.ts` flushes the agent's queue first; otherwise the chat paints a finished turn with no reply, drops the footer, and jumps twice.
- **A history row re-renders only when its item or layout item identity changes.** Every
coalesced tick hands the list a new layout. Without a memo boundary each tick re-rendered every
mounted row (measured on the inverted FlatList native used before LegendList: about 50 rows on a
+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.
+5 -1
View File
@@ -26,6 +26,10 @@ checkout root. They intentionally differ for an exact subproject inside a worktr
restore, branch auto-name, and descriptor flows consume those persisted facts rather than
rediscovering ownership from a directory that may already be gone. Reconciliation may refresh
mutable placement facts, but never changes `projectId`, `cwd`, `displayName`, or `baseBranch`.
An explicit base selection is stored in the checkout's Git-directory Paseo metadata and
mirrored to `baseBranch` in every workspace record sharing that checkout. The record keeps
recovery possible after the checkout metadata is deleted. A newly opened workspace inherits
the checkout's selected base; separate worktrees keep separate bases.
Workspace archive runs lifecycle teardown from the exact `cwd` but removes only the backing
`worktreeRoot` after its last active reference disappears. Worktree recovery recreates that backing
checkout from `mainRepoRoot`, then restores the relative path from `worktreeRoot` to `cwd`.
@@ -470,7 +474,7 @@ Array of workspace records. A workspace is a specific working directory within a
| `title` | `string \| null` | User-set name override layered over `displayName`. Null means "use `displayName`". |
| `branch` | `string \| null` | The current Git branch for git-backed workspaces. Separate from `displayName`/`title`; a background branch refresh never rewrites the name. |
| `worktreeRoot` | `string \| null` | Backing checkout/worktree root. May differ from `cwd` for exact subprojects and remains persisted after the worktree is deleted so restore can reproduce the placement. |
| `baseBranch` | `string \| null` | Comparison base retained across archive and restore. Branch-off creation stores the resolved ref; legacy and PR-checkout records hold a bare name. Null means no recorded base. |
| `baseBranch` | `string \| null` | Current base retained across archive and restore. Explicit selections and branch-off creation store exact refs; older records may store branch names. Null means no recorded base. |
| `isPaseoOwnedWorktree` | `boolean` | Whether Paseo owns and may remove/recreate the backing `worktreeRoot` |
| `mainRepoRoot` | `string \| null` | Main repository root for worktree checkouts, independent of both exact `cwd` and backing `worktreeRoot` |
| `createdAt` | `string` (ISO 8601) | |
+7 -1
View File
@@ -70,6 +70,10 @@ the sidebar. Apply it as `backgroundColor`; setting `opacity` on the control als
Controls come in two sizes, defined once in `control-geometry.ts`. The default is the right size almost everywhere — pages, dialogs, forms, settings rows, the setup flow — so don't pass a size. It is 32px tall where a pointer drives it and 44px where controls are touch-sized, so every touch control meets the iOS HIG's 44pt hit region. `sm` is 28px pointer and 32px touch, with `fontSize.sm` labels, and is for tight chrome only: toolbars, nested settings rows, dense inline row actions. A `size="sm"` on an ordinary page or dialog is wrong, and so is choosing a size from `useIsCompactFormFactor()`.
Dense toolbar rows, such as the composer's, are the exception to the 44pt control. Touch-sized the row is 44pt tall and each control is a 36pt circle (`TOOLBAR_DISC_SIZE`): send and stop fill it, and every other control's pressed highlight fills it, so every highlight is the same fully rounded shape. Labelled pickers are 36pt pills. A control lays out at the width of what it paints (`toolbarSlot`, `toolbarContentSlot`) and its circle reaches into the gaps, so `TOOLBAR_CONTROL_GAP`, 20pt, is the visible space between glyphs. Toolbar glyphs match the header icons: 20pt, the app's 1.5px stroke, resting at `foreground` (`toolbarGlyphsFor`). The context ring is 18pt with a 2px stroke (`TOOLBAR_RINGS`): a circle fills its box where an outline glyph does not, and a thin circle reads lighter than a glyph at the same stroke. A disc's solid glyph, such as the stop square, stays at the `md` size (`TOOLBAR_DISC_GLYPH_SIZES`). Pointer-sized toolbars keep 28px controls, a 4px gap, and a 16pt muted glyph that brightens on hover.
In the composer, the first glyph's ink starts on the text column's edge, and the trailing control is always a disc, so the row does not shift as it changes between voice mode, send and stop. The disc nests into the corner, as far from the right edge as from the bottom, and touch-sized the composer's corner radius is concentric with it (`packages/app/src/composer/input/toolbar-rails.ts`).
One gate, `resolveControlSizing`, picks touch or pointer sizing: touch when the layout is compact or the device has a touch screen. An iPad with the sidebar docked is wide and touch-sized; a desktop is pointer-sized; a phone is both compact and touch. The touch-screen fact is read once at startup (`hasTouchScreen` in `hooks/use-fine-pointer.ts`), so attaching a mouse or keyboard to a tablet never shrinks its controls. Stylesheets take sizes from `bySizing({ touch, pointer })`, which resolves to a breakpoint value, so a caller never sees which sizing it got. Rows, icon buttons and icon slots take theirs from `styles/density.ts`, which uses the same gate. An `{ xs, md }` literal for a hit target or glyph is wrong: on an iPad it yields the pointer size.
Buttons are pills (`borderRadius.full`) with medium-weight labels; an icon-only button is a circle. `<Switch>` follows the same two sizes: a 34×20 track by default and in `sm`, 44×26 where controls are touch-sized, and it reserves its size's control height so a row holding one lines up with a row holding a button. Buttons, text fields, dropdown triggers, and `<SegmentedControl>` (`packages/app/src/components/ui/segmented-control.tsx`) share the size contract — the same size name means the same height and label size on every control kind, so controls in one row line up. Segments run tighter than a button, because the gap between segments already reads as padding. The selected segment is a `surface3` fill with `foreground` text, not an inverted one — inverting it inside thin chrome puts a white slab in the toolbar. A settings row sizes the controls in its trailing slot itself, default in a card and `sm` when nested (`packages/app/src/components/ui/control-size.tsx`), so don't pass a size to a button, dropdown or field placed in a row. Code that computes with a control's height outside a stylesheet uses `controlHeightForLayout`. Never shrink a control's font or padding locally to fit a context — if the context needs a smaller control, the size tier is missing or the wrong one is in use.
@@ -140,7 +144,7 @@ Things align to their glyphs, not to their boxes. A row's leading icon, its titl
Pick the rails from the content, then hold them. A settings card establishes a leading rail at the icon's left edge and a trailing rail at the last glyph's right edge; every row in that card uses the same two. A row whose icon is absent still starts its title on the leading rail. Indentation is a new rail, not an arbitrary offset.
The pressable is bigger than the glyph, and that is fine. Hit areas grow outward from the aligned content — they never move it. A button that looks two pixels off because its padding is asymmetric is misaligned even though its box is correct.
The pressable is bigger than the glyph, and that is fine. Hit areas grow outward from the aligned content — they never move it. A button that looks two pixels off because its padding is asymmetric is misaligned even though its box is correct. The composer toolbar is the worked example: its outermost ink, the attach `+` on the left and the trailing glyph or send/stop disc on the right, sits on the text column's edges, and the 44pt touch slots overhang into the composer padding by each end control's ink inset (`composerToolbarOverhang`).
Optical alignment beats arithmetic when a glyph disagrees with its bounding box. Icons with visual weight on one side, chevrons, and single-character labels usually need a small nudge to look centered. Trust the eye, then leave a comment saying the offset is optical.
@@ -169,6 +173,8 @@ Electron window controls are top-corner obstructions, not a compact-layout condi
Windows and Linux controls are fixed window chrome, outside scrolling header content. A tab rail that reaches them ends at their obstruction and shows the shared overflow fade. On macOS, the Explorer toggle occupies a fixed top-right window slot so opening and closing Explorer does not move the pointer target.
Each window edge has one safe-area owner. `WindowLayoutProvider` publishes the bottom inset that every surface reads through `useSafeAreaInsets()`: on iOS 27.1 and later it is UIKit's bottom-bar clearance, which refines the rectangular home-indicator inset to the screen's corners, and elsewhere the system inset. Surfaces apply that value once and never stack their own spacing under it; `WindowLayoutSurface` owns the horizontal insets the same way.
A new list+detail feature copies the settings shell. A new workspace-shaped feature copies the workspace shell. Inventing a third shape happens in design review, not in a PR.
---
+10 -1
View File
@@ -594,7 +594,16 @@ rerun passes only by landing elsewhere.
To confirm, fetch the URL from several runners and compare `content-length`, the `cf-ray` colo
suffix, and the number of `attestations` entries (#7730 has the probe). To fix, remove or
replace that version in the lockfile. If the dependent’s range cannot be changed, pin a clean
version in root `overrides` and regenerate the lockfile. Report the URL to npm so they purge it.
version in root `overrides`. Report the URL to npm so they purge it.
A lockfile regeneration is itself how new bad versions enter: the pkijs fix re-resolved the
electron-builder tree and pulled in `semver` 5.7.3 and 6.3.2, published two days earlier, and
5.7.3 then failed the same way. After editing `overrides`, run `npm install --package-lock-only`
and check that the changed `resolved` lines in `package-lock.json` are only the ones you meant.
A per-parent override such as `"tiny-async-pool": { "semver": "5.7.2" }` applies to that
parent's whole subtree, so it can also downgrade a grandchild that needs a different major. Key
the child by range (`{ "semver@^5": "5.7.2" }`) so only matching edges are replaced.
## ACP provider catalog versions
+1 -1
View File
@@ -68,7 +68,7 @@ When app-wide routes such as `/new`, `/settings`, or `/sessions` navigate back
into a host workspace, use `navigateToWorkspace()`. Do not make the caller
branch on its current route.
Pass only `serverId` and `workspaceId` for normal attention-aware navigation.
Pass only `serverId` and `workspaceId` to restore the workspace’s selected tab.
When the action names a specific tab, pass it as `target`; that explicit choice
is authoritative. Callers should not choose between separate route and tab
navigation APIs.
+5
View File
@@ -172,6 +172,11 @@ slide under the header on open and out from under it on close. Do not swap the
dock to keyboard padding at rest. Changing its height creates either a blank
band or a paused transition when the stream list updates its viewport.
The docked composer rests at the larger of the safe-area bottom and 16pt. With
the keyboard open it keeps 16pt above the keyboard through the translate view's
`keyboardGap`, which rises further than the keyboard's safe-area overlap. Do not
grow the composer's bottom padding with the keyboard instead.
Do not animate a layout prop through the keyboard transition. On Android
Fabric, each animated layout update clones the shadow tree and runs Yoga over
the dock subtree. This measured about 10 ms per frame on the emulator and the
+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
@@ -84,3 +84,10 @@ its DOM-derived scope.
Escape interrupts an agent only in the message input or an unclaimed surface.
A focused terminal excludes that binding. The source leaves terminal Escape alone;
terminal Escape/Ctrl forwarding is a separate feature.
## Keys shared with text editing
A binding on a key the platform already uses for editing, such as Cmd/Ctrl+Z for
workspace archive undo, declares `editable: false` and registers its handler with
`isActive` true only while it has work. An inactive handler leaves the key
unhandled, so the pipeline skips `preventDefault` and the platform's own undo runs.
+3 -2
View File
@@ -226,7 +226,8 @@ its own.
inactive. An async action can navigate before its menu closes; a hidden panel must not leave a
portal backdrop blocking the destination. On web the chat suspends one commit after it goes
inactive, and that inactive commit is where the surface unmounts.
- Anchoring, flipping, and edge clamping live in `components/ui/anchor.ts`, shared with the hover
card, and are unit-tested. Fix positioning bugs there, not at a call site.
- Anchoring, flipping, edge clamping, and capping a surface's width to the display live in
`components/ui/anchor.ts`, shared with the hover card, and are unit-tested. Clamping moves a
surface but cannot shrink it, so the width cap is what keeps a wide menu on a narrow screen. Fix positioning bugs there, not at a call site.
- Everything else about floating surfaces on Android — Portal/Modal escape, lifecycle gates,
status-bar offset, the open flash — is in [floating-panels.md](floating-panels.md).
+39 -10
View File
@@ -9,6 +9,10 @@ Compact layouts have three mutually exclusive destinations:
They are one interaction, not two independent drawers. The implementation lives in
`packages/app/src/mobile-panels/`.
Wide layouts reuse `agent-list` for the navigation overlay. When the left dock is collapsed or
yields its width, a touch swipe reveals the same sidebar over the workspace at the resolved sidebar
width. The workspace does not move, and the saved dock preference does not change.
## Ownership
React/Zustand owns the durable intent:
@@ -23,6 +27,12 @@ interface MobilePanelSelection {
Every semantic target change increments `revision`. Repeating the current target is idempotent.
Compact panel selection is not persisted; a cold start begins at `agent`.
Only an overlay sidebar holds `agent-list`, and only a compact layout holds `file-explorer`. App
chrome publishes the layout (compact or wide, and the left sidebar presentation `overlay`, `dock`, or
`hidden`) to `panel-store`, which drops any target the layout does not present. A stale
`agent-list` would reopen the overlay on the next collapse; a stale `file-explorer` after widening
would block the navigation swipe, which begins only from `agent`.
The UI worklet owns transient motion:
- one normalized position (`-1` left, `0` center, `1` right)
@@ -32,8 +42,10 @@ The UI worklet owns transient motion:
React publishes the active panel only when the canonical target and the UI-thread position agree at
the final anchor. Retained content never observes gesture previews, progress, or an unsettled target.
The gesture hosts stay mounted. The Gesture Handler web patch keeps initially disabled hosts
from capturing paragraph pointers; that capture confines browser selection to its starting paragraph.
The gesture hosts stay mounted. A pointer capture on a paragraph confines browser selection to that
paragraph, so the Gesture Handler web patch keeps it off: initially disabled hosts register no
pointer listeners, and an enabled host captures only a pointer its handler still tracks after
pointer-down. The touch-only navigation pans fail a mouse at pointer-down and never capture it.
Native worklets own overlay opacity and pointer events while
React owns settled panel activity and accessibility. Keep hidden native overlays laid out: collapsing
their scroll ranges makes Android spring retained offsets back to zero during touch cancellation or
@@ -43,8 +55,11 @@ appearance keys; see [Unistyles appearance boundaries](unistyles.md#runtime-them
## Why one position
Both transforms and both backdrop opacities are derived from the same normalized position. Window
width is only a projection input. Rotation changes the projection, not the panel state.
Both transforms and both backdrop opacities are derived from the same normalized position. Panel
width is only a projection input: the window width on the right, and on the left the width
`MobilePanelsProvider` resolves (the full window on compact layouts, the saved sidebar width
clamped to the viewport on wide ones). The dock, the overlay, and their gestures read that one
value. Rotation changes the projection, not the panel state.
This makes these invalid states unrepresentable:
@@ -62,7 +77,9 @@ A gesture captures the current revision when it becomes active. Per-frame update
while that revision still owns the gesture.
When a React command arrives during a drag, its newer revision clears gesture ownership and starts
motion toward the new target. The older gesture's remaining updates and finish callback are ignored.
motion toward its target, even an unchanged one: docking mid-swipe keeps `agent` and still returns
the dragged position to its anchor. The older gesture's remaining updates and finish callback are
ignored.
Canceled gestures return to the latest canonical target. A successful gesture starts its finishing
motion immediately; the matching React command adopts that motion instead of restarting it. Position
settlement and command acceptance may arrive in either order. Activity changes after both agree on
@@ -80,6 +97,12 @@ definition, no longer eligible to begin.
- Callers request semantic targets through `panel-store`; they never write shared values.
- Gesture behavior comes from the four explicit hooks in `mobile-panels/gestures.ts`.
- Navigation gestures accept touch pointers only. Mouse and pen fail at touch-down, so dragging a
mouse to select text on a hybrid laptop never moves a panel. Browsers without a touch screen
(`hooks/pointer-capabilities`) never enable them.
- Phone web opens a drawer only from the 32px edge it slides in from, so interior swipes stay with
terminals, code blocks, and diff scrollers. Native drawers and the wide overlay open from anywhere.
Horizontal-scroll ownership and the open-gesture blocker still take precedence.
- A focused interactive surface may block panel-opening gestures through
`useBlockMobilePanelOpenGestures`. Register only while the conflicting interaction is active and
unregister when the surface is hidden or unfocused. The blocker never disables gestures that close
@@ -87,11 +110,17 @@ definition, no longer eligible to begin.
- Keep `SidebarModelProvider` outside `MobileGestureWrapper`. The provider shares sidebar derivation
across consumers, while Gesture Handler requires the wrapper's direct child to be a native `View`
so its injected `collapsable={false}` reaches Android/Fabric.
- Mobile sidebars render through `MobilePanelOverlay`; do not duplicate overlay lifecycle or motion
styles in sidebar components.
- The desktop left sidebar is retained too. App chrome owns separate mounted and visible decisions:
closing it or yielding its width marks it inactive and applies `display: none` without conditionally
removing the sidebar tree.
- Sidebars render through `MobilePanelOverlay`; do not duplicate overlay lifecycle or motion styles
in sidebar components. The wide left sidebar keeps the same content and gesture hosts when switching
between dock and overlay. App chrome owns mounting and presentation; settled panel activity owns
overlay work. A hidden dock is inactive with `display: none`; the sidebar tree stays mounted. Keep
the native overlay laid out while hidden, as on compact layouts. Give the switching host no
Reanimated layout props: a width keyed on the presentation reaches Android after React commits the
switch, and a lost update left the workspace at the dock width. The dock sizes to its content and
the overlay host fills the window.
- The overlay's backdrop covers the whole window; safe-area padding applies to the sidebar and
workspace content only. It closes by swiping back, tapping the backdrop, or picking a destination:
every sidebar destination closes the overlay before it navigates.
- Animated panel nodes use React Native static styles plus inline theme values. Do not attach
Unistyles-generated styles to those nodes; Unistyles and Reanimated patching the same Fabric node
has caused native crashes.
+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
+9 -6
View File
@@ -281,23 +281,26 @@ Drafts do not appear in GitHub's releases feed. Updater clients continue to see
The `rollout_hours` input on `desktop-release.yml` is **only read on `workflow_dispatch`** — tag-push runs always default to 36. To get any other rollout duration on a fresh release, use the post-publish flip below.
### Instant-admit release (rollout_hours=0 from publish)
### Instant-admit release (rollout_hours=0)
For a fresh release that should admit everyone immediately (low-risk change, doc-only, hotfix, or just a release you want out fast), cut the release normally and queue the rollout flip immediately after:
`npm run release:patch` and `npm run release:minor` tag-push, and tag-push runs always stamp 36 hours. To admit everyone immediately (hotfix, doc-only change, a release you want out fast), cut the release normally, wait for it to publish, then flip the manifests:
```bash
# 1. Cut and publish (default 36h ramp from tag push).
# 1. Cut and publish (36h ramp from tag push).
npm run release:patch
# 2. Immediately queue the flip — runs as soon as finalize-rollout completes.
# 2. Wait for Desktop Release to publish the GitHub Release.
gh run watch <desktop-release-run-id> --exit-status
# 3. Flip the published manifests to instant admit.
gh workflow run desktop-rollout.yml \
-f tag=v0.1.64 \
-f rollout_hours=0
```
**Why this is gap-free:** `desktop-release.yml`'s `finalize-rollout` job and `desktop-rollout.yml` share the concurrency group `desktop-rollout-<tag>`. Dispatching `desktop-rollout.yml` while the tag-push pipeline is still running queues it safely behind `finalize-rollout`. The first public manifests already carry `rolloutHours=36`, then `desktop-rollout.yml` flips them to `rolloutHours=0` shortly afterward. The renderer polls every 30 minutes, so active stable users pick up the new manifest on their next check.
Dispatch only after `finalize-rollout` has published the release. Until then the release is a draft, and a draft has no tag: `desktop-rollout.yml` looks the release up by tag and fails with `release not found`. The concurrency group `desktop-rollout-<tag>` that the two workflows share orders them only once both are queued; it does not hold an early dispatch until the draft is published.
Run the dispatch right after `release:patch` or `release:minor` returns. Don't wait for the tag-push CI to finish.
The first public manifests carry `rolloutHours=36` for the minutes between publish and the flip. The renderer polls every 30 minutes, so active stable users pick up the new manifest on their next check.
### Adjusting an already-published release
+9 -6
View File
@@ -15,17 +15,20 @@ 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.
## Record, socket and secret
The worker writes `$PASEO_HOME/terminal-worker/<pid>.json` (owner-only) with its identity (birth time + command line carrying `--paseo-terminal-worker <recordId>`), protocol version, socket path, a random secret, the copy it runs from, and each terminal program's pid and birth time.
The worker writes `$PASEO_HOME/terminal-worker/<pid>.json` (owner-only) with its pid, protocol version, socket path, random secret, runtime copy and terminal programs. One daemon holds the home's pid lock. Its records and sockets belong to that home.
- The first frame on the socket must be `hello` with the secret; anything else closes the connection. Anyone who connects can type into every shell, so the record is `0600`, the unix socket `0600` in a `0700` directory.
- Unix socket paths have a byte budget (103 on macOS, 107 on Linux). Dev homes under worktrees blow it, so `terminal-worker-socket-path.ts` tries `$PASEO_HOME/terminal-worker/sock/`, then `$TMPDIR/paseo-<uid>/`, then `/tmp/paseo-<uid>/`, and accepts an existing directory only if it is a real `0700` directory owned by you. It never chmods a shared root.
- Cleanup signals a process only when its birth time matches the record. Birth times come from `ps -o lstart` with `LC_ALL=C` (it follows `LC_TIME` otherwise) and from `Win32_Process.CreationDate`. A missing birth time is "unknown", never matched by command text: a shell that ran `exec` keeps its pid and birth time but not its argv.
- The first frame must be `hello` with the secret; anything else closes the connection. Anyone authenticated can type into every shell, so the record and Unix socket are `0600` inside a `0700` directory.
- New socket names include the record id. A stale record whose pid belongs to a newer worker cannot reach that newer worker. Existing records keep their recorded paths. Legacy paths use only the home and pid, so force-killing through one also requires successful authentication. A legacy socket that cannot authenticate stays unconfirmed.
- A missing pid or refused socket retires the record without signaling the worker. An accepting socket identifies the live worker. Cleanup asks it to shut down, then probes again before force-killing it after the grace period (`taskkill /T` on Windows). An inconclusive probe counts toward the worker limit and is retried on the next start or stop without signaling it.
- Workers never query their own birth time or command line. Readers accept legacy identity fields but ignore them. Daemons through 0.15.1 report new records unreadable and cannot adopt them.
- Windows terminal programs have no recorded birth time and terminal lifecycle operations never launch PowerShell. Linux and macOS retain program birth-time checks through `ps` with `LC_ALL=C`; Linux also walks sessions to find surviving jobs.
- Unix socket paths have a byte budget (103 on macOS, 107 on Linux). Long dev homes use an owner-only fallback under `$TMPDIR/paseo-<uid>/` or `/tmp/paseo-<uid>/`. An existing directory must be a real `0700` directory owned by you; a shared root is never chmodded.
- A kill cleanup that cannot confirm every process gone writes `closing-<recordId>.json` beside the worker record. The worker rewrites its own record whenever a terminal starts or exits, so the kill intent has to live in a file it never writes. A closing record is never adopted; the next stop or start retries it.
- An unreadable record of either kind makes `daemon status` and a kill restart fail with its path: a kill cleanup that met it could not check what it names, and leaves nothing else behind. A read that fails with `EPERM`, `EBUSY` or `EACCES` is retried for 250ms first: on Windows, opening a record while the worker renames its new copy over it fails that way for a moment.
@@ -40,7 +43,7 @@ The worker writes `$PASEO_HOME/terminal-worker/<pid>.json` (owner-only) with its
| two or more older | shuts it down; its terminals close (logged) |
| newer, or a version it cannot read | leaves it running, undriven; `list_terminals` and `create_terminal` answer `rpc_error` `terminal_worker_incompatible`, and `paseo daemon status` prints why |
Never more than two workers run. A new worker launches only when none is current, none is incompatible and fewer than two run. The count comes from the records, so an unreachable or unidentified worker counts, and so does one a cleanup could not confirm gone. When two run, the daemon starts with no current worker, and the first terminal created after one of them exits launches it.
Never more than two workers run. A new worker launches only when none is current, none is incompatible and fewer than two run. The count comes from the records, so an unreachable worker counts, as does one whose socket probe is inconclusive, and one a cleanup could not confirm gone. When two run, the daemon starts with no current worker, and the first terminal created after one of them exits launches it.
**The frozen envelope never changes across versions:** the frame format (4-byte length + `v8.serialize`), `hello`/`helloAck`, the `listTerminals` info fields and `shutdown`. That is what lets any daemon close any worker: a kill stop sends `shutdown` to a newer worker it cannot otherwise drive. Protocol 5 hard cuts unreleased protocols 1 through 4, including the acknowledgement-based replay contract: those workers are obsolete and close. For future versions, keep a previous-version adapter so the draining worker can still be driven, and drop the adapter for the version before that.
+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
@@ -0,0 +1,4 @@
Fixed
- Fixed the daemon failing to start when the terminal worker cannot read its own process identity, which left Paseo Desktop without a daemon on Windows machines where Windows PowerShell 5.1 cannot run Get-CimInstance
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,4 @@
Fixed
- Fixed the daemon failing to start when the terminal worker cannot read its own process identity, which left Paseo Desktop without a daemon on Windows machines where Windows PowerShell 5.1 cannot run Get-CimInstance
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,4 @@
Fixed
- Fixed the daemon failing to start when the terminal worker cannot read its own process identity, which left Paseo Desktop without a daemon on Windows machines where Windows PowerShell 5.1 cannot run Get-CimInstance
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,4 @@
Fixed
- Fixed the daemon failing to start when the terminal worker cannot read its own process identity, which left Paseo Desktop without a daemon on Windows machines where Windows PowerShell 5.1 cannot run Get-CimInstance
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,8 @@
Added
- Added a first-run setup on Paseo Desktop and web that walks new users through theme, providers, projects, agent orchestration and the mobile app, with Settings → General → Show setup again to reopen it
- Added project suggestions from the folders where you ran Claude Code, Codex, OpenCode or Pi, shown in the setup Projects step
Improved
- Changed the theme picker to a grid of tiles with a preview of each theme
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,8 @@
Added
- Added a first-run setup on Paseo Desktop and web that walks new users through theme, providers, projects, agent orchestration and the mobile app, with Settings → General → Show setup again to reopen it
- Added project suggestions from the folders where you ran Claude Code, Codex, OpenCode or Pi, shown in the setup Projects step
Improved
- Changed the theme picker to a grid of tiles with a preview of each theme
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,8 @@
Added
- Added a first-run setup on Paseo Desktop and web that walks new users through theme, providers, projects, agent orchestration and the mobile app, with Settings → General → Show setup again to reopen it
- Added project suggestions from the folders where you ran Claude Code, Codex, OpenCode or Pi, shown in the setup Projects step
Improved
- Changed the theme picker to a grid of tiles with a preview of each theme
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,8 @@
Added
- Added a first-run setup on Paseo Desktop and web that walks new users through theme, providers, projects, agent orchestration and the mobile app, with Settings → General → Show setup again to reopen it
- Added project suggestions from the folders where you ran Claude Code, Codex, OpenCode or Pi, shown in the setup Projects step
Improved
- Changed the theme picker to a grid of tiles with a preview of each theme
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,4 @@
Fixed
- Fixed conversation text becoming blurred when opening or dismissing the keyboard on iOS 26+
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,4 @@
Fixed
- Fixed conversation text becoming blurred when opening or dismissing the keyboard on iOS 26+
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,4 @@
Fixed
- Fixed conversation text becoming blurred when opening or dismissing the keyboard on iOS 26+
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,4 @@
Fixed
- Fixed conversation text becoming blurred when opening or dismissing the keyboard on iOS 26+
Full notes: https://paseo.sh/changelog
+1 -1
View File
@@ -1 +1 @@
sha256-sLaLHD1u1eJRdGdflcwWBGxnnhRQpB1T74BlaWcmxbU=
sha256-vwivjBU5VFpM9VD7Wx4IKHN6xEVlg/KBMJCCMuA1JEw=
+34 -34
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.15.0",
"version": "0.15.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.15.0",
"version": "0.15.3",
"hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
@@ -15088,9 +15088,9 @@
}
},
"node_modules/app-builder-lib/node_modules/@electron/get/node_modules/semver": {
"version": "6.3.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.2.tgz",
"integrity": "sha512-GYrCADwpu6yV5OAvchkQ27Z5wwMxWAEMg5NNWjyHh/5DboFFqbdbvYNIvZouQpxXyeMPoTOh0vdPYKlf0ZswdQ==",
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true,
"license": "ISC",
"bin": {
@@ -35100,9 +35100,9 @@
}
},
"node_modules/tiny-async-pool/node_modules/semver": {
"version": "5.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.3.tgz",
"integrity": "sha512-kM18BPOqTi0+TB68703/GJRpAML5Me+IDQsR1J26YB3EFKKNEyHIVZuWDX7+WKQB3tHwDwgk24BfhdpWUbytvQ==",
"version": "5.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
"integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
"dev": true,
"license": "ISC",
"bin": {
@@ -37468,7 +37468,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.15.0",
"version": "0.15.3",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -39637,13 +39637,13 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.15.0",
"version": "0.15.3",
"license": "Apache-2.0",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.15.0",
"@getpaseo/protocol": "0.15.0",
"@getpaseo/server": "0.15.0",
"@getpaseo/client": "0.15.3",
"@getpaseo/protocol": "0.15.3",
"@getpaseo/server": "0.15.3",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -39890,11 +39890,11 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.15.0",
"version": "0.15.3",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/protocol": "0.15.0",
"@getpaseo/relay": "0.15.0",
"@getpaseo/protocol": "0.15.3",
"@getpaseo/relay": "0.15.3",
"ws": "^8.20.0",
"zod": "^4.4.3"
},
@@ -39906,7 +39906,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.15.0",
"version": "0.15.3",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/cli": "*",
@@ -40149,7 +40149,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.15.0",
"version": "0.15.3",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -40195,7 +40195,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.15.0",
"version": "0.15.3",
"license": "Apache-2.0",
"dependencies": {
"@codemirror/language": "6.12.4",
@@ -40225,7 +40225,7 @@
},
"packages/plugin": {
"name": "@getpaseo/plugin",
"version": "0.15.0",
"version": "0.15.3",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "^1.4.0",
@@ -40233,8 +40233,8 @@
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.15.0",
"@getpaseo/protocol": "0.15.0",
"@getpaseo/client": "0.15.3",
"@getpaseo/protocol": "0.15.3",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.2.3",
@@ -40244,8 +40244,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.15.0",
"@getpaseo/protocol": "0.15.0",
"@getpaseo/client": "0.15.3",
"@getpaseo/protocol": "0.15.3",
"react": ">=19.1.0 <20",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
@@ -40267,7 +40267,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.15.0",
"version": "0.15.3",
"license": "Apache-2.0",
"dependencies": {
"semver": "^7.8.5",
@@ -40295,7 +40295,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.15.0",
"version": "0.15.3",
"license": "Apache-2.0",
"dependencies": {
"@noble/ciphers": "^2.4.0",
@@ -40517,17 +40517,17 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.15.0",
"version": "0.15.3",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.15.0",
"@getpaseo/highlight": "0.15.0",
"@getpaseo/plugin": "0.15.0",
"@getpaseo/protocol": "0.15.0",
"@getpaseo/relay": "0.15.0",
"@getpaseo/client": "0.15.3",
"@getpaseo/highlight": "0.15.3",
"@getpaseo/plugin": "0.15.3",
"@getpaseo/protocol": "0.15.3",
"@getpaseo/relay": "0.15.3",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -41474,7 +41474,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.15.0",
"version": "0.15.3",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
@@ -43084,7 +43084,7 @@
},
"plugins": {
"name": "@getpaseo/builtin-plugins",
"version": "0.15.0",
"version": "0.15.3",
"dependencies": {
"@getpaseo/plugin": "*",
"zod": "^4.4.3"
+8 -2
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.15.0",
"version": "0.15.3",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -149,6 +149,9 @@
"overrides": {
"@codemirror/language": "6.12.4",
"@codemirror/view": "6.43.6",
"@electron/get@^3.0.0": {
"semver@^6": "6.3.1"
},
"expo": "~57.0.30",
"expo-router": "~57.0.28",
"lightningcss": "1.30.1",
@@ -158,6 +161,9 @@
"react-native": "0.86.3",
"react-native-gesture-handler": "~2.32.0",
"react-native-reanimated": "4.5.5",
"react-native-worklets": "0.10.4"
"react-native-worklets": "0.10.4",
"tiny-async-pool": {
"semver@^5": "5.7.2"
}
}
}
@@ -235,6 +235,7 @@ async function beginTimelineRowStabilityCheck(
lastTop: initialRow.getBoundingClientRect().top,
largestDownwardShift: 0,
sawMissing: false,
sawHidden: false,
};
const windowState = window as unknown as Record<string, unknown>;
windowState.__messageSubmissionTimelineRowStability = state;
@@ -245,6 +246,9 @@ async function beginTimelineRowStabilityCheck(
requestAnimationFrame(checkFrame);
return;
}
if (!initialRow.checkVisibility({ opacityProperty: true, visibilityProperty: true })) {
state.sawHidden = true;
}
const top = initialRow.getBoundingClientRect().top;
state.largestDownwardShift = Math.max(state.largestDownwardShift, top - state.lastTop);
state.lastTop = top;
@@ -257,14 +261,24 @@ async function beginTimelineRowStabilityCheck(
const result = await page.evaluate(() => {
const windowState = window as unknown as Record<string, unknown>;
const state = windowState.__messageSubmissionTimelineRowStability as
| { active: boolean; largestDownwardShift: number; sawMissing: boolean }
| {
active: boolean;
largestDownwardShift: number;
sawMissing: boolean;
sawHidden: boolean;
}
| undefined;
if (!state) throw new Error("Timeline-row stability check was not started");
state.active = false;
delete windowState.__messageSubmissionTimelineRowStability;
return { largestDownwardShift: state.largestDownwardShift, sawMissing: state.sawMissing };
return {
largestDownwardShift: state.largestDownwardShift,
sawMissing: state.sawMissing,
sawHidden: state.sawHidden,
};
});
expect(result.sawMissing).toBe(false);
expect(result.sawHidden).toBe(false);
expect(result.largestDownwardShift).toBeLessThanOrEqual(2);
};
}
@@ -1178,7 +1192,13 @@ test.describe("Agent message submission", () => {
test.setTimeout(120_000);
const toasts = await recordPanelToasts(page);
const pending = await beginDraftCreateSubmission(page, draftCreateScenario);
// The created agent takes over the draft's timeline: the same row stays mounted and painted.
const finishTimelineRowStabilityCheck = await beginTimelineRowStabilityCheck(
page,
pending.userMessage,
);
await completeDraftCreateSubmission(page, draftCreateScenario, pending);
await finishTimelineRowStabilityCheck();
// A chat this client just created is current by construction; it is never out of date.
await toasts.expectNeverShown("agent-updating-toast");
});
@@ -18,8 +18,10 @@ test.describe("Agent timeline sync retry", () => {
const gate = await installDaemonWebSocketGate(page);
const agent = await seedLongMockAgentTimeline({ turns: 2 });
try {
gate.holdNextClientRequest("clear_agent_attention");
await openAgentTimeline(page, agent);
await expectTimelinePromptVisible(page, agent.newestPrompt);
await gate.waitForHeldClientRequest();
gate.failTimelineResponses(agent.agentId);
await disconnectViewedTimeline(page, gate);
@@ -0,0 +1,327 @@
import type { Page } from "@playwright/test";
import { expect, test as baseTest } from "../support/fixtures";
import { expectAgentIdle } from "../support/helpers/agent-stream";
import { submitMessage } from "../support/helpers/composer";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import {
openAgentRoute,
seedMockAgentWorkspace,
type MockAgentWorkspace,
} from "../support/helpers/mock-agent";
// A turn ends with its final reply, `turn_completed`, and the idle status snapshot arriving
// within a few milliseconds of each other. These tests hold all three, let the running
// turn paint, then release them together and sample every painted frame.
interface Box {
top: number;
height: number;
}
interface TurnEndFrame {
at: number;
running: boolean;
footer: Box | null;
userRow: Box | null;
replyVisible: boolean;
}
declare global {
interface Window {
__turnEndFrames?: { active: boolean; frames: TurnEndFrame[] };
}
}
const test = baseTest.extend<{ agent: MockAgentWorkspace }>({
agent: async ({ page: _page }, provide) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "turn-end-",
title: "Turn end",
initialPrompt: "emit 4 tool agent stream updates",
});
await agent.client.waitForFinish(agent.agentId, 15_000);
await provide(agent);
await agent.cleanup();
},
});
async function recordTurnEndFrames(
page: Page,
input: { prompt: string; reply: string | null },
): Promise<void> {
await page.evaluate(({ prompt, reply }) => {
const state = { active: true, frames: [] as TurnEndFrame[] };
const isVisible = (element: Element | null | undefined) => Boolean(element?.checkVisibility());
const boxOf = (element: Element | null | undefined): Box | null => {
if (!element || !isVisible(element)) return null;
const rect = element.getBoundingClientRect();
return rect.height > 0 ? { top: rect.top, height: rect.height } : null;
};
const sample = () => {
const viewport = Array.from(
document.querySelectorAll('[data-testid="agent-chat-scroll"]'),
).find((candidate) => isVisible(candidate));
const footers = Array.from(viewport?.querySelectorAll('[data-testid="turn-footer"]') ?? [])
.map(boxOf)
.filter((box): box is Box => box !== null)
.sort((left, right) => left.top - right.top);
const userRow = Array.from(
viewport?.querySelectorAll('[data-testid="user-message"]') ?? [],
).find((candidate) => candidate.textContent?.includes(prompt));
const replyRow =
reply === null
? undefined
: Array.from(viewport?.querySelectorAll('[data-testid="assistant-message"]') ?? []).find(
(candidate) => candidate.textContent?.includes(reply),
);
state.frames.push({
at: performance.now(),
running: Boolean(viewport?.querySelector('[data-testid="turn-working-indicator"]')),
footer: footers.at(-1) ?? null,
userRow: boxOf(userRow),
replyVisible: boxOf(replyRow) !== null,
});
if (state.active) requestAnimationFrame(() => setTimeout(sample, 0));
};
window.__turnEndFrames = state;
requestAnimationFrame(() => setTimeout(sample, 0));
}, input);
}
async function recordPaintsFor(page: Page, durationMs: number): Promise<void> {
const until = await page.evaluate((duration) => performance.now() + duration, durationMs);
await expect
.poll(() => page.evaluate(() => window.__turnEndFrames?.frames.at(-1)?.at ?? 0))
.toBeGreaterThanOrEqual(until);
}
async function stopRecording(page: Page): Promise<TurnEndFrame[]> {
return page.evaluate(() => {
const state = window.__turnEndFrames;
if (!state) throw new Error("Turn end frames were not recorded");
state.active = false;
return state.frames;
});
}
async function recordTurnEnd(
page: Page,
agent: MockAgentWorkspace,
input: { prompt: string; reply: string | null },
): Promise<TurnEndFrame[]> {
const gate = await installDaemonWebSocketGate(page);
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId });
await expectAgentIdle(page);
if (input.reply === null) {
gate.setAgentStreamItemSuppressed("assistant_message", true);
} else {
gate.holdNextAgentStreamItem("assistant_message");
}
gate.holdNextAgentStreamEvent("turn_completed");
// The daemon sends more than one idle snapshot; hold the first and drop the rest until release.
gate.holdNextAgentUpdate(agent.agentId, "idle");
gate.setServerMessageSuppressed("agent_update", true);
await recordTurnEndFrames(page, input);
await submitMessage(page, input.prompt);
await Promise.all([
input.reply === null ? Promise.resolve() : gate.waitForHeldAgentStreamItem("assistant_message"),
gate.waitForHeldAgentStreamEvent("turn_completed"),
gate.waitForHeldAgentUpdate(agent.agentId, "idle"),
]);
await expect(page.getByTestId("turn-working-indicator")).toBeVisible();
await recordPaintsFor(page, 200);
if (input.reply !== null) gate.releaseHeldAgentStreamItem("assistant_message");
gate.releaseHeldAgentStreamEvent("turn_completed");
gate.releaseHeldAgentUpdate(agent.agentId, "idle");
gate.setServerMessageSuppressed("agent_update", false);
await expectAgentIdle(page);
await recordPaintsFor(page, 300);
return stopRecording(page);
}
function describeFrames(frames: TurnEndFrame[]): string {
return frames
.map(
(frame, index) =>
`${index}: ${frame.running ? "running " : "complete"} footer=${
frame.footer ? `${frame.footer.top.toFixed(1)}+${frame.footer.height.toFixed(1)}` : "none"
} prompt=${frame.userRow?.top.toFixed(1) ?? "none"} reply=${frame.replyVisible}`,
)
.join("\n");
}
function expectSteadyTurnEnd(frames: TurnEndFrame[], input: { reply: string | null }): void {
const firstRunning = frames.findIndex((frame) => frame.running && frame.footer !== null);
const firstComplete = frames.findIndex((frame, index) => index > firstRunning && !frame.running);
const record = `frames:\n${describeFrames(frames)}`;
expect(firstRunning, record).toBeGreaterThanOrEqual(0);
expect(firstComplete, record).toBeGreaterThan(firstRunning);
const transition = frames.slice(firstRunning);
const runningHeight = transition[0].footer?.height;
expect(
transition.map((frame) => frame.footer?.height ?? null),
`the footer must stay painted at one height\n${record}`,
).toEqual(transition.map(() => runningHeight));
if (input.reply !== null) {
expect(
frames.slice(firstComplete).every((frame) => frame.replyVisible),
`a completed turn must show its reply in the same frame\n${record}`,
).toBe(true);
}
// Tool rows land while the turn runs; only the turn end itself must be one step.
const ending = frames.slice(firstComplete - 1);
const promptMoves = ending
.slice(1)
.filter((frame, index) => frame.userRow?.top !== ending[index].userRow?.top);
expect(
promptMoves.length,
`the prompt must move at most once as the turn ends\n${record}`,
).toBeLessThanOrEqual(1);
}
test.describe("Turn end", () => {
test("ends a plain reply without moving or dropping its footer", async ({ page, agent }) => {
const input = { prompt: "emit 1 coalesced agent stream updates", reply: "stress-update-0" };
expectSteadyTurnEnd(await recordTurnEnd(page, agent, input), input);
});
test("ends a tool turn with its reply and completed footer together", async ({ page, agent }) => {
const input = { prompt: "emit 3 tool agent stream updates", reply: "Completed 3 commands" };
expectSteadyTurnEnd(await recordTurnEnd(page, agent, input), input);
});
test("keeps the footer height when a turn ends on a tool call", async ({ page, agent }) => {
const input = { prompt: "emit 2 tool agent stream updates", reply: null };
expectSteadyTurnEnd(await recordTurnEnd(page, agent, input), input);
});
});
async function expectTimingInsideFooter(page: Page): Promise<void> {
const label = page.getByRole("button", { name: /^Worked for/ }).last();
const paintedLines = await label.getByTestId("turn-footer-timing-label").evaluate((element) => {
const labelBounds = element.parentElement!.getBoundingClientRect();
const rowBounds = element.closest('[data-testid="turn-footer"]')!.getBoundingClientRect();
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
const lines: boolean[] = [];
while (walker.nextNode()) {
const range = document.createRange();
range.selectNodeContents(walker.currentNode);
for (const rect of range.getClientRects()) {
lines.push(
rect.left >= Math.max(rowBounds.left, labelBounds.left) - 0.5 &&
rect.right <= Math.min(rowBounds.right, labelBounds.right) + 0.5 &&
rect.top >= Math.max(rowBounds.top, labelBounds.top) - 0.5 &&
rect.bottom <= Math.min(rowBounds.bottom, labelBounds.bottom) + 0.5,
);
}
}
return lines;
});
expect(paintedLines.length).toBeGreaterThan(0);
expect(paintedLines.every(Boolean)).toBe(true);
}
async function expectFooterWithinWidth(page: Page, width: number): Promise<void> {
const footer = page.getByTestId("turn-footer").last();
await footer.evaluate((element, availableWidth) => {
element.style.width = `${availableWidth}px`;
}, width);
const label = page.getByRole("button", { name: /^Worked for/ }).last();
await expect(label).toBeVisible();
await expectTimingInsideFooter(page);
const controls = footer.getByRole("button", { name: /Copy turn|Fork chat/ });
const before = await controls.evaluateAll((elements) =>
elements.map((element) => {
const rect = element.getBoundingClientRect();
return { x: rect.x, y: rect.y };
}),
);
const footerBefore = await footer.boundingBox();
const labelBefore = await label.boundingBox();
await label.hover({ position: { x: labelBefore!.width - 2, y: labelBefore!.height - 2 } });
await expect(label.getByTestId("turn-footer-timing-label")).toContainText(/\d:\d\d/);
expect(await label.boundingBox()).toMatchObject({
x: labelBefore!.x,
width: labelBefore!.width,
height: labelBefore!.height,
});
expect(
await controls.evaluateAll((elements) =>
elements.map((element) => {
const rect = element.getBoundingClientRect();
return { x: rect.x, y: rect.y };
}),
),
).toEqual(before);
expect(await footer.boundingBox()).toMatchObject({
x: footerBefore!.x,
y: footerBefore!.y,
width: footerBefore!.width,
});
await expectTimingInsideFooter(page);
await page.mouse.move(0, 0);
await expect(label.getByTestId("turn-footer-timing-label")).toContainText("Worked for");
}
test("footer timing fits the chat column and swapping labels keeps controls in place", async ({
page,
agent,
}) => {
await openAgentRoute(page, agent);
await expectFooterWithinWidth(page, 140);
await expectFooterWithinWidth(page, 150);
await expectFooterWithinWidth(page, 300);
await expectFooterWithinWidth(page, 880);
});
async function visibleFooterLeftEdges(page: Page): Promise<number[]> {
return page.evaluate(() => {
const viewport = Array.from(
document.querySelectorAll('[data-testid="agent-chat-scroll"]'),
).find((candidate) => candidate.checkVisibility());
return Array.from(viewport?.querySelectorAll('[data-testid="turn-footer"]') ?? [])
.filter((footer) => footer.checkVisibility())
.map((footer) => Math.round(footer.getBoundingClientRect().left));
});
}
async function expectFootersInOneColumn(page: Page): Promise<void> {
await expect
.poll(async () => {
const edges = await visibleFooterLeftEdges(page);
return edges.length >= 2 && new Set(edges).size === 1 ? "one column" : edges;
})
.toBe("one column");
}
test("keeps the latest turn's footer in the chat column while it runs and after it ends", async ({
page,
agent,
}) => {
// Wider than the chat's max width, so the column is centered with space on both sides.
await page.setViewportSize({ width: 1600, height: 1000 });
const gate = await installDaemonWebSocketGate(page);
await openAgentRoute(page, agent);
await expectAgentIdle(page);
gate.holdNextAgentStreamEvent("turn_completed");
gate.holdNextAgentUpdate(agent.agentId, "idle");
gate.setServerMessageSuppressed("agent_update", true);
await submitMessage(page, "emit 1 coalesced agent stream updates");
await Promise.all([
gate.waitForHeldAgentStreamEvent("turn_completed"),
gate.waitForHeldAgentUpdate(agent.agentId, "idle"),
]);
await expect(page.getByTestId("turn-working-indicator")).toBeVisible();
await expectFootersInOneColumn(page);
gate.releaseHeldAgentStreamEvent("turn_completed");
gate.releaseHeldAgentUpdate(agent.agentId, "idle");
gate.setServerMessageSuppressed("agent_update", false);
await expectAgentIdle(page);
await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0);
await expectFootersInOneColumn(page);
});
@@ -6,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,
@@ -723,3 +723,46 @@ test("copying a selection across an assistant image keeps its Markdown source an
await agent.cleanup();
}
});
test.describe("on a touch-capable desktop browser", () => {
// A touch screen registers the navigation pan's pointer listeners over the whole workspace.
// The pan accepts touch only, and a mouse drag must still extend the browser's selection.
test.use({ hasTouch: true });
test("a mouse drag selects across assistant paragraphs", async ({ page }) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "assistant-selection-drag-",
title: "Assistant selection drag",
initialPrompt: "Render three paragraphs.",
featureValues: {
mockAssistantResponse: "First paragraph.\n\nSecond paragraph.\n\nThird paragraph.",
},
});
try {
await agent.client.waitForAgentUpsert(
agent.agentId,
(snapshot) => snapshot.status === "idle",
30_000,
);
await openAgentRoute(page, agent);
const blocks = assistantMessageBlocks(page);
const first = await blocks.getByText("First paragraph.").boundingBox();
const third = await blocks.getByText("Third paragraph.").boundingBox();
if (!first || !third) {
throw new Error("Expected rendered paragraphs");
}
await page.mouse.move(first.x + 1, first.y + first.height / 2);
await page.mouse.down();
await page.mouse.move(third.x + third.width - 1, third.y + third.height / 2, { steps: 20 });
await page.mouse.up();
const selected = await page.evaluate(() => window.getSelection()?.toString() ?? "");
expect(selected).toContain("First paragraph.");
expect(selected).toContain("Third paragraph.");
} finally {
await agent.cleanup();
}
});
});
@@ -1,4 +1,7 @@
import { writeFile, unlink } from "node:fs/promises";
import path from "node:path";
import { expect, test, type Page } from "../support/fixtures";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import { gotoAppShell } from "../support/helpers/app";
import {
expectNoBranchSwitcherInWorkspaceHeader,
@@ -14,6 +17,95 @@ import {
waitForSidebarHydration,
} from "../support/helpers/workspace-ui";
// Hold the real daemon response until the test has observed the pending UI.
async function holdBranchSuggestions(page: Page) {
let requestCount = 0;
let release!: () => void;
let gate = new Promise<void>((resolve) => {
release = resolve;
});
await page.routeWebSocket(daemonWsRoutePattern(), (browser) => {
const server = browser.connectToServer();
browser.onMessage((message) => {
if (typeof message === "string") {
const envelope = JSON.parse(message);
if (envelope.type === "session" && envelope.message?.type === "branch_suggestions_request")
requestCount++;
}
server.send(message);
});
server.onMessage(async (message) => {
if (typeof message === "string") {
const envelope = JSON.parse(message);
if (
envelope.type === "session" &&
envelope.message?.type === "branch_suggestions_response"
) {
await gate;
}
}
browser.send(message);
});
});
return {
release: () => release(),
holdNextResponse: () => {
gate = new Promise<void>((resolve) => {
release = resolve;
});
},
requestCount: () => requestCount,
};
}
async function expectInitialBranchLoading(page: Page, release: () => void): Promise<void> {
await page.getByRole("button", { name: "Current branch: main. Press to switch branch." }).click();
const picker = page.getByTestId("combobox-desktop-container");
await expect(picker.getByText("Loading...", { exact: true })).toBeVisible();
await expect(picker.getByText("No branches found.", { exact: true })).toHaveCount(0);
release();
await expect(picker.getByText("dev", { exact: true })).toBeVisible();
await page.keyboard.press("Escape");
await expect(picker).not.toBeVisible();
}
async function expectFailedRefreshRecovery(
page: Page,
repoPath: string,
responses: Awaited<ReturnType<typeof holdBranchSuggestions>>,
) {
// Expire the actual query freshness window before reopening the cached picker.
const started = Date.now();
await expect.poll(() => Date.now() - started, { timeout: 20_000 }).toBeGreaterThan(15_000);
const picker = page.getByTestId("combobox-desktop-container");
const packedRefs = path.join(repoPath, ".git", "packed-refs");
await writeFile(packedRefs, "invalid packed refs\n");
try {
await page
.getByRole("button", { name: "Current branch: dev. Press to switch branch." })
.click();
await expect(picker.getByRole("alert")).toHaveText(
"Could not load branches. Reopen the picker to try again.",
);
} finally {
await unlink(packedRefs);
}
await expect(picker.getByText("No branches found.", { exact: true })).toHaveCount(0);
responses.holdNextResponse();
const beforeRetry = responses.requestCount();
await page.keyboard.press("Escape");
await page.getByRole("button", { name: "Current branch: dev. Press to switch branch." }).click();
await expect(picker.getByRole("alert")).toHaveText("Loading...");
await expect.poll(responses.requestCount).toBe(beforeRetry + 1);
await expect(picker).toBeVisible();
responses.release();
await expect(picker.getByRole("alert")).toHaveCount(0);
await expect(picker.getByText("main", { exact: true })).toBeVisible();
await page.keyboard.press("Escape");
await expectWorkspaceBranch(page, "dev");
expect(responses.requestCount()).toBe(beforeRetry + 1);
}
async function renameWorkspaceViaSidebar(
page: Page,
input: { workspaceId: string; title: string },
@@ -59,6 +151,7 @@ test.describe("Branch switcher", () => {
try {
expect(workspace.workspaceName).toBe("main");
const releaseBranches = await holdBranchSuggestions(page);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: workspace.workspaceId });
@@ -82,8 +175,10 @@ test.describe("Branch switcher", () => {
// and switching it checks out the real branch on disk.
await openChangesPanel(page);
await expectWorkspaceBranch(page, "main");
await expectInitialBranchLoading(page, releaseBranches.release);
await switchBranchFromChangesPanel(page, { from: "main", to: "dev" });
await expectWorkspaceBranch(page, "dev");
await expectFailedRefreshRecovery(page, workspace.repoPath, releaseBranches);
// The custom title is unaffected by the branch switch.
await expect(headerTitle).toHaveText(customTitle, { timeout: 30_000 });
+297 -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();
@@ -323,6 +447,119 @@ test("All changes combines committed and pending edits and preserves an unfinish
});
});
test("Changes selects exact local and remote bases and retains the selection after reload", async ({
page,
}, testInfo) => {
const workspace = await createWorktreeWithCommittedDiff();
execFileSync("git", ["branch", "release"], { cwd: workspace.repoPath });
execFileSync("git", ["update-ref", "refs/remotes/upstream/release", "main"], {
cwd: workspace.repoPath,
});
const head = execFileSync("git", ["rev-parse", "HEAD"], { cwd: workspace.repoPath }).toString();
await openWorkspaceChangesSurface(page, workspace);
const tree = page.getByTestId("changes-tree-panel").filter({ visible: true });
const picker = tree.getByTestId("workspace-base-branch-trigger");
await expect(picker).toBeVisible();
await picker.click();
await expect(page.getByRole("button", { name: "release", exact: true })).toBeVisible();
await page.screenshot({ path: testInfo.outputPath("base-picker.png") });
await page.getByRole("button", { name: "release", exact: true }).click();
await expect(picker).toContainText("release");
await expect(tree.getByText("No changes to display", { exact: true })).toBeVisible();
await picker.click();
await page.getByRole("button", { name: "upstream/release", exact: true }).click();
await expect(picker).toContainText("upstream/release");
await expect(tree.getByText("committed-only.ts", { exact: true })).toBeVisible();
await page.screenshot({ path: testInfo.outputPath("base-selected.png") });
await page.reload();
await waitForWorkspaceTabsVisible(page);
await expect(picker).toContainText("upstream/release");
expect(execFileSync("git", ["rev-parse", "HEAD"], { cwd: workspace.repoPath }).toString()).toBe(
head,
);
});
test("Changes lets a compact workspace change its base in a sheet", async ({ page }, testInfo) => {
const workspace = await createWorktreeWithCommittedDiff();
execFileSync("git", ["branch", "release"], { cwd: workspace.repoPath });
await openCompactChanges(page, workspace);
const picker = page.getByRole("button", { name: "Change base" }).filter({ visible: true });
await picker.click();
await expect(page.getByText("Change base", { exact: true })).toBeVisible();
const release = page.getByRole("button", { name: "release", exact: true });
await expect(release).toBeVisible();
await page.screenshot({ path: testInfo.outputPath("compact-base-picker.png") });
await release.click();
await expect(picker).toContainText("release");
await page.screenshot({ path: testInfo.outputPath("compact-base-selected.png") });
});
test("Changes shows a failed base selection and lets the user retry", async ({ page }) => {
const workspace = await createWorktreeWithCommittedDiff();
execFileSync("git", ["branch", "release"], { cwd: workspace.repoPath });
await openWorkspaceChangesSurface(page, workspace);
const picker = page
.getByTestId("workspace-base-branch-trigger")
.filter({ visible: true })
.first();
await picker.click();
const option = page.getByRole("button", { name: "release", exact: true });
await expect(option).toBeVisible();
execFileSync("git", ["branch", "-D", "release"], { cwd: workspace.repoPath });
await option.click();
await expect(page.getByTestId("workspace-base-branch-error")).toContainText(
"Base branch not found",
);
await expect(picker).toContainText("main");
execFileSync("git", ["branch", "release"], { cwd: workspace.repoPath });
await option.click();
await expect(page.getByTestId("workspace-base-branch-error")).toHaveCount(0);
await expect(picker).toContainText("release");
});
test("Changes shows the base as text on a host without the base-branch capability", async ({
page,
}) => {
let baseRequests = 0;
await page.routeWebSocket(daemonWsRoutePattern(), (browser) => {
const server = browser.connectToServer();
browser.onMessage((frame) => {
if (typeof frame === "string") {
const envelope = JSON.parse(frame) as { message?: { type?: string } };
if (envelope.message?.type === "workspace.base_branch.set.request") baseRequests += 1;
}
server.send(frame);
});
server.onMessage((frame) => {
if (typeof frame === "string") {
const envelope = JSON.parse(frame) as {
message?: { payload?: { status?: string; features?: Record<string, unknown> } };
};
const payload = envelope.message?.payload;
if (payload?.status === "server_info" && payload.features) {
delete payload.features.workspaceBaseBranch;
browser.send(JSON.stringify(envelope));
return;
}
}
browser.send(frame);
});
});
const workspace = await createWorktreeWithCommittedDiff();
await openWorkspaceChangesSurface(page, workspace);
await expect(page.getByTestId("workspace-base-branch-trigger")).toHaveCount(0);
expect(baseRequests).toBe(0);
});
test("Changes shows no base on a checkout that is not a Paseo worktree", async ({ page }) => {
const workspace = await createWorkspaceWithCommittedDiff();
await openWorkspaceChangesSurface(page, workspace);
const header = page.getByTestId("changes-repository-header").filter({ visible: true });
await expect(header).toContainText("feature");
await expect(header).not.toContainText("main");
await expect(page.getByTestId("workspace-base-branch-trigger")).toHaveCount(0);
});
test("Changes keeps a manual comparison when checkout dirtiness changes", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await openWorkspaceChanges(page, workspace);
@@ -386,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,
}) => {
@@ -1198,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);
@@ -1657,6 +1925,28 @@ async function expectRenderedAddedLine(page: Page, contents: string): Promise<vo
}).toPass({ timeout: 15_000 });
}
async function createWorktreeWithCommittedDiff(): Promise<DirtyWorkspace> {
const repo = await createTempGitRepo("changes-base-worktree-", {
files: [{ path: "tracked.ts", content: "export const tracked = 1;\n" }],
});
const client = await connectSeedClient();
cleanupTasks.push({
run: async () => {
await client.close();
await repo.cleanup();
},
});
const created = await client.createWorkspace({
source: { kind: "worktree", cwd: repo.path, worktreeSlug: "base-picker", baseBranch: "main" },
});
if (!created.workspace) throw new Error(created.error ?? "Failed to create worktree");
const cwd = created.workspace.workspaceDirectory;
await writeFile(path.join(cwd, "committed-only.ts"), "export const committed = true;\n");
execFileSync("git", ["add", "committed-only.ts"], { cwd });
execFileSync("git", ["commit", "-m", "Add committed-only file"], { cwd });
return { id: created.workspace.id, repoPath: cwd };
}
async function createWorkspaceWithCommittedDiff(): Promise<DirtyWorkspace> {
const repo = await createTempGitRepo("changes-committed-", {
files: [{ path: "tracked.ts", content: "export const tracked = 1;\n" }],
@@ -1759,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`),
@@ -5,8 +5,10 @@ import {
holdDirectorySuggestionResponses,
expectOneLineTruncatedFileResult,
expectStableCommandCenterLayout,
expectLayoutObservationToCatchPanelShift,
failDirectorySuggestionRequests,
startCommandCenterLayoutObservation,
shiftOutsideCommandCenter,
} from "../support/helpers/command-center-file-search";
import {
scrollCommandCenterAwayFromSelection,
@@ -84,7 +86,9 @@ test("workspace file search stays geometrically stable through delayed loading a
await expect(panel.getByTestId("command-center-file-search-loading")).toBeVisible();
await expect(panel.getByText("Searching files...", { exact: true })).toHaveCount(0);
await expect(panel.getByTestId("command-center-file-search-loading")).toBeHidden();
await shiftOutsideCommandCenter(page);
await expectStableCommandCenterLayout(page);
await expectLayoutObservationToCatchPanelShift(page);
await row.click();
await expectFileTabOpen(page, FILE_PATH);
@@ -105,7 +105,21 @@ interface PopoverFrameRecorderWindow extends Window {
__stopComposerAutocompleteFrameRecorder?: () => void;
}
async function installListCommandsStub(page: Page): Promise<void> {
interface ListCommandsReply {
commands: readonly { name: string; description: string; argumentHint: string }[];
error: string | null;
}
const TEST_COMMANDS_REPLY: ListCommandsReply = { commands: TEST_COMMANDS, error: null };
/**
* Rewrites every `list_commands_response` with `reply()`. A reply that returns a promise holds
* the response until it settles, standing in for a slow host.
*/
async function installListCommandsStub(
page: Page,
reply: () => ListCommandsReply | Promise<ListCommandsReply> = () => TEST_COMMANDS_REPLY,
): Promise<void> {
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
@@ -130,14 +144,20 @@ async function installListCommandsStub(page: Page): Promise<void> {
};
};
};
const payload = parsed.message?.payload;
if (
parsed.type === "session" &&
parsed.message?.type === "list_commands_response" &&
parsed.message.payload
payload
) {
parsed.message.payload.commands = TEST_COMMANDS;
parsed.message.payload.error = null;
ws.send(JSON.stringify(parsed));
const send = (result: ListCommandsReply) => {
payload.commands = result.commands;
payload.error = result.error;
ws.send(JSON.stringify(parsed));
};
const result = reply();
if (result instanceof Promise) void result.then(send);
else send(result);
return;
}
} catch {
@@ -149,6 +169,17 @@ async function installListCommandsStub(page: Page): Promise<void> {
});
}
function createHeldReply(): {
reply: () => Promise<ListCommandsReply>;
release: () => void;
} {
let release = () => {};
const held = new Promise<ListCommandsReply>((resolve) => {
release = () => resolve(TEST_COMMANDS_REPLY);
});
return { reply: () => held, release };
}
async function openAppWideNewWorkspace(page: Page): Promise<void> {
await page.getByTestId("sidebar-global-new-workspace").first().click();
await page.waitForURL((url) => url.pathname === "/new", { timeout: 30_000 });
@@ -545,16 +576,17 @@ test.describe("Composer autocomplete", () => {
const input = composerLocator(page);
await expect(input).toBeEditable({ timeout: 30_000 });
await input.fill("/he");
// `/hea` lists two commands and `/he` enough to reach the height cap, so the list grows.
await input.fill("/hea");
const popover = page.getByTestId("composer-autocomplete-popover");
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible({
await expect(popover.getByText("/heapdump", { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
const beforeDelete = await visiblePopoverBox(page);
await input.press("Backspace");
await expect(input).toHaveValue("/h");
await expect(popover.getByText("/history", { exact: true }).first()).toBeVisible({
await expect(input).toHaveValue("/he");
await expect(popover.getByText("/hello", { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
const afterDelete = await visiblePopoverBox(page);
@@ -733,6 +765,116 @@ test.describe("Composer autocomplete", () => {
}
});
test("forgets an arrow-key choice once the search changes", async ({ page }) => {
await installListCommandsStub(page);
const session = await seedMockAgentWorkspace({
repoPrefix: "autocomplete-stale-choice-",
title: "Autocomplete stale choice",
});
try {
await openAgentRoute(page, session);
await expectWorkspaceTabVisible(page, session.agentId);
const input = composerLocator(page);
await expect(input).toBeEditable({ timeout: 30_000 });
await input.fill("/");
const popover = page.getByTestId("composer-autocomplete-popover");
await expect(popover.getByText("/tdd", { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
// Down wraps from the default bottom row to the top: /clear, then /exit.
await input.press("ArrowDown");
await input.press("ArrowDown");
await input.press("h");
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible();
await input.press("Backspace");
await expect(input).toHaveValue("/");
await input.press("Enter");
await expect(input).toHaveValue("/tdd ");
await expectWorkspaceTabVisible(page, session.agentId);
} finally {
await session.cleanup();
}
});
test("shows loading inside the popover until a slow host answers", async ({ page }) => {
const host = createHeldReply();
await installListCommandsStub(page, host.reply);
const agent = await openReadyMockAgent(page);
try {
const input = composerLocator(page);
await expect(input).toBeEditable({ timeout: 30_000 });
await input.fill("/");
const popover = page.getByTestId("composer-autocomplete-popover");
await expect(popover.getByTestId("composer-autocomplete-loading")).toBeVisible({
timeout: 30_000,
});
const loading = await visiblePopoverBox(page);
host.release();
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
await expect(popover.getByTestId("composer-autocomplete-loading")).toHaveCount(0);
const results = await visiblePopoverBox(page);
expect(Math.abs(results.bottom - loading.bottom)).toBeLessThanOrEqual(4);
} finally {
await agent.cleanup();
}
});
test("shows why commands failed, keeps Enter from choosing a hidden command, and retries", async ({
page,
}) => {
let reply: () => ListCommandsReply | Promise<ListCommandsReply> = () => ({
commands: [],
error: "Timed out waiting for the agent session",
});
await installListCommandsStub(page, () => reply());
const session = await seedMockAgentWorkspace({
repoPrefix: "autocomplete-failing-host-",
title: "Autocomplete failing host",
});
try {
await openAgentRoute(page, session);
await expectWorkspaceTabVisible(page, session.agentId);
const input = composerLocator(page);
await expect(input).toBeEditable({ timeout: 30_000 });
await input.fill("/");
const popover = page.getByTestId("composer-autocomplete-popover");
const errorRow = popover.getByTestId("composer-autocomplete-error");
await expect(errorRow).toContainText("Timed out waiting for the agent session", {
timeout: 30_000,
});
await input.press("Enter");
await page.waitForTimeout(500);
await expectWorkspaceTabVisible(page, session.agentId);
await input.fill("/");
await expect(errorRow).toBeVisible({ timeout: 30_000 });
const host = createHeldReply();
reply = host.reply;
await errorRow.getByRole("button", { name: "Retry" }).click();
await expect(popover.getByTestId("composer-autocomplete-loading")).toBeVisible();
host.release();
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
} finally {
await session.cleanup();
}
});
test.describe("compact sidebar layering", () => {
test.use({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true });
@@ -0,0 +1,53 @@
import type { Locator } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { openNewWorkspaceComposer } from "../support/helpers/new-workspace";
import { seedWorkspace } from "../support/helpers/seed-client";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
async function box(locator: Locator) {
await expect(locator).toBeVisible({ timeout: 30_000 });
const rect = await locator.boundingBox();
expect(rect).not.toBeNull();
return { top: rect!.y, bottom: rect!.y + rect!.height };
}
test.describe("Composer bottom spacing", () => {
test("the chat composer rests one content spacing above the window edge", async ({ page }) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "composer-bottom-spacing-",
title: "Bottom spacing",
});
try {
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId });
const surface = await box(
page.getByTestId("message-input-root").filter({ visible: true }).first(),
);
const viewportHeight = await page.evaluate(() => window.innerHeight);
expect(viewportHeight - surface.bottom).toBe(16);
} finally {
await agent.cleanup();
}
});
test("the wide new workspace form keeps one content spacing above Import session", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "composer-bottom-spacing-form-" });
try {
await page.setViewportSize({ width: 1440, height: 900 });
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openNewWorkspaceComposer(page, {
projectKey: workspace.projectKey,
projectDisplayName: workspace.projectDisplayName,
});
const composer = await box(page.locator('[data-testid="message-input-root"]:visible'));
const importButton = await box(page.getByTestId("new-workspace-import-session"));
expect(importButton.top - composer.bottom).toBe(16);
} finally {
await workspace.cleanup();
}
});
});
@@ -1,8 +1,14 @@
import { test, expect } from "../support/fixtures";
import { openWorkspaceWithAgents } from "../support/helpers/archive-tab";
import { fillComposerDraft } from "../support/helpers/composer";
import {
expectComposerToolbarOnTextRails,
expectNoCollapsedComposerToolbarFrame,
expectNoFrameTruncatesModelBesideThinking,
readSettledModelTriggerLabels,
recordComposerToolbarFrames,
recordModelTriggerFramesFromLoad,
sweepModelTriggerLabels,
} from "../support/helpers/composer-control-density";
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
@@ -124,3 +130,128 @@ test.describe("Composer controls folding on a touch device", () => {
}
});
});
test.describe("Folded model control on a phone", () => {
// Wide enough for "Ten second stream" and "Low" side by side; narrower, they no longer fit.
test.use({ viewport: { width: 480, height: 844 }, isMobile: true, hasTouch: true });
test.describe.configure({ timeout: 180_000 });
test("hides the thinking label before the model label truncates", async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "composer-density-labels-",
title: "Label priority chat",
model: "ten-second-stream",
});
try {
await recordModelTriggerFramesFromLoad(page);
await openAgentRoute(page, session);
await expect(
page.getByTestId("combined-model-selector-thinking-label").filter({ visible: true }),
).toHaveText("Low");
await readSettledModelTriggerLabels(page);
// From the first frame on, the control never guesses: it paints both labels only once it
// has measured that they fit.
await expectNoFrameTruncatesModelBesideThinking(page);
const sweep = await sweepModelTriggerLabels(page, {
from: 480,
to: 280,
step: 4,
height: 844,
});
expect(sweep.widest).toMatchObject({
modelText: "Ten second stream",
thinkingShown: true,
modelTruncated: false,
});
expect(sweep.thinkingHidden).toMatchObject({
modelText: "Ten second stream",
modelTruncated: false,
});
expect(sweep.modelTruncated.width).toBeLessThan(sweep.thinkingHidden.width);
// A clean tail: the label ends on a whole word and the ellipsis, never on a space.
expect(sweep.modelTruncated).toMatchObject({
modelText: "Ten second…",
thinkingShown: false,
});
} finally {
await session.cleanup();
}
});
});
test.describe("Composer toolbar rails on touch", () => {
test.use({ isMobile: true, hasTouch: true });
test.describe.configure({ timeout: 180_000 });
for (const viewport of [
{ device: "phone", width: 390, height: 844 },
{ device: "tablet", width: 1024, height: 1366 },
]) {
test(`the toolbar starts on the text rail and ends in a disc nested in the corner on a ${viewport.device}`, async ({
page,
}) => {
await page.setViewportSize({ width: viewport.width, height: viewport.height });
const idle = await seedMockAgentWorkspace({
repoPrefix: "composer-rails-idle-",
title: "Rails idle chat",
});
let running: Awaited<ReturnType<typeof seedMockAgentWorkspace>> | null = null;
try {
await openAgentRoute(page, idle);
await expect(page.getByTestId("message-input-root").filter({ visible: true })).toBeVisible({
timeout: 30_000,
});
await test.step("empty composer: attach on the left, voice mode on the right", async () => {
await expectComposerToolbarOnTextRails(page);
});
await test.step("draft: the send disc on the right", async () => {
await fillComposerDraft(page, "Refactor the composer toolbar");
await expect(page.getByRole("button", { name: /send/i }).first()).toBeVisible();
await expectComposerToolbarOnTextRails(page);
});
running = await seedMockAgentWorkspace({
repoPrefix: "composer-rails-running-",
title: "Rails running chat",
model: "one-minute-stream",
initialPrompt: "stream for a while",
});
await openAgentRoute(page, running);
await test.step("running agent: the stop disc on the right", async () => {
await expect(page.getByRole("button", { name: /stop agent/i }).first()).toBeVisible({
timeout: 30_000,
});
await expectComposerToolbarOnTextRails(page);
});
} finally {
await idle.cleanup();
await running?.cleanup();
}
});
}
});
test.describe("Composer trailing disc on a new chat", () => {
test.use({ isMobile: true, hasTouch: true, viewport: { width: 1024, height: 1366 } });
test("an empty draft holds a disabled send disc until there is something to send", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "composer-placeholder-send-" });
try {
await gotoWorkspace(page, workspace.workspaceId);
await clickNewChat(page);
const send = page
.getByTestId("message-input-root")
.filter({ visible: true })
.getByRole("button", { name: "Send message", exact: true });
await expect(send).toBeDisabled({ timeout: 30_000 });
await fillComposerDraft(page, "Refactor the composer toolbar");
await expect(send).toBeEnabled();
} finally {
await workspace.cleanup();
}
});
});
@@ -1,7 +1,9 @@
import type { Locator } from "@playwright/test";
import type { CDPSession, Locator } from "@playwright/test";
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, test, type Page } from "../support/fixtures";
import { seedSubagent } from "../support/helpers/subagents";
import { submitMessage } from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { gateNextAgentMessage } from "../support/helpers/agent-message-gate";
import {
@@ -78,7 +80,8 @@ test("composer diff stat reveals Changes, then opens the diff in the configured
const workspace = await seedChangedAgent("composer-diff-stat-side-");
try {
await page.setViewportSize({ width: 1400, height: 900 });
// Keep both workspace rails visible alongside app navigation and Explorer.
await page.setViewportSize({ width: 1900, height: 900 });
await openAgentRoute(page, {
workspaceId: workspace.workspaceId,
agentId: workspace.agentId,
@@ -859,3 +862,91 @@ async function expectReviewEditorBody(page: Page, body: string) {
body,
);
}
async function swipeComposerTracks(page: Page, client: CDPSession, direction: "left" | "right") {
const track = page.getByTestId("composer-track-scroll");
const box = await track.boundingBox();
if (!box) throw new Error("Composer tracks are not visible");
const start = direction === "left" ? box.x + box.width - 5 : box.x + 5;
const end = direction === "left" ? box.x + 5 : box.x + box.width - 5;
await swipeTouch(client, start, end, box.y + box.height / 2);
}
async function swipeTouch(client: CDPSession, start: number, end: number, y: number) {
await client.send("Input.dispatchTouchEvent", {
type: "touchStart",
touchPoints: [{ x: start, y }],
});
for (let step = 1; step <= 8; step++) {
await client.send("Input.dispatchTouchEvent", {
type: "touchMove",
touchPoints: [{ x: start + ((end - start) * step) / 8, y }],
});
}
await client.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
}
async function trackScrollOffset(track: Locator): Promise<number> {
return track.evaluate((node) => node.scrollLeft);
}
test.describe("touch composer tracks", () => {
test.use({ hasTouch: true, isMobile: true });
test("compact composer scrolls its task, subagent and changes tracks without squeezing them", async ({
page,
}) => {
const workspace = await seedChangedAgent("composer-tracks-overflow-");
const touch = await page.context().newCDPSession(page);
try {
await seedSubagent(
{ client: workspace.client, repoPath: workspace.cwd, workspaceId: workspace.workspaceId },
{ parentId: workspace.agentId, title: "Track child" },
);
await page.setViewportSize({ width: 1000, height: 844 });
await openAgentRoute(page, {
workspaceId: workspace.workspaceId,
agentId: workspace.agentId,
});
await submitMessage(page, "emit 1 agent stream updates");
const tasks = page.getByTestId("agent-task-list-header");
const subagents = page.getByTestId("subagents-track-header");
const changes = page.getByTestId("composer-diff-stat-pill");
await expect(tasks).toBeVisible();
await expect(subagents).toBeVisible();
await expect(changes).toBeVisible();
const wideWidths = await Promise.all(
[tasks, subagents, changes].map(async (pill) => (await pill.boundingBox())!.width),
);
await page.setViewportSize({ width: 200, height: 844 });
const narrowWidths = await Promise.all(
[tasks, subagents, changes].map(async (pill) => (await pill.boundingBox())!.width),
);
for (const [index, width] of wideWidths.entries())
expect(narrowWidths[index]).toBeGreaterThanOrEqual(width - 1);
const track = page.getByTestId("composer-track-scroll");
expect(await track.evaluate((node) => node.scrollWidth)).toBeGreaterThan(
await track.evaluate((node) => node.clientWidth),
);
await swipeComposerTracks(page, touch, "left");
await expect.poll(() => trackScrollOffset(track)).toBeGreaterThan(0);
await swipeComposerTracks(page, touch, "right");
await expect.poll(() => trackScrollOffset(track)).toBeLessThanOrEqual(1);
await swipeComposerTracks(page, touch, "left");
await expect.poll(() => trackScrollOffset(track)).toBeGreaterThan(0);
await expect(page.getByTestId("sidebar-sessions")).not.toBeInViewport();
await expect(page.getByTestId("workspace-explorer-sidebar")).not.toBeInViewport();
await page.getByRole("button", { name: "Open menu" }).click();
await expect(page.getByTestId("sidebar-sessions")).toBeInViewport({ ratio: 1 });
await page.getByTestId("sidebar-close").click();
await expect(page.getByTestId("sidebar-sessions")).not.toBeInViewport();
await expect(changes).toBeInViewport();
await changes.click();
await expect(
page.getByTestId("changes-header").filter({ visible: true }).first(),
).toBeVisible();
} finally {
await touch.detach();
await workspace.cleanup();
}
});
});
@@ -1,5 +1,11 @@
import { expect, test, type Page } from "../support/fixtures";
import {
pasteRichTextIntoComposer,
fillComposerDraft,
copyLatestTurnIntoComposer,
selectComposerDraftRange,
undoComposerEdit,
redoComposerEdit,
blurComposerInput,
roundTripComposerTabBeforePaint,
composerLocator,
@@ -13,6 +19,7 @@ import {
expectWorkspaceHeaderVisible,
resizeComposerViewport,
submitMessage,
submitMessageWithButton,
typeIntoFocusedComposer,
} from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
@@ -25,9 +32,10 @@ import {
expectMobileAgentSidebarVisible,
expectMobileExplorerHidden,
expectMobileExplorerVisible,
swipeOpenMobileAgentSidebar,
openMobileAgentSidebarWithKeyboard,
swipeOpenMobileExplorer,
} from "../support/helpers/sidebar";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import { readScrollMetrics } from "../support/helpers/agent-bottom-anchor";
import {
expectTimelinePromptPositionPreserved,
@@ -55,7 +63,9 @@ test("clicking chat text or background focuses the feed for keyboard paging", as
await expect
.poll(async () => (await readScrollMetrics(page)).offsetY)
.toBeLessThan(beforePaging.offsetY - beforePaging.viewportHeight / 2);
await page.screenshot({ path: testInfo.outputPath("chat-keyboard-paging.png") });
await page.screenshot({
path: testInfo.outputPath("chat-keyboard-paging.png"),
});
await page.keyboard.press("PageDown");
await expect
.poll(async () => (await readScrollMetrics(page)).distanceFromBottom)
@@ -124,20 +134,86 @@ test("focusing the feed preserves assistant text selection and code copy control
});
test("submitting a message leaves the composer ready for the next message", async ({ page }) => {
const copiedTable =
"| Resource | Status |\n| --- | --- |\n| [Unicode](https://example.com/日本語) | ~~closed~~ |\n| [Space](<https://example.com/a b>) | **open** |\n| [ASCII](https://example.com/1) | closed |";
const agent = await seedMockAgentWorkspace({
repoPrefix: "composer-focus-",
title: "Composer focus",
featureValues: { mockAssistantResponse: copiedTable },
});
const connection = await installDaemonWebSocketGate(page);
try {
await openAgentRoute(page, agent);
await expectComposerVisible(page);
await submitMessageWithButton(page, "Pointer submission");
await expectComposerDraft(page, "");
await expectComposerFocused(page);
await typeIntoFocusedComposer(page, "Ready after Send");
await expectComposerDraft(page, "Ready after Send");
await agent.client.waitForFinish(agent.agentId, 15_000);
await expectComposerFocused(page);
await submitMessage(page, "First message");
await expectComposerFocused(page);
await typeIntoFocusedComposer(page, "Second message");
await expectComposerDraft(page, "Second message");
await typeIntoFocusedComposer(page, "Investigate: replace this. Thanks");
await selectComposerDraftRange(page, 13, 25);
await pasteRichTextIntoComposer({
page,
html: '<a href="https://example.com/issues/39150">this FR</a>',
text: "this FR",
});
await expectComposerDraft(
page,
"Investigate: [this FR](https://example.com/issues/39150). Thanks",
);
await undoComposerEdit(page);
await expectComposerDraft(page, "Investigate: replace this. Thanks");
await redoComposerEdit(page);
await expectComposerDraft(
page,
"Investigate: [this FR](https://example.com/issues/39150). Thanks",
);
await typeIntoFocusedComposer(page, " next");
await expectComposerDraft(
page,
"Investigate: [this FR](https://example.com/issues/39150) next. Thanks",
);
await agent.client.waitForFinish(agent.agentId, 15_000);
await expect(page.getByRole("button", { name: "Copy turn", exact: true }).last()).toBeVisible();
await connection.drop();
await connection.waitForBlockedConnection();
await pasteRichTextIntoComposer({
page,
html: '<a href="https://example.com/offline">offline link</a>',
text: "offline link",
});
await expectComposerDraft(
page,
"Investigate: [this FR](https://example.com/issues/39150) next[offline link](https://example.com/offline). Thanks",
);
await fillComposerDraft(page, "");
await pasteRichTextIntoComposer({
page,
html: '<table><thead><tr><th>Resource</th><th>Status</th></tr></thead><tbody><tr><td><a href="https://example.com/issue">Issue</a></td><td>closed</td></tr></tbody></table>',
text: "Resource\tStatus\nIssue\tclosed",
});
await expectComposerDraft(
page,
"| Resource | Status |\n| --- | --- |\n| [Issue](https://example.com/issue) | closed |",
);
await fillComposerDraft(page, "");
await pasteRichTextIntoComposer({
page,
html: '<a href="https://example.com/">Issue</a><table></table>',
text: "Issue",
});
await expectComposerDraft(page, "[Issue](https://example.com/)");
await copyLatestTurnIntoComposer(page);
await expectComposerDraft(page, copiedTable);
await expectComposerFocused(page);
} finally {
await agent.cleanup();
}
@@ -168,7 +244,11 @@ async function waitForAnimationFrames(page: Page, count: number): Promise<void>
test.describe("touch screen wide enough for the desktop layout", () => {
// A tablet or an unfolded foldable gets the desktop layout, but focusing the
// composer there raises the on-screen keyboard over the conversation.
test.use({ viewport: { width: 1024, height: 768 }, isMobile: true, hasTouch: true });
test.use({
viewport: { width: 1024, height: 768 },
isMobile: true,
hasTouch: true,
});
test("remounting an inline composer does not restore focus on touch", async ({ page }) => {
const agent = await seedMockAgentWorkspace({
@@ -192,7 +272,9 @@ test.describe("touch screen wide enough for the desktop layout", () => {
test("returning to an inactive inline composer does not restore focus on touch", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "composer-touch-retained-" });
const workspace = await seedWorkspace({
repoPrefix: "composer-touch-retained-",
});
try {
const first = await createMockIdleAgent(workspace.client, {
cwd: workspace.repoPath,
@@ -240,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();
}
@@ -249,7 +334,7 @@ test.describe("touch screen wide enough for the desktop layout", () => {
const COMPACT_PANELS = [
{
name: "agent list",
open: swipeOpenMobileAgentSidebar,
open: openMobileAgentSidebarWithKeyboard,
expectOpen: expectMobileAgentSidebarVisible,
close: closeMobileAgentSidebar,
expectClosed: expectMobileAgentSidebarHidden,
@@ -37,7 +37,7 @@ import {
closeMobileAgentSidebar,
expectMobileAgentSidebarHidden,
expectMobileAgentSidebarVisible,
swipeOpenMobileAgentSidebar,
openMobileAgentSidebarWithKeyboard,
} from "../support/helpers/sidebar";
async function composerHeight(page: Page): Promise<number> {
@@ -541,8 +541,8 @@ test("the compact sidebar over a fullscreen editor leaves the exit action alone"
await writeCompactDraft(page, draft);
await enterComposerFullscreen(page);
await expectWorkspaceHeaderHidden(page);
// Fullscreen hides the header's menu button; the edge swipe still opens the list over it.
await swipeOpenMobileAgentSidebar(page);
// Fullscreen hides the header's menu button; the keyboard shortcut opens the list over it.
await openMobileAgentSidebarWithKeyboard(page);
await expectMobileAgentSidebarVisible(page);
// The list covers the pane, so the editor's exit action is not armed.
await page.keyboard.press("Escape");
@@ -1,3 +1,5 @@
import { installLocalDesktopHost } from "../support/helpers/local-desktop-host";
import type { Locator, Page } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
import {
@@ -9,6 +11,7 @@ import { seedWorkspace } from "../support/helpers/seed-client";
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
test.describe("Direct terminal shortcut pane placement", () => {
test.use({ viewport: { width: 1600, height: 900 } });
test("opens a terminal in the focused pane", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "direct-terminal-shortcut-pane-" });
@@ -47,3 +50,91 @@ test.describe("Direct terminal shortcut pane placement", () => {
}
});
});
async function prepareNarrowSplit(page: Page, workspaceId: string) {
await page.setViewportSize({ width: 1600, height: 900 });
await gotoWorkspace(page, workspaceId);
await waitForWorkspaceTabsVisible(page);
await clickNewChat(page);
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
const draftPane = page
.locator('[data-testid^="workspace-pane-"]')
.filter({ has: page.getByTestId("message-input-root") });
const emptyPane = page
.locator('[data-testid^="workspace-pane-"]')
.filter({ has: page.getByTestId("workspace-new-tab-panel") });
const draftPaneId = await draftPane.getAttribute("data-testid");
const emptyPaneId = await emptyPane.getAttribute("data-testid");
expect(draftPaneId).not.toBeNull();
expect(emptyPaneId).not.toBeNull();
await page.setViewportSize({ width: 760, height: 900 });
return {
originalPane: page.getByTestId(draftPaneId!),
otherPane: page.getByTestId(emptyPaneId!),
};
}
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 maximizeAndRestorePane(pane: Locator) {
await pane.getByTestId("workspace-maximize-pane").click();
await pane.getByTestId("workspace-restore-pane").click();
}
test("narrow split panes keep their tab rails and launches stay in the owning pane", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "narrow-split-launch-" });
try {
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 maximizeAndRestorePane(originalPane);
await expect(otherPane).toBeVisible();
await expectPanesKeepTabRails(page, [originalPane, otherPane]);
} finally {
await workspace.cleanup();
}
});
async function expectPaneHasWindowDragSurface(pane: Locator) {
await expect
.poll(() =>
pane.evaluate((element) =>
Array.from(element.querySelectorAll<HTMLElement>("div")).some(
(node) => node.style.getPropertyValue("-webkit-app-region") === "drag",
),
),
)
.toBe(true);
}
test("narrow desktop split panes retain window dragging with both sidebars closed", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "narrow-split-window-drag-" });
try {
await installLocalDesktopHost(page, workspace.repoPath);
const { originalPane, otherPane } = await prepareNarrowSplit(page, workspace.workspaceId);
await runWorkspaceActionFromCommandCenter(page, "Toggle left sidebar");
await expectPanesKeepTabRails(page, [originalPane, otherPane]);
await expectPaneHasWindowDragSurface(originalPane);
await expectPaneHasWindowDragSurface(otherPane);
} finally {
await workspace.cleanup();
}
});
@@ -56,9 +56,9 @@ test.describe("New tab keyboard launcher", () => {
await expectLauncherSelection(launcher, "Diff");
await page.keyboard.press("Enter");
await expect(
page.getByTestId("workspace-tab-working_diff").filter({ visible: true }),
).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId("working-diff-panel").filter({ visible: true })).toBeVisible({
timeout: 30_000,
});
} finally {
await workspace.cleanup();
}
@@ -105,6 +105,9 @@ function collectConsoleErrors(page: Page): string[] {
return errors;
}
// These drag and tab-menu journeys need full rails beside the docked Explorer.
test.use({ viewport: { width: 1900, height: 900 } });
test.describe("explorer pane tab placement", () => {
test("agents open in the main pane and New-tab splits keep the app usable", async ({
page,
@@ -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(() =>
@@ -435,7 +435,8 @@ test.describe("CodeMirror workspace file editing", () => {
});
try {
await page.setViewportSize({ width: 1280, height: 900 });
// Keep both tab rails available alongside the docked Explorer.
await page.setViewportSize({ width: 1900, height: 900 });
await openAgentRoute(page, session);
await openWorkspaceFile(page, "target.ts");
@@ -1,6 +1,8 @@
import { test } from "../support/fixtures";
import {
closeDiagramFullscreen,
expectInlineDiagramFitsViewport,
toggleDiagramSourceAndVerifyControls,
closeDiagramFullscreenFromOutside,
expectCompletedDiagram,
expectFullscreenDiagram,
@@ -21,6 +23,36 @@ const MERMAID_DIAGRAM = [
"```",
].join("\n");
test("bounds a tall inline diagram", async ({ page }) => {
test.setTimeout(120_000);
const diagram = [
"```mermaid",
"flowchart TD",
...Array.from({ length: 30 }, (_, i) => `N${i} --> N${i + 1}`),
"```",
].join("\n");
const agent = await seedMockAgentWorkspace({
repoPrefix: "mermaid-tall-",
title: "Tall Mermaid",
featureValues: {
mockStreamingAssistantResponse: diagram,
mockStreamingAssistantIntervalMs: 10,
},
});
try {
await openAgentRoute(page, agent);
await requestDiagram(agent);
await waitForDiagramTurnToComplete(agent);
await expectCompletedDiagram(page, ["N0", "N30"]);
await expectInlineDiagramFitsViewport(page);
await openDiagramFullscreen(page);
await expectFullscreenDiagram(page, ["N0", "N30"]);
await closeDiagramFullscreen(page);
} finally {
await agent.cleanup();
}
});
test("opens a Mermaid diagram fullscreen and closes it again", async ({ page }) => {
test.setTimeout(120_000);
@@ -40,6 +72,10 @@ test("opens a Mermaid diagram fullscreen and closes it again", async ({ page })
await expectCompletedDiagram(page, ["Start", "Ship"]);
});
await test.step("Source toggles preserve the diagram and exclude hidden controls", async () => {
await toggleDiagramSourceAndVerifyControls(page);
});
await test.step("The toolbar opens the diagram fullscreen", async () => {
await openDiagramFullscreen(page);
await expectFullscreenDiagram(page, ["Start", "Review", "Ship"]);
@@ -1,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();
+137
View File
@@ -12,11 +12,15 @@ import {
openAppAsNewUser,
openRelayPairingFromSetup,
closeRelayPairing,
emptyHome,
loadingScreen,
openTailscaleDocsFromSetup,
paseoToolsSwitch,
pickTheme,
projectSuggestion,
projectSuggestionRow,
readFirstLaunch,
recordFirstLaunch,
setupModal,
setupPrimaryButton,
showSetupAgainFromSettings,
@@ -99,6 +103,139 @@ test("a new user is walked through setup once", async ({ page }, testInfo) => {
}
});
test("a new user goes from the loading screen straight into setup", async ({ page }) => {
const host = await startSetupHost("first-launch");
try {
await recordFirstLaunch(page);
await openAppAsNewUser(page, host);
await expectSetupStep(page, "Welcome to Paseo");
const launch = await readFirstLaunch(page);
expect(launch.appBeforeSetup).toBe(false);
expect(launch.setupOpacityOnArrival).toBeLessThan(1);
await expect(setupModal(page)).toHaveCSS("opacity", "1");
} finally {
await host.close();
}
});
test("with reduced motion, setup appears without fading in", async ({ page }) => {
const host = await startSetupHost("reduced-motion");
try {
await page.emulateMedia({ reducedMotion: "reduce" });
await recordFirstLaunch(page);
await openAppAsNewUser(page, host);
await expectSetupStep(page, "Welcome to Paseo");
expect((await readFirstLaunch(page)).setupOpacityOnArrival).toBe(1);
} finally {
await host.close();
}
});
test("changing the theme keeps the setup window in place on every step", async ({ page }) => {
const host = await startSetupHost("theme-change");
try {
await openAppAsNewUser(page, host);
await expectSetupStep(page, "Welcome to Paseo");
const setupWindow = await setupModal(page).elementHandle();
for (const title of [
"Welcome to Paseo",
"Your agents",
"Add your projects",
"Let agents use Paseo",
"Take your agents with you",
]) {
if (title !== "Welcome to Paseo") await continueSetup(page);
await expectSetupStep(page, title);
const heading = setupModal(page).getByRole("heading", { name: title });
const headingNode = await heading.elementHandle();
const lightColor = await heading.evaluate((node) => getComputedStyle(node).color);
await page.emulateMedia({ colorScheme: "dark" });
await expect(heading).not.toHaveCSS("color", lightColor);
await page.emulateMedia({ colorScheme: "light" });
await expect(heading).toHaveCSS("color", lightColor);
expect(await setupWindow?.evaluate((node) => node.isConnected), title).toBe(true);
expect(await headingNode?.evaluate((node) => node.isConnected), title).toBe(true);
}
await goToSetupStep(page, "Appearance");
await pickTheme(page, "Midnight");
await pickTheme(page, "Light");
expect(await setupWindow?.evaluate((node) => node.isConnected)).toBe(true);
} finally {
await host.close();
}
});
test("the loading screen stays up while the setup host loads its workspaces", async ({ page }) => {
const host = await startSetupHost("slow-workspaces");
const gate = await installDaemonWebSocketGate(page, { port: host.port });
try {
await recordFirstLaunch(page);
gate.holdNextClientRequest("fetch_workspaces_request");
await openAppAsNewUser(page, host);
await gate.waitForHeldClientRequest();
await expect(loadingScreen(page)).toBeVisible();
await expect(emptyHome(page)).toHaveCount(0);
gate.releaseHeldClientRequest();
await expectSetupStep(page, "Welcome to Paseo");
expect((await readFirstLaunch(page)).appBeforeSetup).toBe(false);
} finally {
await host.close();
}
});
test("a setup host that never loads its workspaces still lands on the home", async ({ page }) => {
const host = await startSetupHost("stuck-workspaces");
const gate = await installDaemonWebSocketGate(page, { port: host.port });
try {
gate.holdNextClientRequest("fetch_workspaces_request");
await openAppAsNewUser(page, host);
await gate.waitForHeldClientRequest();
await expect(emptyHome(page)).toBeVisible({ timeout: 15_000 });
await expect(setupModal(page)).toHaveCount(0);
// Setup still opens once the host answers.
gate.releaseHeldClientRequest();
await expectSetupStep(page, "Welcome to Paseo");
} finally {
await host.close();
}
});
test("a setup host that drops while loading still lands on the home", async ({ page }) => {
const host = await startSetupHost("dropped-workspaces");
const gate = await installDaemonWebSocketGate(page, { port: host.port });
try {
gate.holdNextClientRequest("fetch_workspaces_request");
await openAppAsNewUser(page, host);
await gate.waitForHeldClientRequest();
await gate.drop();
await expect(emptyHome(page)).toBeVisible({ timeout: 15_000 });
await expect(setupModal(page)).toHaveCount(0);
} finally {
await host.close();
}
});
test("an unreachable setup host lands on the home without setup", async ({ page }) => {
const host = await startSetupHost("unreachable");
await host.close();
await openAppAsNewUser(page, host);
await expect(emptyHome(page)).toBeVisible({ timeout: 15_000 });
await expect(setupModal(page)).toHaveCount(0);
});
test("setup suggests the repos the user already ran agents in", async ({ page }, testInfo) => {
const repo = await createTempGitRepo("onboarding-history-");
const repoName = path.basename(repo.path);
@@ -1,7 +1,8 @@
import { mkdtempSync, realpathSync } from "node:fs";
import { mkdirSync, mkdtempSync, realpathSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, test } from "../support/fixtures";
import type { Locator } from "@playwright/test";
import { expect, test, type Page } from "../support/fixtures";
import {
cleanupRewindFlow,
launchAgent,
@@ -14,7 +15,11 @@ import {
expectTaskListEntries,
openCompletedTaskList,
} from "../support/helpers/task-list";
import { submitMessage } from "../support/helpers/composer";
import { submitMessage, submitMessageWithButton } from "../support/helpers/composer";
import { waitForSettledPosition } from "../support/helpers/sheet-layout";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import { gotoAppShell } from "../support/helpers/app";
interface TaskScenario {
provider: RewindFlowProvider;
@@ -114,3 +119,425 @@ test.describe("real provider composer task lists", () => {
}
});
});
async function openBackgroundInventory(
page: Page,
options: { compact: boolean; count: number; timeout: number },
): Promise<Locator> {
const pill = page.getByRole("button", {
name: options.count === 1 ? "1 background task" : `${options.count} background tasks`,
});
await expect(pill).toBeVisible({ timeout: options.timeout });
await pill.click();
const panel = page.getByTestId(
options.compact ? "background-work-panel-content" : "background-work-panel",
);
if (options.compact) await waitForSettledPosition(panel);
return panel;
}
async function openBackgroundWork(page: Page, compact: boolean): Promise<void> {
const panel = await openBackgroundInventory(page, { compact, count: 1, timeout: 30_000 });
await expect(panel).toContainText(/sleep 120/);
}
async function expectBackgroundItems(
panel: Locator,
selected: string,
sibling: string,
): Promise<void> {
await expect(panel).toContainText(selected);
await expect(panel).toContainText(sibling);
}
async function stopBackgroundItem(panel: Locator, selected: string): Promise<void> {
const escaped = selected.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
await panel.getByRole("button", { name: new RegExp(`Stop .*${escaped}`) }).click();
}
async function expectStoppedItemAndSibling(
panel: Locator,
selected: string,
sibling: string,
): Promise<void> {
await expect(panel).not.toContainText(selected, { timeout: 40_000 });
await expect(panel).toContainText(sibling);
}
async function closeBackgroundWork(page: Page, compact: boolean): Promise<void> {
const panel = page.getByTestId(
compact ? "background-work-panel-content" : "background-work-panel",
);
if (compact) {
await page
.getByRole("button", { name: "Bottom sheet backdrop" })
.click({ position: { x: 12, y: 12 } });
} else {
await page.keyboard.press("Escape");
}
await expect(panel).toBeHidden();
}
async function expectBackgroundAgentRunning(page: Page): Promise<void> {
await expect(page.getByTestId("workspace-status-indicator-running").first()).toBeVisible();
await expect(page.getByRole("button", { name: "Stop agent", exact: true })).toHaveCount(0);
}
async function expectAssistantReply(page: Page, text: string): Promise<void> {
await expect(page.getByTestId("assistant-message").filter({ hasText: text }).last()).toBeVisible({
timeout: 120_000,
});
}
async function expectBackgroundPill(page: Page): Promise<void> {
await expect(page.getByRole("button", { name: "1 background task" })).toBeVisible();
}
async function expectBackgroundWorkOnAnotherClient(page: Page): Promise<void> {
const another = await page.context().newPage();
try {
await another.goto(page.url());
await openBackgroundWork(another, false);
} finally {
await another.close();
}
}
async function startBackgroundWorkWithoutOpeningPane(workspace: SeededWorkspace): Promise<void> {
const agent = await workspace.client.createAgent({
provider: "claude",
model: "haiku",
modeId: "bypassPermissions",
cwd: workspace.workspaceDirectory,
workspaceId: workspace.workspaceId,
title: "Background work before opening",
});
await workspace.client.sendAgentMessage(
agent.id,
"Use Bash with run_in_background to run exactly sleep 120. Do not wait for it. Reply SPAWNED immediately.",
);
const result = await workspace.client.waitForFinish(agent.id, 120_000);
expect(result.status).toBe("idle");
}
async function expectWorkspaceRunning(page: Page, workspaceId: string): Promise<void> {
const row = page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`);
await expect(row.getByTestId("workspace-status-indicator-running")).toBeVisible({
timeout: 30_000,
});
}
test("Claude keeps background work visible while accepting another turn", async ({ page }) => {
test.setTimeout(300_000);
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-background-work-")));
let handle: AgentHandle | undefined;
try {
handle = await launchAgent({ page, provider: "claude", cwd, mode: "full-access" });
await sendMessage(
handle,
"Use Bash with run_in_background to run exactly sleep 120. Do not wait for it. Reply SPAWNED immediately.",
);
await expectBackgroundAgentRunning(page);
await openBackgroundWork(page, false);
await page.screenshot({
path: path.resolve("../../.artifacts/phase1-claude-background.png"),
animations: "disabled",
});
await closeBackgroundWork(page, false);
await sendMessage(handle, "Reply exactly HELLO without stopping the background command.");
await expectBackgroundPill(page);
await expectBackgroundWorkOnAnotherClient(page);
} finally {
await cleanupRewindFlow({ handle, cwd });
}
});
test("Claude background work remains readable in dark mode", async ({ page }) => {
test.setTimeout(300_000);
await page.addInitScript(() => {
localStorage.setItem("@paseo:app-settings", JSON.stringify({ theme: "pureBlack" }));
});
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-background-dark-")));
let handle: AgentHandle | undefined;
try {
handle = await launchAgent({ page, provider: "claude", cwd, mode: "full-access" });
await sendMessage(
handle,
"Use Bash with run_in_background to run exactly sleep 120. Do not wait for it. Reply SPAWNED immediately.",
);
await openBackgroundWork(page, false);
await page.screenshot({
path: path.resolve("../../.artifacts/review-background-dark.png"),
animations: "disabled",
});
} finally {
await cleanupRewindFlow({ handle, cwd });
}
});
test("Claude background work opens in a compact sheet", async ({ page }) => {
test.setTimeout(300_000);
await page.setViewportSize({ width: 390, height: 844 });
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-background-compact-")));
let handle: AgentHandle | undefined;
try {
handle = await launchAgent({ page, provider: "claude", cwd, mode: "full-access" });
await submitMessageWithButton(
page,
"Use Bash with run_in_background to run exactly sleep 120. Do not wait for it. Reply SPAWNED immediately.",
);
await expectAssistantReply(page, "SPAWNED");
await openBackgroundWork(page, true);
await page.screenshot({
path: path.resolve("../../.artifacts/phase1-claude-compact.png"),
animations: "disabled",
});
await closeBackgroundWork(page, true);
await submitMessageWithButton(
page,
"Reply exactly HELLO without stopping the background command.",
);
await expectAssistantReply(page, "HELLO");
await expectBackgroundPill(page);
} finally {
await cleanupRewindFlow({ handle, cwd });
}
});
test("background work updates the sidebar before the agent is opened", async ({ page }) => {
test.setTimeout(300_000);
const workspace = await seedWorkspace({ repoPrefix: "background-unopened-" });
try {
await gotoAppShell(page);
await expect(
page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspace.workspaceId}`),
).toBeVisible();
await startBackgroundWorkWithoutOpeningPane(workspace);
await expectWorkspaceRunning(page, workspace.workspaceId);
} finally {
await workspace.cleanup();
}
});
test("Codex keeps a live command visible after its turn", async ({ page }) => {
test.setTimeout(300_000);
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-codex-background-")));
let handle: AgentHandle | undefined;
try {
handle = await launchAgent({
page,
provider: "codex",
cwd,
mode: "full-access",
providerConfig: { model: "gpt-6-astra" },
});
await sendMessage(
handle,
"Use exec_command to run sleep 120 with yield_time_ms 1000. Do not wait for or stop it. Reply SPAWNED immediately.",
);
await expectBackgroundAgentRunning(page);
await openBackgroundWork(page, false);
await page.screenshot({
path: path.resolve("../../.artifacts/phase2-codex-background.png"),
animations: "disabled",
});
await closeBackgroundWork(page, false);
await sendMessage(handle, "Reply exactly HELLO without stopping the background command.");
await expectBackgroundPill(page);
await expectBackgroundWorkOnAnotherClient(page);
} finally {
await cleanupRewindFlow({ handle, cwd });
}
});
test("OpenCode keeps a live command visible after its turn", async ({ page }) => {
test.setTimeout(300_000);
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-opencode-background-")));
let handle: AgentHandle | undefined;
try {
handle = await launchAgent({
page,
provider: "opencode",
cwd,
mode: "full-access",
providerConfig: { model: "openrouter/google/gemini-3.6-flash" },
});
await sendMessage(
handle,
"Use the shell tool with background true to run sleep 120. Do not wait or stop it. Reply SPAWNED immediately.",
);
await expectBackgroundAgentRunning(page);
await openBackgroundWork(page, false);
await page.screenshot({
path: path.resolve("../../.artifacts/phase2-opencode-background.png"),
animations: "disabled",
});
await closeBackgroundWork(page, false);
await sendMessage(handle, "Reply exactly HELLO without stopping the background command.");
await expectBackgroundPill(page);
await expectBackgroundWorkOnAnotherClient(page);
} finally {
await cleanupRewindFlow({ handle, cwd });
}
});
for (const scenario of [
{
provider: "claude" as const,
prompt:
"Use Bash with run_in_background to start sleep 180 and sleep 181 as two separate background commands. Do not wait for them. Reply SPAWNED immediately.",
providerConfig: { model: "haiku" },
},
{
provider: "codex" as const,
prompt:
"Use exec_command twice to start sleep 180 and sleep 181 separately with yield_time_ms 1000. Do not wait for or stop either process. Reply SPAWNED immediately.",
providerConfig: { model: "gpt-6-astra" },
},
{
provider: "opencode" as const,
prompt:
"Use the shell tool twice with background true: start sleep 180, then start sleep 181. Do not wait for or stop either. Reply SPAWNED immediately.",
providerConfig: { model: "openrouter/google/gemini-3.6-flash" },
},
]) {
test(`${scenario.provider} stops selected background work after the turn`, async ({ page }) => {
test.setTimeout(300_000);
mkdirSync(path.resolve("../../.artifacts"), { recursive: true });
const cwd = realpathSync(mkdtempSync(path.resolve("../../.artifacts/phase3-qa-workspace-")));
let handle: AgentHandle | undefined;
try {
handle = await launchAgent({
page,
provider: scenario.provider,
cwd,
mode: "full-access",
providerConfig: scenario.providerConfig,
});
await sendMessage(handle, scenario.prompt);
const panel = await openBackgroundInventory(page, {
compact: false,
count: 2,
timeout: 60_000,
});
await expectBackgroundItems(panel, "sleep 180", "sleep 181");
await stopBackgroundItem(panel, "sleep 180");
await expectStoppedItemAndSibling(panel, "sleep 180", "sleep 181");
await page.screenshot({
path: path.resolve(`../../.artifacts/phase3-${scenario.provider}-stop.png`),
animations: "disabled",
});
await page.keyboard.press("Escape");
await sendMessage(handle, "Reply exactly HELLO without stopping the background command.");
await expectBackgroundPill(page);
} finally {
await cleanupRewindFlow({ handle, cwd });
}
});
}
test("Claude stops a selected monitor in the compact sheet", async ({ page }) => {
test.setTimeout(300_000);
await page.setViewportSize({ width: 390, height: 844 });
mkdirSync(path.resolve("../../.artifacts"), { recursive: true });
const cwd = realpathSync(mkdtempSync(path.resolve("../../.artifacts/phase3-monitor-workspace-")));
let handle: AgentHandle | undefined;
try {
handle = await launchAgent({
page,
provider: "claude",
cwd,
mode: "full-access",
providerConfig: { model: "opus" },
});
await submitMessageWithButton(
page,
'Use exactly two separate Monitor tool calls, each with command `sleep 150`, timeout_ms 150000, persistent false, descriptions "probe monitor one" and "probe monitor two". Launch both independently, do not wait, then reply SPAWNED. Do not use any other tools.',
);
await expectAssistantReply(page, "SPAWNED");
const panel = await openBackgroundInventory(page, { compact: true, count: 2, timeout: 30_000 });
await expectBackgroundItems(panel, "probe monitor one", "probe monitor two");
await expect(panel.getByText("sleep 150", { exact: true })).toHaveCount(2);
await page.screenshot({
path: path.resolve("../../.artifacts/refinement-monitor-compact.png"),
animations: "disabled",
});
await stopBackgroundItem(panel, "probe monitor one");
await expectStoppedItemAndSibling(panel, "probe monitor one", "probe monitor two");
await page.screenshot({
path: path.resolve("../../.artifacts/phase3-claude-monitor-compact.png"),
animations: "disabled",
});
await closeBackgroundWork(page, true);
await submitMessageWithButton(
page,
"Reply exactly HELLO without stopping the background monitor.",
);
await expectAssistantReply(page, "HELLO");
await expectBackgroundPill(page);
await handle.client.sendAgentMessage(
handle.agentId,
"Use Bash to run sleep 20 in the foreground, then reply WAITED.",
);
await expect(page.getByRole("button", { name: "Stop agent", exact: true })).toBeVisible({
timeout: 30_000,
});
await handle.client.sendAgentMessage(
handle.agentId,
"Reply exactly INTERRUPTED without stopping the background monitor.",
{ activeTurnBehavior: "interrupt" },
);
expect((await handle.client.waitForFinish(handle.agentId, 120_000)).status).toBe("idle");
await expectBackgroundPill(page);
await expect
.poll(async () => handle!.client.listAgentBackgroundWork(handle!.agentId))
.toEqual(
expect.arrayContaining([
expect.objectContaining({ description: "probe monitor two", kind: "monitor" }),
]),
);
} finally {
await cleanupRewindFlow({ handle, cwd });
}
});
test("Claude shows monitor description and command in the wide track", async ({ page }) => {
test.setTimeout(300_000);
const cwd = realpathSync(
mkdtempSync(path.resolve("../../.artifacts/refinement-wide-workspace-")),
);
let handle: AgentHandle | undefined;
try {
handle = await launchAgent({
page,
provider: "claude",
cwd,
mode: "full-access",
providerConfig: { model: "opus" },
});
await sendMessage(
handle,
'Use Monitor with command `sleep 150`, timeout_ms 150000, persistent false, description "Watch the build". Do not wait; reply SPAWNED. Do not use other tools.',
);
await expectBackgroundAgentRunning(page);
const workspaceRow = page.getByTestId(
`sidebar-workspace-row-${getServerId()}:${handle.workspaceId}`,
);
await workspaceRow.scrollIntoViewIfNeeded();
await expect(workspaceRow.getByTestId("workspace-status-indicator-running")).toBeVisible();
const panel = await openBackgroundInventory(page, {
compact: false,
count: 1,
timeout: 30_000,
});
await expect(panel).toContainText("Watch the build");
await expect(panel).toContainText("sleep 150");
await expect(panel.getByRole("button", { name: "Stop Watch the build" })).toBeVisible();
await page.screenshot({
path: path.resolve("../../.artifacts/refinement-monitor-wide.png"),
animations: "disabled",
});
} finally {
await cleanupRewindFlow({ handle, cwd });
}
});
@@ -60,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,127 @@
import { expect, test } from "../support/fixtures";
import { addProjectFlow } from "../support/helpers/add-project-flow";
import { gotoAppShell } from "../support/helpers/app";
import {
chooseFooterOverflowAction,
expectFooterLine,
expectFooterMenusInsideViewport,
expectFooterNeverClipped,
expectFooterOverflowActions,
footerOverflowMenu,
openFooterOverflow,
seedDockedSidebarWidth,
watchFooterClipping,
} from "../support/helpers/sidebar-footer-overflow";
import { addOfflineHostsAndReload } from "../support/helpers/hosts";
import { openCompactSidebar } from "../support/helpers/usage-sidebar-item";
const TOUCH_CONTROL = 44;
const MIN_SIDEBAR_WIDTH = 200;
const ALL_INLINE = [
"sidebar-add-project",
"sidebar-usage-icon",
"sidebar-hosts-trigger",
"sidebar-help",
"sidebar-settings",
];
// Five 44pt buttons and the row's padding need 236pt; 200pt holds three and the caret.
const NARROW_TOUCH_LINE = [
"sidebar-add-project",
"sidebar-hosts-trigger",
"sidebar-footer-overflow",
"sidebar-settings",
];
test.describe("Sidebar footer on a touch screen", () => {
test.use({ viewport: { width: 1280, height: 800 }, hasTouch: true });
test("a narrow docked sidebar moves the actions it can't fit behind a caret", async ({
page,
}) => {
await seedDockedSidebarWidth(page, MIN_SIDEBAR_WIDTH);
await watchFooterClipping(page);
await gotoAppShell(page);
await expectFooterLine(page, { testIDs: NARROW_TOUCH_LINE, controlSize: TOUCH_CONTROL });
await expectFooterNeverClipped(page);
await test.step("Help opens as a page of the overflow menu", async () => {
await openFooterOverflow(page);
await expectFooterOverflowActions(page, ["Usage", "Help and support"]);
await chooseFooterOverflowAction(page, "Help and support");
await expect(page.getByRole("menuitem", { name: "What's new" })).toBeVisible();
await expect(page.getByTestId("sidebar-help-menu")).toHaveCount(0);
await page.keyboard.press("Escape");
await expect(footerOverflowMenu(page)).toBeHidden();
});
await test.step("Usage opens from the overflow menu", async () => {
await openFooterOverflow(page);
await chooseFooterOverflowAction(page, "Usage");
await expect(footerOverflowMenu(page)).toBeHidden();
await expect(page.getByTestId("usage-modal")).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByTestId("usage-modal")).toBeHidden();
});
await test.step("Add project and Settings shortcuts still work", async () => {
await page.keyboard.press("Control+o");
await expect(addProjectFlow(page)).toBeVisible();
await page.keyboard.press("Escape");
await expect(addProjectFlow(page)).toBeHidden();
await page.keyboard.press("Control+,");
await expect(page).toHaveURL(/\/settings\/general$/);
});
});
test("the default sidebar width keeps all five actions inline", async ({ page }) => {
await gotoAppShell(page);
await expectFooterLine(page, { testIDs: ALL_INLINE, controlSize: TOUCH_CONTROL });
});
});
test.describe("Sidebar footer in a compact overlay narrower than the footer", () => {
test.use({ viewport: { width: 220, height: 800 }, hasTouch: true });
test("the overflow menu reaches the hidden actions", async ({ page }) => {
await watchFooterClipping(page);
await gotoAppShell(page);
await openCompactSidebar(page);
await expectFooterLine(page, { testIDs: NARROW_TOUCH_LINE, controlSize: TOUCH_CONTROL });
await expectFooterNeverClipped(page);
await openFooterOverflow(page);
await expectFooterOverflowActions(page, ["Usage", "Help and support"]);
await expectFooterMenusInsideViewport(page);
});
});
test.describe("Sidebar footer on a short, narrow phone", () => {
// 164pt for buttons holds Settings, Add project and the caret, so Hosts moves behind it.
test.use({ viewport: { width: 180, height: 360 }, hasTouch: true });
test("the overflow Hosts page scrolls to its last host and Add host", async ({ page }) => {
await gotoAppShell(page);
await addOfflineHostsAndReload(
page,
Array.from({ length: 10 }, (_, index) => ({
serverId: `overflow-host-${index}`,
label: `Overflow host ${index}`,
})),
);
await openCompactSidebar(page);
await expectFooterLine(page, {
testIDs: ["sidebar-add-project", "sidebar-footer-overflow", "sidebar-settings"],
controlSize: TOUCH_CONTROL,
});
await openFooterOverflow(page);
await expectFooterOverflowActions(page, ["Usage", "Hosts", "Help and support"]);
await expectFooterMenusInsideViewport(page);
await chooseFooterOverflowAction(page, "Hosts");
await expectFooterMenusInsideViewport(page);
const addHost = page.getByRole("menuitem", { name: "Add host" });
await addHost.scrollIntoViewIfNeeded();
await expect(page.getByRole("menuitem", { name: "Overflow host 9" })).toBeVisible();
await expect(addHost).toBeInViewport();
await addHost.click();
await expect(page.getByText("Add connection", { exact: true })).toBeVisible();
});
});
@@ -97,6 +97,7 @@ test("fixed footer line keeps its five icons, Help and Settings at the end", asy
await setFooterItemVisible(page, "usage", true);
await leaveSettings(page);
await page.setViewportSize({ width: 390, height: 844 });
await openCompactSidebar(page);
await expectCompactSidebarOpen(page);
await expectFooterSeparator(page, true);
await expectFooterIconRow(page);
@@ -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();
});
});
@@ -1,12 +1,13 @@
import type { Page } from "@playwright/test";
import { test as base, expect } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import {
openAgentRoute,
seedMockAgentWorkspace,
type MockAgentWorkspace,
} from "../support/helpers/mock-agent";
import { expectOpenTabCount } from "../support/helpers/sheet";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import { expectReconnectingToastGone } from "../support/helpers/workspace-ui";
import { getServerId } from "../support/helpers/server-id";
import { closeMobileAgentSidebar, openMobileAgentSidebar } from "../support/helpers/sidebar";
@@ -101,11 +102,13 @@ async function leaveMarkedWorkspaceAndReopen(page: Page, { subject, other }: Fin
}
async function completeTurnAndLeave(page: Page, { subject, other }: FinishedWorkspaces) {
await test.step("ordinary completion still clears on departure", async () => {
await test.step("ordinary completion stays unread on departure and clears on return", async () => {
await subject.client.sendAgentMessage(subject.agentId, "Finish another turn.");
await subject.client.waitForFinish(subject.agentId, 20_000);
await expectStatus(page, subject.workspaceId, "attention");
await openWorkspace(page, other.workspaceId);
await expectStatus(page, subject.workspaceId, "attention");
await openWorkspace(page, subject.workspaceId);
await expectStatus(page, subject.workspaceId, "done");
});
}
@@ -141,13 +144,15 @@ async function leaveMarkedWorkspaceAndReopenOnCompact(
}
async function completeTurnAndLeaveOnCompact(page: Page, { subject, other }: FinishedWorkspaces) {
await test.step("ordinary completion still clears on departure", async () => {
await test.step("ordinary completion stays unread on departure and clears on return", async () => {
await closeMobileAgentSidebar(page);
await subject.client.sendAgentMessage(subject.agentId, "Finish another turn.");
await subject.client.waitForFinish(subject.agentId, 20_000);
await openMobileAgentSidebar(page);
await expectStatus(page, subject.workspaceId, "attention");
await openWorkspaceOnCompact(page, other.workspaceId);
await expectStatus(page, subject.workspaceId, "attention");
await openWorkspaceOnCompact(page, subject.workspaceId);
await expectStatus(page, subject.workspaceId, "done");
});
}
@@ -194,11 +199,12 @@ async function markUnreadThenResumeChat(page: Page, workspaceId: string) {
});
}
test("manual unread survives departure and clears on reopening without changing normal completions", async ({
test("manual unread survives departure and clears on reopening and ordinary completion survives departure", async ({
page,
workspaces,
}) => {
await gotoAppShell(page);
await openAgentRoute(page, workspaces.subject);
await expect(page.getByText("Finish this turn.", { exact: true })).toBeVisible();
await openWorkspace(page, workspaces.other.workspaceId);
await markBackgroundWorkspaceAndReopen(page, workspaces.subject.workspaceId);
await leaveMarkedWorkspaceAndRead(page, workspaces);
@@ -207,16 +213,19 @@ test("manual unread survives departure and clears on reopening without changing
await markBackgroundWorkspaceAndRead(page, workspaces.subject.workspaceId);
});
test("clicking a multi-agent workspace reveals and clears its marked agent", async ({
test("clicking a multi-agent workspace preserves its selected agent and leaves the other unread", async ({
page,
workspaces,
}) => {
await openAgentRoute(page, workspaces.subject);
await expect(page.getByText("Finish this turn.", { exact: true })).toBeVisible();
await expectSelectedAgent(page, workspaces.subject.agentId);
await openWorkspace(page, workspaces.other.workspaceId);
const newest = await addFinishedAgent(workspaces.subject);
await markBackgroundWorkspaceAndReopen(page, workspaces.subject.workspaceId);
await expectSelectedAgent(page, newest.id);
await addFinishedAgent(workspaces.subject);
await markAsUnread(page, workspaces.subject.workspaceId);
await openWorkspace(page, workspaces.subject.workspaceId);
await expectSelectedAgent(page, workspaces.subject.agentId);
await expectStatus(page, workspaces.subject.workspaceId, "attention");
});
test("manual unread survives leaving the current workspace on compact layout", async ({
@@ -230,7 +239,7 @@ test("manual unread survives leaving the current workspace on compact layout", a
await completeTurnAndLeaveOnCompact(page, workspaces);
});
async function reopenMarkedWorkspaceAfterBackgroundTurn(page: Page, workspace: MockAgentWorkspace) {
async function reopenWorkspaceAfterBackgroundTurn(page: Page, workspace: MockAgentWorkspace) {
const route = page.url();
// End this app session before the turn, so its cached transcript cannot contain the new prompt.
await page.goto("about:blank");
@@ -265,9 +274,72 @@ test("reopening a marked unread workspace restores only its agent tab and curren
await test.step("mark the current workspace unread, then close and reopen the app", async () => {
await openMobileAgentSidebar(page);
await markAsUnread(page, workspaces.subject.workspaceId);
await reopenMarkedWorkspaceAfterBackgroundTurn(page, workspaces.subject);
await reopenWorkspaceAfterBackgroundTurn(page, workspaces.subject);
});
await test.step("restore the main tab without Explorer entries and load its latest history", async () => {
await expectRestoredCompactChat(page);
});
});
test("clicking the already focused timeline acknowledges a new completion", async ({
page,
workspaces,
}) => {
await openAgentRoute(page, workspaces.subject);
await expect(page.getByText("Finish this turn.", { exact: true })).toBeVisible();
await workspaces.subject.client.sendAgentMessage(
workspaces.subject.agentId,
"Finish another turn.",
);
await workspaces.subject.client.waitForFinish(workspaces.subject.agentId, 20_000);
await expectStatus(page, workspaces.subject.workspaceId, "attention");
await page.getByText("Finish another turn.", { exact: true }).click();
await expectStatus(page, workspaces.subject.workspaceId, "done");
});
async function reconnectWithoutInteracting(
page: Page,
gate: Awaited<ReturnType<typeof installDaemonWebSocketGate>>,
agentId: string,
) {
await gate.drop();
await gate.waitForBlockedConnection();
gate.holdTimelineResponses(agentId);
gate.restore();
await gate.waitForHeldTimelineResponse();
await expectReconnectingToastGone(page);
gate.releaseHeldTimelineResponses();
await expect(page.getByRole("alert").filter({ hasText: "Updating messages" })).toHaveCount(0);
}
test("reconnecting a focused chat preserves its unread completion until another click", async ({
page,
workspaces,
}) => {
const gate = await installDaemonWebSocketGate(page);
await openAgentRoute(page, workspaces.subject);
await expect(page.getByText("Finish this turn.", { exact: true })).toBeVisible();
await workspaces.subject.client.sendAgentMessage(
workspaces.subject.agentId,
"Unread across reconnect.",
);
await workspaces.subject.client.waitForFinish(workspaces.subject.agentId, 20_000);
await expectStatus(page, workspaces.subject.workspaceId, "attention");
await reconnectWithoutInteracting(page, gate, workspaces.subject.agentId);
await expectStatus(page, workspaces.subject.workspaceId, "attention");
await page.getByText("Unread across reconnect.", { exact: true }).click();
await expectStatus(page, workspaces.subject.workspaceId, "done");
});
test("a cached read chat acknowledges a completion that arrived while the app was closed", async ({
page,
workspaces,
}) => {
await openCompactWorkspace(page, workspaces.subject);
await expectStatus(page, workspaces.subject.workspaceId, "done");
await closeMobileAgentSidebar(page);
await reopenWorkspaceAfterBackgroundTurn(page, workspaces.subject);
await expectRestoredCompactChat(page);
await openMobileAgentSidebar(page);
await expectStatus(page, workspaces.subject.workspaceId, "done");
});
@@ -6,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-" });
@@ -652,6 +702,9 @@ test.describe("Half-screen desktop layout", () => {
.locator('[data-testid^="workspace-pane-"]')
.getByTestId("workspace-tabs-row")
.filter({ visible: true }),
).toHaveCount(0);
await expect(
page.getByTestId("workspace-tab-switcher-trigger").filter({ visible: true }),
).toHaveCount(1);
await explorerToggle.click();
@@ -38,8 +38,12 @@ async function openParentInRightPane(page: Page, parentId: string, workspaceId:
await expect(left).toBeVisible();
await expect(right).toBeVisible();
await expect(right).not.toHaveAttribute("data-testid", "workspace-pane-main");
// A later split can replace the parent pane's rail with compact chrome.
// Keep the pane identity instead of finding it again through that rail.
const rightPaneId = await right.getAttribute("data-testid");
expect(rightPaneId).not.toBeNull();
await left.locator('[data-testid^="workspace-tab-draft_"]').click();
return { left, right };
return { left, right: page.getByTestId(rightPaneId!) };
}
test.beforeEach(async ({ page }) => {
@@ -1,3 +1,4 @@
import { getServerId } from "../support/helpers/server-id";
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
import { spawnSync } from "node:child_process";
import { readFile, writeFile } from "node:fs/promises";
@@ -413,6 +414,8 @@ test("searches retained output without sending Find input to the shell", async (
test("keeps terminal Find owned by retained tabs and the focused split pane", async ({
page,
}, testInfo) => {
// This journey selects tabs from the full rails in both split panes.
await page.setViewportSize({ width: 1600, height: 900 });
const first = await harness.createTerminal({
name: "First search",
command: "bash",
@@ -487,3 +490,142 @@ test("keeps the shared Find controls usable in a compact dark browser", async ({
await page.getByRole("button", { name: "Close Find" }).click();
await expect(input(page)).toBeFocused();
});
// A phone browser installs xterm's document-level touch gesture listener too.
// Keep this journey touch-enabled so it catches duplicate scroll handling.
test.describe("terminal history with a finger", () => {
test.use({ isMobile: true, hasTouch: true, viewport: { width: 390, height: 844 } });
test("scrolls history once per finger drag", async ({ page }) => {
await openPhoneTerminalHistory(page);
await expectSingleFingerScroll(page, 24);
await expectSingleFingerScroll(page, 1);
await expectMouseWheelScrollsHistory(page);
});
for (const mode of ["alternate", "mouse"] as const) {
test(`preserves finger navigation in ${mode} input mode`, async ({ page }) => {
await openPhoneInputCapture(page, mode);
await expectFingerNavigationInput(page, mode);
});
}
});
async function expectFingerNavigationInput(page: Page, mode: "alternate" | "mouse") {
await dragTerminalWithFinger(page, 24, async () => {
const prefix = mode === "alternate" ? "TOUCH_BYTES:1b4f41" : "TOUCH_BYTES:1b5b3c3634";
await expect.poll(() => getTerminalBufferText(page)).toContain(prefix);
});
}
async function openPhoneTerminalHistory(page: Page) {
await harness.createTerminal({
name: "Finger scroll history",
command: "bash",
args: [
"--noprofile",
"--norc",
"-c",
"for i in $(seq 1 150); do echo HISTORY_$i; done; exec sleep 300",
],
});
await openPhoneTerminal(page);
await expect.poll(() => getTerminalBufferText(page)).toContain("HISTORY_150");
}
async function openPhoneInputCapture(page: Page, mode: "alternate" | "mouse") {
const escapes = mode === "alternate" ? "\x1b[?1049h\x1b[?1h" : "\x1b[?1000h\x1b[?1006h";
await harness.createTerminal({
name: "Touch application input",
command: process.execPath,
args: [
"-e",
`
process.stdin.setRawMode(true);
process.stdin.once("data", () => {
process.stdin.on("data", chunk => {
process.stdout.write("\\r\\nTOUCH_BYTES:" + chunk.toString("hex") + ":END\\r\\n");
});
process.stdout.write(${JSON.stringify(escapes)} + "TOUCH_INPUT_READY\\r\\n");
});
process.stdout.write("TOUCH_START_READY\\r\\n");
`,
],
});
await openPhoneTerminal(page);
await expect.poll(() => getTerminalBufferText(page)).toContain("TOUCH_START_READY");
await input(page).press("x");
await expect.poll(() => getTerminalBufferText(page)).toContain("TOUCH_INPUT_READY");
await expect
.poll(() => page.evaluate(() => window.__paseoTerminal!.buffer.active.type))
.toBe(mode === "alternate" ? "alternate" : "normal");
await expect
.poll(() => page.evaluate(() => window.__paseoTerminal!.modes.mouseTrackingMode))
.toBe(mode === "mouse" ? "vt200" : "none");
}
async function openPhoneTerminal(page: Page) {
await page.goto("/");
await page.getByRole("button", { name: "Open menu", exact: true }).click();
await page.getByTestId(`sidebar-workspace-row-${getServerId()}:${harness.workspaceId}`).click();
await expect(page.getByRole("textbox", { name: "Terminal input" })).toBeVisible();
await expect(page.getByTestId("terminal-surface").filter({ visible: true })).toBeVisible();
}
async function expectMouseWheelScrollsHistory(page: Page) {
const beforeWheel = (await viewport(page))!;
await page.mouse.move(200, 300);
await page.mouse.wheel(0, -120);
await expect.poll(() => viewport(page)).toBeLessThan(beforeWheel);
}
async function expectSingleFingerScroll(page: Page, moves: number) {
await dragTerminalWithFinger(page, moves, async ({ lineHeight, before }) => {
await expect.poll(async () => before - (await viewport(page))!).toBeGreaterThan(0);
const moved = before - (await viewport(page))!;
await test.info().attach("finger-scroll", {
body: JSON.stringify({ moves, distancePx: 120, lineHeight, rowsMoved: moved }),
contentType: "application/json",
});
expect(moved, "one gesture must not scroll twice its physical distance").toBeLessThanOrEqual(
Math.ceil(120 / lineHeight),
);
});
}
async function dragTerminalWithFinger(
page: Page,
moves: number,
inspect: (state: { lineHeight: number; before: number }) => Promise<void>,
) {
await test.step(`Drag history with ${moves} touch moves`, async () => {
const screen = page
.getByTestId("terminal-surface")
.filter({ visible: true })
.locator(".xterm-screen");
const box = await screen.boundingBox();
if (!box) throw new Error("Terminal screen is not visible");
const rows = await page.evaluate(() => window.__paseoTerminal!.rows);
const lineHeight = box.height / rows;
const before = (await viewport(page))!;
const session = await page.context().newCDPSession(page);
try {
const start = { x: box.x + box.width / 2, y: box.y + box.height / 3 };
await session.send("Input.dispatchTouchEvent", {
type: "touchStart",
touchPoints: [{ ...start, id: 1 }],
});
for (let step = 1; step <= moves; step++) {
await session.send("Input.dispatchTouchEvent", {
type: "touchMove",
touchPoints: [{ x: start.x, y: start.y + (120 * step) / moves, id: 1 }],
});
}
// Check before lifting the finger to exclude inertial scrolling.
await inspect({ lineHeight, before });
} finally {
await session.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
await session.detach();
}
});
}
@@ -45,6 +45,11 @@ process.stdin.on("data", (chunk) => {
for (;;) {
const paste = /\x1b\[200~([^]*?)\x1b\[201~/.exec(buffer);
if (!paste) break;
const pasted = JSON.stringify(paste[1]).replace(
/\s/g,
(space) => "\\u" + space.charCodeAt(0).toString(16).padStart(4, "0"),
);
process.stdout.write("PASTED:" + pasted + "\r\n");
for (const [, path] of paste[1].matchAll(/'([^']*)'/g)) {
const digest = crypto.createHash("sha256").update(fs.readFileSync(path)).digest("hex");
const location = /[\\/]uploads[\\/]upload_/.test(path) ? "upload" : "local";
@@ -202,6 +207,21 @@ test.describe("Terminal protocol queries", () => {
await expect.poll(() => terminalOutput(terminalId), { timeout: 10_000 }).toContain(text);
}
// Every bracketed paste body the receiver consumed, in order and joined: what a
// shell would read as one command line.
async function pastedText(terminalId: string): Promise<string> {
const output = await terminalOutput(terminalId);
return [...output.matchAll(/PASTED:("(?:[^"\\]|\\.)*")/g)]
.map(([, json]) => JSON.parse(json) as string)
.join("");
}
// Exactly one ASCII space after each quoted path and nothing else between them,
// so the paths stay separate shell words.
async function expectSeparatePastedPaths(terminalId: string): Promise<void> {
await expect.poll(() => pastedText(terminalId)).toMatch(/^'[^']+' '[^']+' $/);
}
test("pastes images from two panes into their own terminals as uploaded host files", async ({
page,
}) => {
@@ -238,6 +258,23 @@ test.describe("Terminal protocol queries", () => {
await expectTerminalOutput(terminal.id, fileRead(pngBytes(3), "upload"));
await expectTerminalOutput(terminal.id, fileRead(Buffer.from("remote notes"), "upload"));
await expectSeparatePastedPaths(terminal.id);
} finally {
await harness.killTerminal(terminal.id);
}
});
test("keeps the paths of two consecutive drops to another machine apart", async ({ page }) => {
const terminal = await openFileReceiver(page, "file-drop-remote-twice");
const first = { name: "first.png", type: "image/png", bytes: pngBytes(10) };
const second = { name: "second.txt", type: "text/plain", bytes: Buffer.from("second drop") };
try {
await dropFiles(page, [first]);
await expectTerminalOutput(terminal.id, fileRead(first.bytes, "upload"));
await dropFiles(page, [second]);
await expectTerminalOutput(terminal.id, fileRead(second.bytes, "upload"));
await expectSeparatePastedPaths(terminal.id);
} finally {
await harness.killTerminal(terminal.id);
}
+155 -17
View File
@@ -2,12 +2,14 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import path from "node:path";
import {
holdOlderHistoryPages,
scrollTimelineRowIntoView,
userScrollsTimelineToHistoryStart,
} from "../support/helpers/timeline-pagination";
import { getActiveTabTestId } from "../support/helpers/launcher";
import type { Page, Locator } from "@playwright/test";
import { test, expect } from "../support/fixtures";
import { expectComposerVisible } from "../support/helpers/composer";
import { withDirectoryPlugin } from "../support/helpers/plugin-timeline";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
test.use({
@@ -49,15 +51,36 @@ async function seedAnsweredAgent(title: string) {
}
/** Chat rows between the latest turn's summary line and its reply, in document order. */
async function rowsBetweenLatestSummaryAndReply(page: Page): Promise<number> {
return page.evaluate(() => {
async function rowsBetweenLatestSummaryAndReply(
page: Page,
replyTestId = "assistant-message",
): Promise<number> {
return page.evaluate((testId) => {
const rows = [...document.querySelectorAll("[data-message-id]")];
const summaryRow = rows.findLastIndex((row) => row.querySelector("[data-testid=turn-summary]"));
const replyRow = rows.findLastIndex((row) =>
row.querySelector("[data-testid=assistant-message]"),
);
const replyRow = rows.findLastIndex((row) => row.querySelector(`[data-testid="${testId}"]`));
return replyRow - summaryRow - 1;
});
}, replyTestId);
}
/** The latest turn's summary line is a chat row of its own, sitting apart from its prompt. */
async function expectSummaryLineSitsApartFromPrompt(page: Page) {
const summaryRow = () =>
page.evaluate(() => {
const latest = (testId: string) =>
[...document.querySelectorAll(`[data-testid="${testId}"]`)].at(-1)!;
const summary = latest("turn-summary");
const row = summary.closest("[data-message-id]")!;
return {
sharesRowWithPrompt: row.querySelector("[data-testid=user-message]") !== null,
gapBelowPrompt: Math.round(
summary.getBoundingClientRect().top -
latest("user-message").getBoundingClientRect().bottom,
),
};
});
await expect.poll(summaryRow).toMatchObject({ sharesRowWithPrompt: false });
expect((await summaryRow()).gapBelowPrompt).toBeGreaterThan(0);
}
async function groupedToolsInsideLatestTurn(page: Page): Promise<string[]> {
@@ -125,6 +148,67 @@ async function expandAndCollapseLongPrompt(page: Page) {
}
test.describe("Turn collapse", () => {
test("folds work before a plugin-rendered reply and reopens it", async ({ page }, info) => {
test.setTimeout(120_000);
await useChatDisplay(page, "collapseTurns");
const agent = await seedMockAgentWorkspace({
repoPrefix: "plugin-reply-fold-",
title: "Plugin reply folding",
model: "ten-second-stream",
initialPrompt: "Make the fix and run the burst script.",
});
try {
await agent.client.waitForFinish(agent.agentId, 30_000);
await withDirectoryPlugin({
pluginId: "reply-fold-e2e",
source: `import React from "react";
import { Text } from "react-native";
import { z } from "zod";
export default function contribute(client) {
client.addTimelineTransformer({
id: "reply", query: { itemType: "assistant_message" },
transform({ item, phase }) {
if (phase === "streaming") return undefined;
return { items: [{ type: "plugin", kind: "reply", version: 1, data: { text: item.text } }] };
},
});
client.addTimelineRenderer({
kind: "reply", version: 1, schema: z.object({ text: z.string() }),
Component: ({ item }) => <Text testID="plugin-reply">{item.data.text}</Text>,
});
return () => {};
}`,
run: async () => {
await page.setViewportSize({ width: 1280, height: 1000 });
await openAgentRoute(page, agent);
await expectComposerVisible(page);
for (const width of [1280, 390]) {
await page.setViewportSize({ width, height: 1000 });
const summary = page.getByTestId("turn-summary").last().getByRole("button");
const reply = page.getByTestId("plugin-reply").last();
await expect(summary).toBeVisible();
await expect(reply).toContainText("(end of synthetic stream)");
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page, "plugin-reply")).toBe(0);
await page.screenshot({ path: info.outputPath(`plugin-reply-folded-${width}.png`) });
await summary.click();
await expect
.poll(() => rowsBetweenLatestSummaryAndReply(page, "plugin-reply"))
.toBeGreaterThan(0);
await expect(reply).toContainText("(end of synthetic stream)");
await summary.click();
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page, "plugin-reply")).toBe(0);
}
await page.reload();
await expect(page.getByTestId("turn-summary").last()).toBeVisible();
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page, "plugin-reply")).toBe(0);
},
});
} finally {
await agent.cleanup();
}
});
test("folds each answered turn behind a summary line that opens it", async ({ page }) => {
test.setTimeout(120_000);
await page.setViewportSize({ width: 1280, height: 1000 });
@@ -144,6 +228,7 @@ test.describe("Turn collapse", () => {
/\(end of synthetic stream\)$/,
);
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
await expectSummaryLineSitsApartFromPrompt(page);
await summary.click();
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBeGreaterThan(0);
@@ -343,7 +428,6 @@ test("opening a turn keeps the received message above it clipped on every frame"
.getByTestId("user-message")
.filter({ has: page.getByRole("button", { name: "Sent by Reviewer", exact: true }) });
await expect(bubble.getByRole("button", { name: "Show more", exact: true })).toBeVisible();
await bubble.scrollIntoViewIfNeeded();
await watchReceivedMessagePreview(page, "Sent by Reviewer");
await page
@@ -407,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 {
@@ -529,6 +666,7 @@ async function expectThinkingPresentation(page: Page, autoExpandReasoning: boole
.last();
await expect(group).toContainText(/read 1 file/i);
await expect(group.getByRole("button").first()).not.toContainText(/thought/i);
await scrollTimelineRowIntoView(page, group);
await group.getByRole("button").first().click();
// Expand thinking is off: the group opens its rows, but the thought row stays closed
// until the reader opens it.
@@ -8,7 +8,7 @@ import {
openProjectViaDaemon,
} from "../support/helpers/new-workspace";
import { getServerId } from "../support/helpers/server-id";
import { openSettingsHostSection } from "../support/helpers/settings";
import { openCompactSettings, openSettingsHostSection } from "../support/helpers/settings";
import {
expectFooterItemSetting,
expectFooterSeparator,
@@ -42,6 +42,7 @@ import {
import { seedSidebarFooterPreferences } from "../support/helpers/sidebar-nav-settings";
import { waitForSettledPosition } from "../support/helpers/sheet-layout";
import { createTempGitRepo } from "../support/helpers/workspace";
import { buildOpenProjectRoute } from "@/utils/host-routes";
const WIDE = { width: 1440, height: 900 };
const COMPACT = { width: 390, height: 844 };
@@ -70,7 +71,112 @@ async function footerClip(page: Page) {
return (await page.locator('[data-testid="sidebar-footer"]:visible').boundingBox())!;
}
async function setPageVisibility(page: Page, visibility: DocumentVisibilityState): Promise<void> {
await page.evaluate((state) => {
Object.defineProperty(document, "visibilityState", { configurable: true, get: () => state });
Object.defineProperty(document, "hidden", {
configurable: true,
get: () => state === "hidden",
});
document.dispatchEvent(new Event("visibilitychange"));
}, visibility);
}
test.describe("Usage item", () => {
test("visible summary refreshes every five minutes without opening Usage", async ({ page }) => {
let usedPct = 31;
const usage = await installUsageReportsFixture(page, {
lists: [
() => [
{
...claudeAndCodexReports()[0]!,
report: {
status: "available",
windows: [
{ id: "five_hour", label: "Session", shortLabel: "5h", summary: true, usedPct },
],
},
},
],
],
});
await page.clock.install();
await page.setViewportSize(WIDE);
await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]);
await gotoAppShell(page);
await expectPinnedUsage(page, ["31% 5h"]);
const startingUrl = page.url();
await qaScreenshot(page, "usage-auto-refresh-initial", { kind: "footer" });
const requestsBeforeRefresh = usage.listRequests();
usedPct = 42;
await page.clock.fastForward(5 * 60_000);
await qaScreenshot(page, "usage-auto-refresh-after-interval", { kind: "footer" });
await expectPinnedUsage(page, ["42% 5h"]);
await expect(usageModal(page)).toHaveCount(0);
await expect(page).toHaveURL(startingUrl);
expect(usage.listRequests()).toEqual([
...requestsBeforeRefresh,
{ forceRefresh: false, reportIds: undefined, agentId: undefined },
]);
await setPageVisibility(page, "hidden");
const requestsBeforeBackgrounding = usage.listRequests();
usedPct = 47;
await page.clock.fastForward(5 * 60_000);
expect(usage.listRequests()).toEqual(requestsBeforeBackgrounding);
await expectPinnedUsage(page, ["42% 5h"]);
await setPageVisibility(page, "visible");
await page.clock.fastForward(5 * 60_000);
await expectPinnedUsage(page, ["47% 5h"]);
expect(usage.listRequests()).toEqual([
...requestsBeforeBackgrounding,
{ forceRefresh: false, reportIds: undefined, agentId: undefined },
]);
await page.setViewportSize(COMPACT);
await expect(usageItem(page)).toHaveCount(0);
const requestsBeforeHiding = usage.listRequests();
await page.clock.fastForward(5 * 60_000);
expect(usage.listRequests()).toEqual(requestsBeforeHiding);
await openCompactSidebar(page);
await expectPinnedUsage(page, ["47% 5h"]);
await expect(page.getByTestId("agent-list-backdrop")).toBeVisible();
usedPct = 53;
await page.clock.fastForward(5 * 60_000);
await expectPinnedUsage(page, ["53% 5h"]);
await expect(usageModal(page)).toHaveCount(0);
await page.setViewportSize(WIDE);
await openSidebarNavSettings(page);
await setFooterItemVisible(page, "usage", false);
await leaveSettings(page);
await expectNoUsageItem(page);
const requestsBeforeDisabling = usage.listRequests();
await page.clock.fastForward(5 * 60_000);
expect(usage.listRequests()).toEqual(requestsBeforeDisabling);
});
test("Usage settings do not poll reports while the sidebar is inactive", async ({ page }) => {
const usage = await installUsageReportsFixture(page, {
lists: [() => claudeAndCodexReports()],
});
await page.clock.install();
await page.setViewportSize(COMPACT);
await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]);
await gotoAppShell(page);
await openCompactSettings(page, buildOpenProjectRoute());
await openSettingsHostSection(page, getServerId(), "usage");
await expect(page.getByTestId("usage-card").getByText("31% used")).toBeVisible();
await expect(usageItem(page)).toHaveCount(0);
const requestsBeforeWaiting = usage.listRequests();
await page.clock.fastForward(5 * 60_000);
expect(usage.listRequests()).toEqual(requestsBeforeWaiting);
await expect(page.getByTestId("usage-card").getByText("31% used")).toBeVisible();
});
test("both compact footer entry points open Usage as a sheet", async ({ page }) => {
await page.setViewportSize(COMPACT);
await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
@@ -357,6 +357,8 @@ test.describe("Viewed agent timelines", () => {
});
test("two visible split chats both stay current", async ({ page }) => {
// Keep both split panes wide enough for the tab-rail interactions below.
await page.setViewportSize({ width: 1600, height: 900 });
const scenario = await seedViewedTimelineScenario();
try {
await enableMoveTabShortcut(page);
@@ -0,0 +1,187 @@
import type { Page } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import {
archiveWorkspaceFromDaemon,
connectNewWorkspaceDaemonClient,
createWorktreeViaDaemon,
openProjectViaDaemon,
} from "../support/helpers/new-workspace";
import { getServerId } from "../support/helpers/server-id";
import { expectWorkspaceAbsentFromSidebar } from "../support/helpers/sidebar";
import { createMockIdleAgent, openWorkspaceWithAgents } from "../support/helpers/archive-tab";
import { connectSeedClient } from "../support/helpers/seed-client";
import { createTempGitRepo } from "../support/helpers/workspace";
import { getWorkspaceTabTestIds } from "../support/helpers/workspace-tabs";
import {
archiveFromRowButton,
createGatedTeardown,
expectDirectoryPresence,
expectHostWorkspaceSettledActive,
outlastUndoWindow,
pressUndoShortcut,
rowArchiveButton,
rowKebab,
undoButton,
workspaceRow,
workspaceTitleWidth,
} from "../support/helpers/workspace-archive-undo";
import {
waitForSidebarHydration,
waitForWorkspaceInSidebar,
} from "../support/helpers/workspace-ui";
test.describe("Workspace archive undo", () => {
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
let tempRepo: { path: string; cleanup: () => Promise<void> };
const worktreeDirectories = new Set<string>();
test.describe.configure({ timeout: 120_000 });
test.beforeEach(async () => {
client = await connectNewWorkspaceDaemonClient();
tempRepo = await createTempGitRepo("archive-undo-");
});
test.afterEach(async () => {
for (const directory of worktreeDirectories) {
await archiveWorkspaceFromDaemon(client, directory).catch(() => undefined);
}
worktreeDirectories.clear();
await client?.close().catch(() => undefined);
await tempRepo?.cleanup().catch(() => undefined);
});
async function seedWorktree(page: Page, slug: string) {
await openProjectViaDaemon(client, tempRepo.path);
const worktree = await createWorktreeViaDaemon(client, {
cwd: tempRepo.path,
slug: `${slug}-${Date.now()}`,
});
worktreeDirectories.add(worktree.workspaceDirectory);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await waitForWorkspaceInSidebar(page, {
serverId: getServerId(),
workspaceId: worktree.workspaceId,
});
return worktree;
}
test("the hover archive button overlays the title instead of narrowing it", async ({ page }) => {
const worktree = await seedWorktree(page, "archive-button-overlay");
await page.mouse.move(0, 0);
await expect(rowArchiveButton(page, worktree.workspaceId)).toHaveCount(0);
const restingWidth = await workspaceTitleWidth(page, worktree.workspaceId);
await workspaceRow(page, worktree.workspaceId).hover();
await expect(rowArchiveButton(page, worktree.workspaceId)).toBeVisible();
await expect(page.getByTestId("sidebar-workspace-trailing-scrim")).toBeVisible();
expect(await workspaceTitleWidth(page, worktree.workspaceId)).toBe(restingWidth);
const [buttonBox, kebabBox] = await Promise.all([
rowArchiveButton(page, worktree.workspaceId).boundingBox(),
rowKebab(page, worktree.workspaceId).boundingBox(),
]);
expect(buttonBox && kebabBox && buttonBox.x + buttonBox.width <= kebabBox.x).toBe(true);
});
test("Cmd/Ctrl+Z right after archiving keeps the worktree", async ({ page }) => {
const worktree = await seedWorktree(page, "archive-undo-shortcut");
await archiveFromRowButton(page, worktree.workspaceId);
await pressUndoShortcut(page);
await expect(workspaceRow(page, worktree.workspaceId)).toBeVisible();
await expect(undoButton(page)).toHaveCount(0);
await expectHostWorkspaceSettledActive(client, worktree.workspaceId);
// Once the host settled, any remove the canceled archive sent has already arrived.
await expect(workspaceRow(page, worktree.workspaceId)).toBeVisible();
await expectDirectoryPresence(worktree.workspaceDirectory, true);
});
test("Undo after the worktree was removed recreates it", async ({ page }) => {
const worktree = await seedWorktree(page, "archive-undo-recreate");
await archiveFromRowButton(page, worktree.workspaceId);
// Hovering the toast holds it, and with it the undo, open while the archive finishes.
await undoButton(page).hover();
await expectDirectoryPresence(worktree.workspaceDirectory, false);
await undoButton(page).click();
await expect(workspaceRow(page, worktree.workspaceId)).toBeVisible({ timeout: 30_000 });
await expectDirectoryPresence(worktree.workspaceDirectory, true);
await expectHostWorkspaceSettledActive(client, worktree.workspaceId);
});
test("hovering Undo keeps the toast open past its deadline, and its close ends the undo", async ({
page,
}) => {
await page.clock.install({ time: Date.now() });
const worktree = await seedWorktree(page, "archive-undo-hold");
await archiveFromRowButton(page, worktree.workspaceId);
await page.getByTestId("workspace-archived-toast").hover({ position: { x: 8, y: 8 } });
await undoButton(page).hover();
await outlastUndoWindow(page);
await expect(undoButton(page)).toBeVisible();
await page.mouse.move(0, 0);
await outlastUndoWindow(page);
await expect(undoButton(page)).toHaveCount(0);
// The toast took the undo with it.
await pressUndoShortcut(page);
await expectWorkspaceAbsentFromSidebar(page, worktree.workspaceId);
});
test("opening the workspace while its undo is still running keeps its tabs", async ({ page }) => {
const teardown = createGatedTeardown();
const repo = await createTempGitRepo("archive-undo-tabs-", {
paseoConfig: teardown.paseoConfig,
});
const seedClient = await connectSeedClient();
try {
await openProjectViaDaemon(client, repo.path);
const worktree = await createWorktreeViaDaemon(client, {
cwd: repo.path,
slug: `archive-undo-tabs-${Date.now()}`,
});
worktreeDirectories.add(worktree.workspaceDirectory);
const target = { cwd: worktree.workspaceDirectory, workspaceId: worktree.workspaceId };
const agents = [
await createMockIdleAgent(seedClient, { ...target, title: "first" }),
await createMockIdleAgent(seedClient, { ...target, title: "second" }),
] as const;
await openWorkspaceWithAgents(page, [agents[0], agents[1]]);
const tabsBefore = await getWorkspaceTabTestIds(page);
await archiveFromRowButton(page, worktree.workspaceId);
// The archive has taken its agents down and now parks in teardown.
await teardown.expectStarted();
await undoButton(page).click();
await workspaceRow(page, worktree.workspaceId).click();
for (const agent of agents) {
await expect(page.getByTestId(`workspace-tab-agent_${agent.id}`).first()).toBeVisible();
}
teardown.release();
await expectHostWorkspaceSettledActive(client, worktree.workspaceId);
await expect.poll(() => getWorkspaceTabTestIds(page)).toEqual(tabsBefore);
} finally {
teardown.release();
await seedClient.close().catch(() => undefined);
await repo.cleanup().catch(() => undefined);
}
});
test("Cmd/Ctrl+Z without an archive to undo leaves the sidebar alone", async ({ page }) => {
const worktree = await seedWorktree(page, "archive-undo-none");
await pressUndoShortcut(page);
await expect(undoButton(page)).toHaveCount(0);
await expect(workspaceRow(page, worktree.workspaceId)).toBeVisible();
});
});
@@ -101,3 +101,20 @@ test("focus mode only applies to the active workspace screen", async ({ page, wi
await expect(exitFocusMode).toHaveCount(0);
await expect(settingsButton).toBeVisible();
});
async function exitFocusModeFromCompactHeader(page: Page) {
await page.setViewportSize({ width: 400, height: 844 });
await page.getByTestId("workspace-header-menu-trigger").click();
await page.getByRole("menuitem", { name: "Exit focus mode", exact: true }).click();
await page.setViewportSize({ width: 1280, height: 900 });
}
test("compact workspace menu keeps focus mode exit reachable", async ({ page, withWorkspace }) => {
const workspace = await withWorkspace({ prefix: "compact-focus-exit-" });
await page.setViewportSize({ width: 1280, height: 900 });
await workspace.navigateTo();
await enterFocusMode(page);
await exitFocusModeFromCompactHeader(page);
await expect(exitFocusModeButton(page)).toHaveCount(0);
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
});
@@ -52,6 +52,8 @@ async function waitForWorkspaceRoute(page: Page, route: string) {
test.describe("Workspace pane mounting", () => {
test("workspace navigation keeps the existing agent composer mounted", async ({ page }) => {
test.setTimeout(90_000);
// Keep both split panes wide enough for the tab-rail interactions below.
await page.setViewportSize({ width: 1600, height: 900 });
const serverId = getServerId();
const workspace = await seedWorkspace({ repoPrefix: "pane-remount-" });
@@ -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();
}
});
});
@@ -509,6 +509,8 @@ check_autocomplete() {
fi
fi
ad wait 'id="composer-autocomplete-popover"' 10000
# Rows replace the popover's loading row once the host lists them; a cold Claude list is slow.
ad wait "text=\"${expected}\"" 30000
# The popover remeasures once keyboard motion settles.
sleep 1
capture_screen "${prefix}.png"
@@ -69,17 +69,23 @@ async function waitFor(name, predicate, timeoutMs = 10_000) {
}
}
// Keyboard motion has settled once two snapshots agree on the composer position.
// Keyboard motion has settled once two snapshots agree on the composer position. The
// popover shows a loading row until the host lists its commands; a cold Claude list is slow.
async function settledPlacement(name, keyboard) {
let previousTop;
return waitFor(name, (snap) => {
const composer = nodeById(snap, "message-input-root");
if (!nodeById(snap, "composer-autocomplete-popover") || !composer) return false;
if (keyboardShown(snap) !== keyboard) return false;
const stable = composer.rect.y === previousTop;
previousTop = composer.rect.y;
return stable;
});
return waitFor(
name,
(snap) => {
const composer = nodeById(snap, "message-input-root");
if (!nodeById(snap, "composer-autocomplete-popover") || !composer) return false;
if (nodeById(snap, "composer-autocomplete-loading")) return false;
if (keyboardShown(snap) !== keyboard) return false;
const stable = composer.rect.y === previousTop;
previousTop = composer.rect.y;
return stable;
},
30_000,
);
}
function assertPlacement(name, snap, expected) {
@@ -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));
@@ -99,6 +99,7 @@ export async function startCommandCenterLayoutObservation(page: Page): Promise<v
const observedElements = new WeakSet<Element>();
const resizeObserver = new ResizeObserver(record);
const mutationObserver = new MutationObserver(discover);
let performanceObserver: PerformanceObserver | undefined;
function record(): void {
if (!observation.active) return;
@@ -132,6 +133,7 @@ export async function startCommandCenterLayoutObservation(page: Page): Promise<v
if (!observation.active) {
mutationObserver.disconnect();
resizeObserver.disconnect();
performanceObserver?.disconnect();
return;
}
record();
@@ -141,9 +143,16 @@ export async function startCommandCenterLayoutObservation(page: Page): Promise<v
if (typeof PerformanceObserver !== "undefined") {
try {
const performanceObserver = new PerformanceObserver((list) => {
performanceObserver = new PerformanceObserver((list) => {
const panel = document.querySelector(selectors.panel);
for (const entry of list.getEntries()) {
observation.layoutShift += (entry as PerformanceEntry & { value?: number }).value ?? 0;
const shift = entry as PerformanceEntry & {
value: number;
sources: Array<{ node: Node | null }>;
};
if (shift.sources.some((source) => source.node && panel?.contains(source.node))) {
observation.layoutShift += shift.value;
}
}
});
performanceObserver.observe({ type: "layout-shift", buffered: false });
@@ -174,7 +183,54 @@ export async function expectStableCommandCenterLayout(page: Page): Promise<void>
).toBeLessThanOrEqual(1);
}
}
expect(observation.layoutShift, JSON.stringify(observation)).toBeLessThanOrEqual(0.001);
expect(
observation.layoutShift,
`command center layoutShift: ${JSON.stringify(observation)}`,
).toBeLessThanOrEqual(0.001);
}
/** Exercise an unrelated page shift while the real search panel is being observed. */
export async function shiftOutsideCommandCenter(page: Page): Promise<void> {
await shiftLayoutProbe(page.locator("body"));
}
export async function expectLayoutObservationToCatchPanelShift(page: Page): Promise<void> {
await startCommandCenterLayoutObservation(page);
await shiftLayoutProbe(page.getByTestId("command-center-panel"));
await expect(expectStableCommandCenterLayout(page)).rejects.toThrow("command center layoutShift");
}
async function shiftLayoutProbe(parent: Locator): Promise<void> {
const recordedShift = await parent.evaluate(async (container) => {
const sibling = document.createElement("div");
sibling.textContent = "Layout probe";
sibling.style.cssText =
"position:fixed;left:0;top:0;width:300px;height:100px;background:white;z-index:99999";
container.append(sibling);
await new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
);
const value = await new Promise<number>((resolve) => {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
const shift = entry as PerformanceEntry & {
value: number;
sources: Array<{ node: Node | null }>;
};
if (shift.sources.some((source) => source.node === sibling)) {
observer.disconnect();
resolve(shift.value);
}
}
});
observer.observe({ type: "layout-shift", buffered: false });
sibling.style.left = "400px";
});
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
sibling.remove();
return value;
});
expect(recordedShift, "the browser recorded a substantial probe shift").toBeGreaterThan(0.001);
}
export async function expectOneLineTruncatedFileResult(
@@ -53,3 +53,192 @@ export async function expectNoCollapsedComposerToolbarFrame(page: Page): Promise
const collapsed = frames.filter((frame) => frame.labels.some((label) => label === ""));
expect(collapsed, `collapsed toolbar frames: ${JSON.stringify(collapsed)}`).toEqual([]);
}
/** What the folded model control's labels show, read from the DOM. */
export interface ModelTriggerLabelState {
thinkingShown: boolean;
modelShown: boolean;
modelText: string;
modelTruncated: boolean;
}
declare global {
interface Window {
__modelTriggerFrames?: ModelTriggerLabelState[];
}
}
function readModelTriggerLabelStateInPage(): ModelTriggerLabelState {
const visible = (node: Element) => node.getClientRects().length > 0;
const model = Array.from(
document.querySelectorAll('[data-testid="combined-model-selector-model-label"]'),
).find(visible);
const thinking = Array.from(
document.querySelectorAll('[data-testid="combined-model-selector-thinking-label"]'),
).find(visible);
const shown = (node: Element | null): boolean => {
for (let current = node; current; current = current.parentElement) {
if (Number(getComputedStyle(current).opacity) === 0) return false;
}
return true;
};
const modelShown = model !== undefined && shown(model);
const modelText = model?.textContent ?? "";
return {
modelShown,
modelText,
thinkingShown: modelShown && thinking !== undefined,
modelTruncated:
model !== undefined && (modelText.endsWith("…") || model.scrollWidth > model.clientWidth),
};
}
/** Records the folded model control's labels on every painted frame from page load. */
export async function recordModelTriggerFramesFromLoad(page: Page): Promise<void> {
await page.addInitScript(`(() => {
const read = ${readModelTriggerLabelStateInPage.toString()};
const frames = [];
window.__modelTriggerFrames = frames;
const tick = () => {
frames.push(read());
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
})()`);
}
/** The model control's labels once two reads 100ms apart agree. */
export async function readSettledModelTriggerLabels(page: Page): Promise<ModelTriggerLabelState> {
let previous = await page.evaluate(readModelTriggerLabelStateInPage);
for (;;) {
await page.waitForTimeout(100);
const next = await page.evaluate(readModelTriggerLabelStateInPage);
if (JSON.stringify(next) === JSON.stringify(previous)) return next;
previous = next;
}
}
/** The labels at the widths where the folded model control changes what it shows. */
export interface ModelTriggerLabelSweep {
/** The labels at the widest width. */
widest: ModelTriggerLabelState & { width: number };
/** The first width at which the thinking label is hidden. */
thinkingHidden: ModelTriggerLabelState & { width: number };
/** The first width at which the model label is truncated. */
modelTruncated: ModelTriggerLabelState & { width: number };
}
/**
* Narrows the viewport step by step and records where the thinking label disappears and where the
* model label first truncates.
*/
export async function sweepModelTriggerLabels(
page: Page,
widths: { from: number; to: number; step: number; height: number },
): Promise<ModelTriggerLabelSweep> {
const steps: (ModelTriggerLabelState & { width: number })[] = [];
for (let width = widths.from; width >= widths.to; width -= widths.step) {
await page.setViewportSize({ width, height: widths.height });
const labels = await readSettledModelTriggerLabels(page);
steps.push({ ...labels, width });
if (labels.modelTruncated || !labels.modelShown) break;
}
const thinkingHidden = steps.find((step) => !step.thinkingShown);
const modelTruncated = steps.find((step) => step.modelTruncated);
if (!thinkingHidden || !modelTruncated) {
throw new Error(`the sweep never hid the thinking label and truncated the model label`);
}
return { widest: steps[0], thinkingHidden, modelTruncated };
}
/** No frame recorded so far painted a truncated model label beside the thinking label. */
export async function expectNoFrameTruncatesModelBesideThinking(page: Page): Promise<void> {
const frames = await page.evaluate(() => window.__modelTriggerFrames ?? []);
expect(frames.some((frame) => frame.modelShown)).toBe(true);
const wrong = frames
.map((frame, index) => ({ ...frame, index }))
.filter((frame) => frame.thinkingShown && frame.modelTruncated);
expect(wrong, `of ${frames.length} frames`).toEqual([]);
}
/** Where the composer toolbar's ends sit against the composer, in CSS pixels. */
export interface ComposerToolbarRails {
/** Where the text column starts. */
textLeft: number;
/** Where the toolbar's leftmost ink starts. */
inkLeft: number;
/** How far the trailing disc sits from the composer's right edge. */
discRightInset: number;
/** How far the trailing disc sits from the composer's bottom edge. */
discBottomInset: number;
}
/**
* Reads where the toolbar's ink starts against the text column and where the trailing disc sits
* in the composer's corner. A control's ink is its filled disc when it has one, otherwise its
* glyph's stroked geometry.
*/
export async function readComposerToolbarRails(page: Page): Promise<ComposerToolbarRails> {
const root = page.locator('[data-testid="message-input-root"]:visible').first();
return root.evaluate((element) => {
const textarea = element.querySelector("textarea");
if (!textarea) throw new Error("composer has no text input");
const composerRect = element.getBoundingClientRect();
const textLeft =
textarea.getBoundingClientRect().left +
Number.parseFloat(getComputedStyle(textarea).paddingLeft);
const isFilled = (node: Element) => {
const color = getComputedStyle(node).backgroundColor;
return color !== "" && color !== "transparent" && !/rgba\(.*,\s*0\)$/.test(color);
};
const glyphInkLeft = (svg: SVGSVGElement) => {
let left = Number.POSITIVE_INFINITY;
for (const shape of Array.from(
svg.querySelectorAll<SVGGraphicsElement>("path, circle, rect, line, polyline"),
)) {
const matrix = shape.getScreenCTM();
if (!matrix) continue;
const box = shape.getBBox();
const stroke = Number.parseFloat(getComputedStyle(shape).strokeWidth || "0");
left = Math.min(left, matrix.a * (box.x - stroke / 2) + matrix.e);
}
return left;
};
const band = composerRect.bottom - 64;
let inkLeft = Number.POSITIVE_INFINITY;
let trailingDisc: DOMRect | null = null;
for (const control of Array.from(element.querySelectorAll('[role="button"]'))) {
const rect = control.getBoundingClientRect();
if (rect.width === 0 || rect.top < band) continue;
const disc = [control, ...Array.from(control.querySelectorAll("div"))].find(isFilled);
const svg = control.querySelector("svg");
if (disc) {
const discRect = disc.getBoundingClientRect();
inkLeft = Math.min(inkLeft, discRect.left);
if (!trailingDisc || discRect.right > trailingDisc.right) trailingDisc = discRect;
} else if (svg) {
inkLeft = Math.min(inkLeft, glyphInkLeft(svg));
}
}
if (!trailingDisc) throw new Error("composer toolbar has no trailing disc");
return {
textLeft,
inkLeft,
discRightInset: composerRect.right - trailingDisc.right,
discBottomInset: composerRect.bottom - trailingDisc.bottom,
};
});
}
/**
* The toolbar's left ink sits on the text column's edge, and its trailing disc nests into the
* corner, as far from the right edge as from the bottom edge. Within half a pixel.
*/
export async function expectComposerToolbarOnTextRails(page: Page): Promise<void> {
const rails = await readComposerToolbarRails(page);
// A precision of 0 digits passes within half a pixel.
expect(rails.inkLeft).toBeCloseTo(rails.textLeft, 0);
expect(rails.discRightInset).toBeCloseTo(rails.discBottomInset, 0);
}
+77 -10
View File
@@ -31,7 +31,9 @@ export async function expectComposerVisible(
page: Page,
options?: { timeout?: number },
): Promise<void> {
await expect(composerInput(page)).toBeVisible({ timeout: options?.timeout ?? 15_000 });
await expect(composerInput(page)).toBeVisible({
timeout: options?.timeout ?? 15_000,
});
}
export async function expectComposerDisabled(page: Page): Promise<void> {
@@ -156,7 +158,9 @@ export async function attachImageFromMenu(
}
export async function expectAttachmentPill(page: Page, testID: string): Promise<void> {
await expect(page.getByTestId(testID).first()).toBeVisible({ timeout: 10_000 });
await expect(page.getByTestId(testID).first()).toBeVisible({
timeout: 10_000,
});
}
export async function attachFileFromMenu(
@@ -218,18 +222,24 @@ export async function expectGithubAttachmentPill(
export async function openImageLightbox(page: Page): Promise<void> {
await page.getByRole("button", { name: "Open image attachment" }).first().click();
await expect(page.getByTestId("attachment-lightbox-close")).toBeVisible({ timeout: 5_000 });
await expect(page.getByTestId("attachment-lightbox-close")).toBeVisible({
timeout: 5_000,
});
}
export async function closeImageLightbox(page: Page): Promise<void> {
await page.keyboard.press("Escape");
await expect(page.getByTestId("attachment-lightbox-close")).not.toBeVisible({ timeout: 5_000 });
await expect(page.getByTestId("attachment-lightbox-close")).not.toBeVisible({
timeout: 5_000,
});
}
export async function openGithubPickerFromMenu(page: Page): Promise<void> {
await openAttachmentMenu(page);
await page.getByTestId("message-input-attachment-menu-item-github").click();
await expect(page.getByTestId("combobox-desktop-container")).toBeVisible({ timeout: 5_000 });
await expect(page.getByTestId("combobox-desktop-container")).toBeVisible({
timeout: 5_000,
});
}
/** Open picker, type a query, wait for the matching option by id (e.g. "issue:3", "change_request:1"), and click it. */
@@ -283,7 +293,10 @@ export async function startRunningMockAgent(
model: opts.model,
featureValues: opts.featureValues,
});
const agentUrl = `${buildHostWorkspaceRoute(serverId, workspace.id)}?open=${encodeURIComponent(`agent:${agent.id}`)}`;
const agentUrl = `${buildHostWorkspaceRoute(
serverId,
workspace.id,
)}?open=${encodeURIComponent(`agent:${agent.id}`)}`;
await page.goto(agentUrl);
await expectComposerVisible(page);
await client.sendAgentMessage(agent.id, opts.prompt);
@@ -347,7 +360,11 @@ export async function pasteComposerTestImage(page: Page): Promise<boolean> {
const bytes = Uint8Array.from(atob(base64), (character) => character.charCodeAt(0));
const clipboardData = new DataTransfer();
clipboardData.items.add(new File([bytes], "pasted.png", { type: "image/png" }));
const event = new ClipboardEvent("paste", { clipboardData, bubbles: true, cancelable: true });
const event = new ClipboardEvent("paste", {
clipboardData,
bubbles: true,
cancelable: true,
});
input.dispatchEvent(event);
return event.defaultPrevented;
}, COMPOSER_TEST_IMAGE_BASE64);
@@ -505,7 +522,10 @@ export async function recordVisibleComposerGeometry(page: Page): Promise<void> {
export async function expectNoCollapsedVisibleComposer(page: Page): Promise<void> {
const frames = await page.evaluate(() => {
Reflect.get(window, "__stopComposerGeometry")?.();
return Reflect.get(window, "__composerGeometryFrames") as { input: number; root: number }[];
return Reflect.get(window, "__composerGeometryFrames") as {
input: number;
root: number;
}[];
});
expect(frames.length).toBeGreaterThan(0);
expect(frames.filter((frame) => frame.root < 42 || frame.input < 42)).toEqual([]);
@@ -516,7 +536,10 @@ export async function roundTripComposerTabBeforePaint(
page: Page,
activeChatTitle: string,
): Promise<void> {
const activeTab = page.getByRole("button", { name: activeChatTitle, exact: true });
const activeTab = page.getByRole("button", {
name: activeChatTitle,
exact: true,
});
await expect(activeTab).toHaveAttribute("aria-selected", "true");
const result = await activeTab.evaluate((tab) => {
const editor = document.activeElement;
@@ -556,7 +579,10 @@ export async function roundTripComposerTabBeforePaint(
});
}
});
observer.observe(tab, { attributes: true, attributeFilter: ["aria-selected"] });
observer.observe(tab, {
attributes: true,
attributeFilter: ["aria-selected"],
});
shortcut("BracketLeft");
});
});
@@ -566,3 +592,44 @@ export async function roundTripComposerTabBeforePaint(
).toBe(true);
expect(result.focused, "returning on touch must leave the composer unfocused").toBe(false);
}
/** Paste both clipboard representations through the browser's normal paste command. */
export async function pasteRichTextIntoComposer({
page,
html,
text,
}: {
page: Page;
html: string;
text: string;
}): Promise<void> {
await page.context().grantPermissions(["clipboard-read", "clipboard-write"]);
await page.evaluate(
async ({ html: richHtml, text: plainText }) => {
await navigator.clipboard.write([
new ClipboardItem({
"text/html": new Blob([richHtml], { type: "text/html" }),
"text/plain": new Blob([plainText], { type: "text/plain" }),
}),
]);
},
{ html, text },
);
await composerInput(page).press("ControlOrMeta+v");
}
export async function undoComposerEdit(page: Page): Promise<void> {
await composerInput(page).press("ControlOrMeta+z");
}
export async function redoComposerEdit(page: Page): Promise<void> {
await composerInput(page).press("ControlOrMeta+Shift+z");
}
/** Reuse the actual response Copy action and the browser's native paste. */
export async function copyLatestTurnIntoComposer(page: Page): Promise<void> {
await page.context().grantPermissions(["clipboard-read", "clipboard-write"]);
await page.getByRole("button", { name: "Copy turn", exact: true }).last().click();
await expect(page.getByRole("button", { name: "Copied", exact: true })).toBeVisible();
await composerInput(page).fill("");
await composerInput(page).press("ControlOrMeta+v");
}
@@ -148,3 +148,59 @@ export async function expectFullscreenDiagram(
export async function expectFullscreenDiagramClosed(page: Page): Promise<void> {
await expect(page.getByTestId(FULLSCREEN_VIEWPORT)).toHaveCount(0);
}
/** Tall diagrams stay within the screen so their controls remain reachable. */
export async function expectInlineDiagramFitsViewport(page: Page): Promise<void> {
const viewport = page.getByTestId("mermaid-viewport").last();
const box = await viewport.boundingBox();
expect(box, "the inline diagram has measurable layout").not.toBeNull();
const screenHeight = page.viewportSize()!.height;
expect(box!.height, "the inline diagram fits within the screen").toBeLessThan(screenHeight);
const contentHeight = await diagramSvg(page, "mermaid-viewport").evaluate(
(svg) => (svg as SVGSVGElement).viewBox.baseVal.height,
);
expect(box!.height, "a tall diagram is bounded rather than expanding the chat").toBeLessThan(
contentHeight,
);
}
/** Exercise pointer toggles and the controls encountered along that same journey. */
export async function toggleDiagramSourceAndVerifyControls(page: Page): Promise<void> {
const viewport = page.getByTestId("mermaid-viewport").last();
const iframe = await viewport.locator("iframe").elementHandle();
for (let iteration = 0; iteration < 3; iteration++) {
await viewport.hover();
await page.getByRole("button", { name: "View source" }).last().click();
const viewDiagram = page.getByRole("button", { name: "View diagram" }).last();
await viewDiagram.click({ timeout: 10_000 });
await expect(viewDiagram).toHaveCount(0);
await expectFullscreenDiagramClosed(page);
expect(
await viewport.locator("iframe").evaluate((node, original) => node === original, iframe),
"switching views preserves the rendered diagram",
).toBe(true);
}
await viewport.hover();
await page.getByRole("button", { name: "View source" }).last().click();
const hiddenFullscreen = viewport.getByRole("button", {
name: "Fullscreen",
includeHidden: true,
});
await hiddenFullscreen.focus();
await expect(hiddenFullscreen).not.toBeFocused();
await page.getByRole("button", { name: "View diagram" }).last().click();
await viewport.hover();
const fullscreen = viewport.getByRole("button", { name: "Fullscreen", exact: true });
const box = await viewport.boundingBox();
const control = await fullscreen.boundingBox();
expect(box).not.toBeNull();
expect(control).not.toBeNull();
expect(control!.y, "the toolbar starts inside the preview").toBeGreaterThanOrEqual(box!.y);
expect(control!.y + control!.height, "the toolbar fits inside the preview").toBeLessThanOrEqual(
box!.y + box!.height,
);
await viewport.getByRole("button", { name: "Zoom in" }).click();
await expect(viewport.getByRole("button", { name: "Reset view" })).toBeEnabled();
await viewport.getByRole("button", { name: "Reset view" }).click();
await expect(viewport.getByRole("button", { name: "Reset view" })).toBeDisabled();
}
+25 -11
View File
@@ -22,15 +22,27 @@ export async function addOfflineHostAndReload(
page: Page,
input: { serverId: string; label: string; primaryLabel?: string },
): Promise<void> {
const offlineHost = buildSeededHost({
serverId: input.serverId,
label: input.label,
endpoint: "127.0.0.1:59999",
nowIso: new Date().toISOString(),
});
await addOfflineHostsAndReload(page, [input], { primaryLabel: input.primaryLabel });
}
/** Adds several unreachable hosts with one reload. See `addOfflineHostAndReload`. */
export async function addOfflineHostsAndReload(
page: Page,
hosts: Array<{ serverId: string; label: string }>,
options: { primaryLabel?: string } = {},
): Promise<void> {
const nowIso = new Date().toISOString();
const offlineHosts = hosts.map((host) =>
buildSeededHost({
serverId: host.serverId,
label: host.label,
endpoint: "127.0.0.1:59999",
nowIso,
}),
);
await page.evaluate(
({ host, keys, primaryLabel }) => {
({ hosts: added, keys, primaryLabel }) => {
const nonce = localStorage.getItem(keys.nonce);
if (!nonce) {
throw new Error("Expected the e2e seed nonce before overriding the host registry.");
@@ -40,20 +52,22 @@ export async function addOfflineHostAndReload(
if (primaryLabel && registry[0]) {
registry[0].label = primaryLabel;
}
if (!registry.some((entry) => entry.serverId === host.serverId)) {
registry.push(host);
for (const host of added) {
if (!registry.some((entry) => entry.serverId === host.serverId)) {
registry.push(host);
}
}
localStorage.setItem(keys.registry, JSON.stringify(registry));
localStorage.setItem(keys.disableSeedOnce, nonce);
},
{
host: offlineHost,
hosts: offlineHosts,
keys: {
registry: REGISTRY_KEY,
nonce: SEED_NONCE_KEY,
disableSeedOnce: DISABLE_DEFAULT_SEED_ONCE_KEY,
},
primaryLabel: input.primaryLabel,
primaryLabel: options.primaryLabel,
},
);
@@ -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"]>>;
@@ -34,6 +34,54 @@ export async function openAppAsNewUser(
await page.goto("/");
}
interface FirstLaunchRecord {
appBeforeSetup: boolean;
setupOpacityOnArrival: number | null;
}
/**
* Watches the page from its first paint: whether the app's sidebar or home showed before setup,
* and how opaque setup was when it arrived. Call before `openAppAsNewUser`.
*/
export async function recordFirstLaunch(page: Page): Promise<void> {
await page.addInitScript(() => {
const record: FirstLaunchRecord = { appBeforeSetup: false, setupOpacityOnArrival: null };
Object.assign(window, { __firstLaunch: record });
const observer = new MutationObserver(() => {
const setup = document.querySelector('[data-testid="onboarding-modal"] [role="dialog"]');
if (setup) {
record.setupOpacityOnArrival = Number(getComputedStyle(setup).opacity);
observer.disconnect();
return;
}
if (
document.querySelector(
'[data-testid="open-project-submit"], [data-testid="sidebar-settings"]',
)
) {
record.appBeforeSetup = true;
}
});
observer.observe(document, { childList: true, subtree: true });
});
}
export async function readFirstLaunch(page: Page): Promise<FirstLaunchRecord> {
return page.evaluate(
() => (window as unknown as { __firstLaunch: FirstLaunchRecord }).__firstLaunch,
);
}
/** The loading screen the app shows while it starts. */
export function loadingScreen(page: Page): Locator {
return page.getByTestId("startup-splash");
}
/** The empty home a host with no projects lands on. */
export function emptyHome(page: Page): Locator {
return page.getByTestId("open-project-submit");
}
export function setupModal(page: Page): Locator {
return page.getByTestId("onboarding-modal").getByRole("dialog");
}
@@ -134,7 +134,7 @@ export default function contribute(client) {
}`;
}
async function withDirectoryPlugin(options: {
export async function withDirectoryPlugin(options: {
pluginId: string;
source: string;
run: () => Promise<void>;
@@ -157,8 +157,11 @@ export interface SeedDaemonClient {
sendAgentMessage(
agentId: string,
text: string,
options?: { sourceAgentId?: string },
options?: { sourceAgentId?: string; activeTurnBehavior?: "interrupt" | "steer" | "queue" },
): Promise<void>;
listAgentBackgroundWork(
agentId: string,
): Promise<import("@getpaseo/client").PaseoAgentBackgroundWorkItem[]>;
waitForFinish(
agentId: string,
timeout?: number,
@@ -0,0 +1,127 @@
import { expect, type Locator, type Page } from "@playwright/test";
/** Every footer control that can sit in the icon line, caret included. */
const FOOTER_LINE_TEST_IDS = [
"sidebar-add-project",
"sidebar-usage-icon",
"sidebar-hosts-trigger",
"sidebar-help",
"sidebar-footer-overflow",
"sidebar-settings",
];
// Browser layout boxes include floating-point rounding, even for whole-pixel styles.
const GEOMETRY_TOLERANCE = 0.01;
/** Open the app with the docked sidebar at `width`, as if the user had dragged it there. */
export async function seedDockedSidebarWidth(page: Page, width: number): Promise<void> {
await page.addInitScript((sidebarWidth) => {
localStorage.setItem("panel-state", JSON.stringify({ version: 17, state: { sidebarWidth } }));
}, width);
}
/**
* From the first frame, record any frame in which a footer button reaches past its row, so a row
* laid out before it measured shows up even if it lasted one frame.
*/
export async function watchFooterClipping(page: Page): Promise<void> {
await page.addInitScript((testIDs) => {
const clippedFrames: string[] = [];
Object.assign(window, { __footerClippedFrames: clippedFrames });
const sample = () => {
for (const row of document.querySelectorAll('[data-testid="sidebar-footer-bottom-line"]')) {
const rowRect = row.getBoundingClientRect();
if (rowRect.width === 0) continue;
for (const testID of testIDs) {
const button = row.querySelector(`[data-testid="${testID}"]`);
if (button && button.getBoundingClientRect().right > rowRect.right + 0.5) {
clippedFrames.push(testID);
}
}
}
requestAnimationFrame(sample);
};
requestAnimationFrame(sample);
}, FOOTER_LINE_TEST_IDS);
}
export async function expectFooterNeverClipped(page: Page): Promise<void> {
const clipped = await page.evaluate(
() => (window as unknown as { __footerClippedFrames: string[] }).__footerClippedFrames,
);
expect(clipped).toEqual([]);
}
function footerLine(page: Page): Locator {
return page.locator('[data-testid="sidebar-footer-bottom-line"]:visible');
}
export function footerOverflowMenu(page: Page): Locator {
return page.getByTestId("sidebar-footer-overflow-menu");
}
/**
* The icon line shows exactly `testIDs`, in order, each `controlSize` square and wholly inside the
* line's box, so no button is cut off by the sidebar's edge.
*/
export async function expectFooterLine(
page: Page,
input: { testIDs: string[]; controlSize: number },
): Promise<void> {
const line = footerLine(page);
await expect(line).toHaveCount(1);
await expect
.poll(async () =>
line
.locator(FOOTER_LINE_TEST_IDS.map((testID) => `[data-testid="${testID}"]`).join(","))
.evaluateAll((elements) => elements.map((element) => element.getAttribute("data-testid"))),
)
.toEqual(input.testIDs);
const { row, buttons } = await line.evaluate((element, testIDs) => {
const rect = (node: Element) => {
const { x, width, height } = node.getBoundingClientRect();
return { x, width, height };
};
return {
row: rect(element),
buttons: testIDs.map((testID) => rect(element.querySelector(`[data-testid="${testID}"]`)!)),
};
}, input.testIDs);
for (const button of buttons) {
expect(Math.abs(button.width - input.controlSize)).toBeLessThan(GEOMETRY_TOLERANCE);
expect(Math.abs(button.height - input.controlSize)).toBeLessThan(GEOMETRY_TOLERANCE);
expect(button.x).toBeGreaterThanOrEqual(row.x - GEOMETRY_TOLERANCE);
expect(button.x + button.width).toBeLessThanOrEqual(row.x + row.width + GEOMETRY_TOLERANCE);
}
}
export async function openFooterOverflow(page: Page): Promise<void> {
await footerLine(page).getByTestId("sidebar-footer-overflow").click();
await expect(footerOverflowMenu(page)).toBeVisible();
}
/** The overflow menu lists exactly these actions, in row order. */
export async function expectFooterOverflowActions(page: Page, names: string[]): Promise<void> {
await expect(footerOverflowMenu(page).getByRole("menuitem")).toHaveText(names);
}
export async function chooseFooterOverflowAction(page: Page, name: string): Promise<void> {
await footerOverflowMenu(page).getByRole("menuitem", { name }).click();
}
/** Every row of the open overflow menu, and of the page it opened, is inside the viewport's width. */
export async function expectFooterMenusInsideViewport(page: Page): Promise<void> {
const viewport = page.viewportSize()!;
const rows = page.getByRole("menuitem");
await expect(rows.first()).toBeVisible();
const boxes = await rows.evaluateAll((elements) =>
elements.map((element) => {
const { x, width } = element.getBoundingClientRect();
return { x, width };
}),
);
for (const box of boxes) {
expect(box.x).toBeGreaterThanOrEqual(0);
expect(box.x + box.width).toBeLessThanOrEqual(viewport.width);
}
}
@@ -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);
},
};
}
+39 -11
View File
@@ -81,7 +81,13 @@ export async function scrollWorkspaceList(page: Page): Promise<void> {
}
export async function expectWorkspaceHoverCardOpen(page: Page): Promise<void> {
await expect(page.getByTestId("workspace-hover-card")).toBeVisible();
const cards = page.getByTestId("workspace-hover-card");
// Keyboard navigation can leave the previous card closing while the next opens.
// Retry both checks together: another card may mount between count and visibility.
await expect(async () => {
expect(await cards.count()).toBe(1);
expect(await cards.isVisible()).toBe(true);
}).toPass({ timeout: 10_000 });
}
export async function expectWorkspaceHoverCardStaysOpen(page: Page): Promise<void> {
@@ -90,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);
}
@@ -261,16 +296,9 @@ export async function expectMobileAgentSidebarHidden(page: Page): Promise<void>
await expect(page.getByTestId("sidebar-sessions")).not.toBeInViewport({ timeout: 5_000 });
}
/** Drag from the left edge: the only way to open the list while fullscreen hides the header. */
export async function swipeOpenMobileAgentSidebar(page: Page): Promise<void> {
const viewport = page.viewportSize();
if (!viewport) throw new Error("viewport size is required");
const y = Math.round(viewport.height / 2);
await page.mouse.move(8, y);
await page.mouse.down();
await page.mouse.move(40, y, { steps: 4 });
await page.mouse.move(Math.round(viewport.width * 0.7), y, { steps: 12 });
await page.mouse.up();
/** Opens navigation without moving focus to the header, including over a fullscreen editor. */
export async function openMobileAgentSidebarWithKeyboard(page: Page): Promise<void> {
await page.keyboard.press("ControlOrMeta+b");
}
/** Drag from the right edge to open the compact Explorer over the agent. */
@@ -477,18 +477,29 @@ export async function expectStableHistoryStartGutter(page: Page): Promise<void>
.toBe(32);
}
export async function scrollTimelinePromptIntoView(page: Page, prompt: string): Promise<void> {
/**
* Wheels the timeline until `row` is centered, as a reader does. Playwright's own
* scroll-into-view is programmatic, which the timeline does not treat as the reader leaving
* the bottom: it snaps back to the bottom and can move the row away mid-click.
*/
export async function scrollTimelineRowIntoView(page: Page, row: Locator): Promise<void> {
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
const row = timeline.getByTestId("user-message").filter({ hasText: prompt });
const [viewport, target] = await Promise.all([timeline.boundingBox(), row.boundingBox()]);
if (!viewport || !target) {
throw new Error(`Expected a rendered timeline prompt for ${prompt}`);
throw new Error("Expected a rendered timeline row");
}
// Programmatic scrolling does not express the user's intent to stop following output.
await timeline.hover();
await page.mouse.wheel(0, target.y - viewport.y - (viewport.height - target.height) / 2);
await expect(row).toBeInViewport({ ratio: 1 });
await waitForTimelineGeometryToSettle(page);
}
export async function scrollTimelinePromptIntoView(page: Page, prompt: string): Promise<void> {
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
await scrollTimelineRowIntoView(
page,
timeline.getByTestId("user-message").filter({ hasText: prompt }),
);
await expect
.poll(async () => (await readTimelineViewport(page)).scrollTop)
.toBeGreaterThan(HISTORY_START_THRESHOLD_PX);
@@ -564,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,97 @@
import { existsSync, mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import type { Page } from "@playwright/test";
import { expect } from "../fixtures";
import type { connectNewWorkspaceDaemonClient } from "./new-workspace";
import { getServerId } from "./server-id";
import { expectWorkspaceAbsentFromSidebar } from "./sidebar";
type DaemonClient = Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
const UNDO_SHORTCUT = process.platform === "darwin" ? "Meta+Z" : "Control+Z";
/** The archived toast's lifetime, from `WORKSPACE_ARCHIVE_UNDO_WINDOW_MS`. */
const UNDO_WINDOW_MS = 6000;
export function workspaceRow(page: Page, workspaceId: string) {
return page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`);
}
export function rowArchiveButton(page: Page, workspaceId: string) {
return page.getByTestId(`sidebar-workspace-archive-button-${getServerId()}:${workspaceId}`);
}
export function rowKebab(page: Page, workspaceId: string) {
return page.getByTestId(`sidebar-workspace-kebab-${getServerId()}:${workspaceId}`);
}
export function undoButton(page: Page) {
return page.getByRole("button", { name: /^Undo/ });
}
export async function workspaceTitleWidth(page: Page, workspaceId: string): Promise<number> {
const box = await page
.getByTestId(`sidebar-workspace-title-${getServerId()}:${workspaceId}`)
.boundingBox();
if (!box) throw new Error(`Title of ${workspaceId} is not laid out`);
return box.width;
}
/** Archives a workspace with its row's hover button and waits for the undo toast. */
export async function archiveFromRowButton(page: Page, workspaceId: string): Promise<void> {
page.once("dialog", (dialog) => void dialog.accept());
await workspaceRow(page, workspaceId).hover();
await rowArchiveButton(page, workspaceId).click();
await expectWorkspaceAbsentFromSidebar(page, workspaceId);
await expect(undoButton(page)).toBeVisible();
}
export async function pressUndoShortcut(page: Page): Promise<void> {
await page.keyboard.press(UNDO_SHORTCUT);
}
/** Moves the page clock past the moment an unheld archived toast would have closed. */
export async function outlastUndoWindow(page: Page): Promise<void> {
await page.clock.fastForward(UNDO_WINDOW_MS + 1000);
}
/** Waits until the host lists the workspace as active with no archive running on it. */
export async function expectHostWorkspaceSettledActive(
client: DaemonClient,
workspaceId: string,
): Promise<void> {
await expect
.poll(
async () => {
const { entries } = await client.fetchWorkspaces();
const entry = entries.find((candidate) => candidate.id === workspaceId);
return entry ? { active: true, archiving: Boolean(entry.archivingAt) } : null;
},
{ timeout: 30_000 },
)
.toEqual({ active: true, archiving: false });
}
export async function expectDirectoryPresence(directory: string, present: boolean): Promise<void> {
await expect.poll(() => existsSync(directory), { timeout: 30_000 }).toBe(present);
}
export interface GatedTeardown {
/** The `paseo.json` config whose worktree teardown parks until released. */
paseoConfig: Record<string, unknown>;
expectStarted(): Promise<void>;
release(): void;
}
/** A worktree teardown that parks an archive mid-flight, so an undo lands while it runs. */
export function createGatedTeardown(): GatedTeardown {
const gateDir = mkdtempSync(path.join(tmpdir(), "archive-undo-gate-"));
const startedPath = path.join(gateDir, "started");
const releasePath = path.join(gateDir, "release");
const teardown = `node -e "const fs=require('fs');fs.writeFileSync('${startedPath}','started');const timer=setInterval(()=>{if(fs.existsSync('${releasePath}'))clearInterval(timer)},20)"`;
return {
paseoConfig: { worktree: { teardown: [teardown] } },
expectStarted: () => expectDirectoryPresence(startedPath, true),
release: () => writeFileSync(releasePath, "release\n"),
};
}
@@ -54,6 +54,28 @@ using namespace facebook::react;
[[RCTComponentViewFactory currentComponentViewFactory] registerComponentViewClass:self];
}
// iOS 26 blurs a scroll view's edges and places the blur in the scroll view's own coordinates,
// ignoring transforms. The keyboard lifts the chat with a translateY, so on iPhones the blur lands
// over the readable chat and stays until the next scroll (#7883). React Native hides it for
// inverted lists for the same reason (https://github.com/react/react-native/pull/58684). The app
// draws its own header fade, so every edge is hidden; hiding only top and bottom leaves the side
// effects, which also cover a transformed list on device.
- (instancetype)initWithFrame:(CGRect)frame
{
if (self = [super initWithFrame:frame]) {
#if defined(__IPHONE_26_0) && __IPHONE_OS_VERSION_MAX_ALLOWED >= __IPHONE_26_0
if (@available(iOS 26.0, *)) {
UIScrollView *scrollView = self.scrollView;
scrollView.topEdgeEffect.hidden = YES;
scrollView.bottomEdgeEffect.hidden = YES;
scrollView.leftEdgeEffect.hidden = YES;
scrollView.rightEdgeEffect.hidden = YES;
}
#endif
}
return self;
}
- (void)mountingTransactionWillMount:(const MountingTransaction &)transaction
withSurfaceTelemetry:(const SurfaceTelemetry &)surfaceTelemetry
{
@@ -17,6 +17,7 @@ final class WindowLayoutView: ExpoView {
private var divisions: [CGRect] = []
private var barGuide: UILayoutGuide?
private var barEdge = "none"
private var bottomBarGuide: UILayoutGuide?
private var measuredBounds = CGRect.zero
required init(appContext: AppContext? = nil) {
@@ -36,6 +37,7 @@ final class WindowLayoutView: ExpoView {
override func didMoveToWindow() {
super.didMoveToWindow()
previousPayload = nil
bottomBarGuide = nil
setNeedsUpdateConstraints()
scheduleUpdate()
}
@@ -66,6 +68,11 @@ final class WindowLayoutView: ExpoView {
case .trailing: edge = "trailing"
default: edge = "none"
}
if bottomBarGuide == nil {
// Where a bottom bar rests: above the home indicator, refined to the
// screen's corners rather than the rectangular safe area.
bottomBarGuide = layoutGuide(for: .bar(onEdge: UIRectEdge.bottom, extent: 44))
}
if edge != barEdge {
barEdge = edge
barGuide = edge == "none" ? nil : layoutGuide(
@@ -93,10 +100,17 @@ final class WindowLayoutView: ExpoView {
let bar = barGuide?.layoutFrame
var barPayload: Any = NSNull()
if let bar, bar.width > 0, bar.height > 0 { barPayload = rect(bar) }
// Before UIKit resolves the guide its frame has no height: publish null (the
// system inset applies) rather than a false zero clearance.
var bottomClearance: Any = NSNull()
if let bottomBar = bottomBarGuide?.layoutFrame, bottomBar.height > 0 {
bottomClearance = max(0, bounds.maxY - bottomBar.maxY)
}
let payload: [String: Any] = [
"bounds": rect(bounds),
"verticalBarEdge": barEdge,
"verticalBar": barPayload,
"bottomClearance": bottomClearance,
"divisions": divisions.map(rect)
]
guard !NSDictionary(dictionary: payload).isEqual(to: previousPayload ?? [:]) else { return }
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.15.0",
"version": "0.15.3",
"private": true,
"main": "index.ts",
"scripts": {
@@ -1,3 +1,5 @@
import { useCallback } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useFetchQuery } from "@/data/query";
import { useTranslation } from "react-i18next";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
@@ -75,6 +77,7 @@ export function useAgentCommandsQuery({
}),
);
const queryClient = useQueryClient();
const query = useFetchQuery({
queryKey: agentCommandsQueryKey({ serverId, agentId, draftConfig }),
queryFn: async () => {
@@ -101,10 +104,20 @@ export function useAgentCommandsQuery({
// isLoading is true when fetching and no data yet
const isLoading = query.isPending || query.isLoading;
// Reset rather than refetch: a refetch keeps the error status while it runs, and the menu
// shows loading for the retry.
const retry = useCallback(() => {
void queryClient.resetQueries({
queryKey: agentCommandsQueryKey({ serverId, agentId, draftConfig }),
exact: true,
});
}, [agentId, draftConfig, queryClient, serverId]);
return {
commands: query.data ?? [],
isLoading,
isError: query.isError && query.data === undefined,
error: query.error,
retry,
};
}
+1
View File
@@ -18,6 +18,7 @@ import { getModeVisuals, type AgentProviderDefinition } from "@getpaseo/protocol
export interface AgentControlIconProps {
size: number;
color: string;
strokeWidth?: number;
}
export type AgentControlIcon = ComponentType<AgentControlIconProps>;
+19 -9
View File
@@ -1,4 +1,6 @@
import { useShallow } from "zustand/shallow";
import { useTranslation } from "react-i18next";
import { i18n } from "@/i18n/i18next";
import { useProviderIcon } from "@/components/provider-icons";
import type { PanelDescriptor } from "@/panels/panel-registry";
import { useSessionStore, selectAgentTurnPresentation, type Agent } from "@/stores/session-store";
@@ -33,6 +35,7 @@ export function useAgentDescriptor(
target: { kind: "agent"; agentId: string },
context: { serverId: string },
): PanelDescriptor {
useTranslation();
const descriptorState = useSessionStore(
useShallow((state) => {
const session = state.sessions[context.serverId];
@@ -42,6 +45,7 @@ export function useAgentDescriptor(
provider: agent?.provider ?? "codex",
title: agent?.title ?? null,
status: agent?.status ?? null,
backgroundWork: agent?.backgroundWork,
pendingPermissionCount: agent?.pendingPermissions.length ?? 0,
requiresAttention: agent?.requiresAttention ?? false,
attentionReason: agent?.attentionReason ?? null,
@@ -57,6 +61,7 @@ export function buildAgentDescriptor(input: {
provider: Agent["provider"];
title: string | null;
status: Agent["status"] | null;
backgroundWork?: Agent["backgroundWork"];
pendingPermissionCount: number;
requiresAttention: boolean;
attentionReason: Agent["attentionReason"];
@@ -65,20 +70,25 @@ export function buildAgentDescriptor(input: {
}): PanelDescriptor {
const provider = input.provider;
const label = resolveAgentLabel(input.title);
const statusBucket = input.status
? deriveSidebarStateBucket({
status: input.isTurnActive ? "running" : input.status,
backgroundWork: input.backgroundWork,
pendingPermissionCount: input.pendingPermissionCount,
requiresAttention: input.requiresAttention,
attentionReason: input.attentionReason,
})
: null;
return {
label: label ?? "",
subtitle: `${formatProviderLabel(provider)} agent`,
subtitle:
statusBucket === "running" && !input.isTurnActive && input.status !== "running"
? i18n.t("message.backgroundWork.running")
: `${formatProviderLabel(provider)} agent`,
tooltip: label ?? `${formatProviderLabel(provider)} agent`,
titleState: label ? "ready" : "loading",
icon: input.icon,
statusBucket: input.status
? deriveSidebarStateBucket({
status: input.isTurnActive ? "running" : input.status,
pendingPermissionCount: input.pendingPermissionCount,
requiresAttention: input.requiresAttention,
attentionReason: input.attentionReason,
})
: null,
statusBucket,
};
}
@@ -65,7 +65,6 @@ interface BottomAnchorControllerDriver {
pendingVerification: AttemptContext | null;
blockedReason: BottomAnchorBlockedReason | null;
};
resetForAgent: () => void;
applyRouteRequest: (request: BottomAnchorRouteRequest | null) => void;
requestLocalAnchor: (request: BottomAnchorLocalRequest) => void;
beginUserScroll: () => void;
@@ -480,17 +479,6 @@ function createBottomAnchorControllerDriver(
blockedReason,
};
},
resetForAgent() {
lastRouteRequestKey = null;
pendingRequest = null;
blockedReason = null;
cancelPendingAttempt();
stickyMeasurementRevision = 0;
lastVerifiedStickyMeasurementRevision = 0;
isUserScrollActive = false;
mode = "sticky-bottom";
input.onModeChange("sticky-bottom");
},
applyRouteRequest(request) {
if (!request) {
return;
@@ -741,10 +729,6 @@ export function useBottomAnchorController(input: {
});
}
useEffect(() => {
driverRef.current?.resetForAgent();
}, [input.agentId]);
useEffect(() => {
driverRef.current?.applyRouteRequest(input.routeRequest);
}, [input.routeRequest]);
@@ -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();
}

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