Compare commits

..
Author SHA1 Message Date
Mohamed Boudra 53fa9524cf fix(app): keep rewind invalidation for find results after Next and Previous
The rewind marker of the result set was cleared in cancel(), which also
stops navigation work. Next and Previous call cancel() while keeping the
results, so after one step a remote rewind no longer invalidated them and
the selection could stay on a retired message. The marker now shares the
result set's lifetime and is cleared only when a new query discards it.
2026-11-11 08:51:28 +01:00
Mohamed Boudra 5a8383d237 fix(app): end chat find results when a rewind retires messages
Find validated its results against the timeline epoch only. Capable hosts
rewind in place, so the epoch stays the same and a remote rewind left the
count and selection pointing at retired messages; Next could reveal one.

The replica now records the latest rewind seq per agent, from live rewind
events and from fetched pages carrying retirements. Find treats epoch plus
that seq as the history identity, so a rewind during or after a search
reports the history as changed.
2026-11-11 08:41:00 +01:00
Mohamed Boudra cf3caefa14 fix(server): retire the turns a steered Codex rewind actually removed
Codex rewinds whole native turns. A steered prompt shares its turn with the prompt it joined, so forking before that turn also removes the earlier prompt, and a rollback counted per prompt can remove earlier turns. The session reported at_target, so Paseo kept prompts the provider no longer had. It now reports the cut from the turn index taken before the fork, and an unreported outcome when the fork switched threads but did not finish.
2026-11-11 08:36:58 +01:00
Mohamed Boudra c747a58871 test(app): expect a rewound first prompt to leave the chat in the rewind sheet spec
On main the mock provider's rewind kept the first user message whatever the target, and the chat was rebuilt from that history, so the spec expected the rewound first prompt to stay. The mock now rewinds at the target and the chat retires the rewound turns in place, so rewinding the first prompt empties the chat and returns the prompt to the composer.
2026-11-11 08:26:19 +01:00
Mohamed Boudra c36d398829 perf(server): read the subscription freshness head without fetching the transcript
The freshness check read the head by fetching every row of the agent's history on each subscribe. The timeline head now carries its last committed sequence number, so the check reads only the head.
2026-11-11 04:00:27 +01:00
Mohamed Boudra 183bc736ec test(server): await provider child reads in the replay and pi child tests
These tests came in with the main merge. The child store reads are asynchronous now, so the tests compared a promise with the expected child.
2026-11-11 04:00:11 +01:00
Mohamed Boudra c4ce21a237 test(client): expect the rewind capability in the client hello
The client advertises timeline_rewind_operations since the rewind operations landed, but the hello expectation was never updated.
2026-11-11 03:59:55 +01:00
Mohamed Boudra 9a2236dac3 test(server): give the test session the interface's persistence signature
TestAgentSession.describePersistence had no return type, so TypeScript
inferred a codex-only handle with a UUID session id. The restarted-child test
from 80879eaf7 assigns the resumed AgentPersistenceHandle to it and failed to
typecheck (TS2322). The test double now declares the AgentSession signature,
which also clears the older plugin-provider sites with the same error.
2026-11-10 13:54:12 +01:00
Mohamed Boudra ecd0dd083e fix(server): let an aborted wait end while the provider is starting the turn
10c443a32 made waitForAgentEvent wait for a starting run before reading its
state, but that wait came before the abort listener. A provider start has no
deadline of its own, so a stalled startTurn kept every timed wait (MCP wait,
CLI wait, schedules, finish subscriptions) past its timeout. The start wait
now rejects as soon as the caller's signal aborts.
2026-11-10 13:53:50 +01:00
Mohamed Boudra 10c443a32b fix(server): wait for a starting run to settle before steering or waiting on it
A run's start now awaits the timeline before the provider is called, and a
failed start awaits its timeline writes before the run is cleared. Two
readers raced those windows:

- waitForForegroundStart returned as soon as a start failed, while the failed
  run was still tracked, so a steer send in that window replaced it and
  interrupted the session instead of starting its own turn.
- waitForAgentEvent replayed the idle state from before a starting run and
  finished immediately, so `wait` after a dispatch could return before the
  turn ran.

waitForForegroundStart now resolves only once the turn started or the run is
cleared, as documented, and waitForAgentEvent waits for it before reading the
agent's state. The OMP restored-history test previously finished only through
that early return; its fake now starts the turn it later ends.
2026-11-10 13:47:49 +01:00
Mohamed Boudra 457d8d6ec5 Merge origin/main into plan-provider-switch-sqlite-timelines
Adapts main's changes to the single asynchronous timeline owner:

- The subscribe freshness check (#9175) awaits the loaded timeline head and
  compares the client's cursor against the epoch that client sees, so legacy
  clients compare against their derived epoch after a rewind.
- History reads (#8737) pass purpose "history" when an agent with no stored
  timeline must be loaded; stored history is still read without the provider.
- Background work clears on reload, close and parent delete next to the
  async provider-child cancellation.
- Claude steered-message rewinds (#8372) report an at-target cut. Codex keeps
  main's saved thread id alongside the reported cut.
- Plugin rewinds (#8368) return an unreported outcome; a failed replay after
  the native revert leaves the rewind for the reload to settle rather than a
  retry. Manager-level rewinds target the timeline row's message id.
- Main's tests read the timeline asynchronously, and wait for the provider to
  receive a prompt instead of assuming it arrives in the dispatch tick.
2026-11-10 13:47:17 +01:00
Mohamed Boudra 80879eaf7c fix(timeline): retain child state and recover rewinds on resume
Provider children now live in the parent's timeline next to its history,
so their descriptors, rows and retirements survive close, archive and a
manager rebuilt over the same backend, and deleting the agent removes
them all. Resuming settles a rewind that was still open when the runtime
stopped instead of leaving its rows half-retired.

A stored child's status is only the last report, possibly from provider
history or an earlier runtime. Workspace activity and idle cleanup now
read the children the current runtime reported running, and closing a
runtime cancels only those, so a child stored as running from an earlier
runtime no longer keeps the agent busy or blocks cleanup.
2026-11-10 13:09:38 +01:00
Mohamed Boudra 79b9e7447b test(app): check unanswered freshness when reopening cached history (#9296)
Foreground checks intentionally stay silent since #9242. Exercise the opening check instead, waiting for a durable cursor before reload so cached messages remain readable while the daemon answer is held.
2026-11-10 18:14:19 +07:00
Mohamed Boudra c91948048b Lead each release with highlights in What's new, the website changelog and release notes (#9281)
Every release entry in CHANGELOG.md now leads with highlights: an unheaded
list above the first `###`. The app's What's new sheet, the website
changelog and the GitHub release body show the highlights and fold the
sections, the full record, behind one control. An entry without highlights
keeps its sections in view. docs/release.md carries the writing rules.

Installed apps fetch CHANGELOG.md from main and parse it with the parser
they shipped with, so the two heading rules are a compatibility surface:
`##` starts a release, `###` starts a section, and headings inside fenced
code are text. The app, website and release-script parsers now split an
entry the same way and each runs the shared cases in
scripts/test-support/changelog-structure.json. The release-script tests,
which no CI job ran before, are part of "Validate CI contracts".

Adds the 0.16.0-beta.1 entry (en and zh-CN) ahead of the release.
2026-11-10 17:44:57 +07:00
Mohamed Boudra dc85c2bfbc feat(timeline): preserve history across reload and archive
Reload agent used to delete the timeline and replay the provider's
history under a new epoch. That brought back turns the user had rewound
and dropped anything the provider no longer had. Archive deleted the
timeline too.

Paseo's timeline is now the owner of the conversation. Reload agent
keeps it and reconciles the provider's history append-only: turns are
aligned by provider prompt id and compared exactly (prompt text,
assistant and reasoning text, tool calls). When the owned conversation
is a whole-segment prefix of the provider's, the rows after it are
appended as new live rows. Any other difference changes nothing and
agent_refreshed reports historyDiverged, which the app shows as a
warning on Reload agent and Unarchive.

Divergence is deliberately conservative:
- An answer or tool call Paseo saw only partly can never be completed by
  a reload. History rows carry no turn id to update a live row in place.
- A prompt sent with attachments diverges on every reload. Providers
  store the rendered attachment text, and the owned row keeps only the
  typed text. Recording the sent prompt is left to provider handoff work.

Archive now only closes the runtime. Stored history, including provider
children, is read without resuming or even requiring the provider.
Forced provider replacement is removed.

Tradeoff: an archived chat's history stays in memory until the daemon
exits, measured at about 1.1-1.2x its JSON size (about 0.5 MB for a
typical session, about 16 MB for a heavy tool session). Moving
retained history to disk belongs to the persisted backend, not eviction,
which would drop the rewinds this change keeps.
2026-11-10 11:43:10 +01:00
Mohamed Boudra 5ed1f8d108 feat(timeline): apply conversation rewinds without replacing history
Rewinding a prompt used to rotate the timeline epoch, so every open
client refetched and remounted the whole chat. A rewind is now a
retirement: the daemon keeps the rewound turn stored but outside the
conversation, and capable clients remove the rows that started in
[retireFromSeq, seq) in place. Catch-up pages carry the retirements in
their span. Clients without the timeline_rewind_operations capability
get the old replacement shape.

The daemon retires only what the provider actually removed. Providers
report the boundary they kept. A failure known to change nothing keeps
the conversation. Any other failure leaves the rewind open and refuses
prompts until reloading the agent proves the outcome from the
provider's replayed prompts. A recorded cut whose save failed is
committed by the next prompt or the reload, which resumes the rewound
session.

Plugin providers complete session.revert without saying what they
removed. Paseo then reloads the session itself and judges the outcome
from the replay, so a no-op completion is not taken as a cut.

The app anchors each row at the seq it started at, because a late
update moves a row's latest seq into the rewound range. The app also
refuses pages served before the newest retirement it has seen, then
refetches them.

Known limits:
- The automatic plugin settle shows the agent as restoring briefly.
- An empty or mismatched plugin replay leaves the rewind unresolved.
  Fork the chat to recover.
- A plugin's failed revert still needs a manual reload.
- An open rewind lives in memory, so a daemon restart drops it.
2026-11-10 10:16:13 +01:00
paseo-bot[bot] 63ea3e5561 test(plugins): isolate failed-update assertions from fixture setup (#9275)
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-10 09:02:08 +00:00
Mohamed Boudra 4c9c59ea2b Let plugins put the cursor in any agent's composer (#9207)
* feat(plugins): let a plugin put the cursor in any agent's composer

A pill could insert text but left the cursor elsewhere, so the user had
to click the composer before typing. Plugins can address any agent's
composer, and an author cannot know where the user is, so focus() owns
revealing the agent as well as focusing it.

A running agent's composer holds one pending request and navigates to
the agent, completing when an input that can take the keyboard attaches.
Drafts and the New workspace composer have nothing to navigate to and
must not pull the user back, so they reject when off screen.

* fix(app): stop treating a covered New workspace composer as on screen

The screen passed isPaneFocused={true}, so with Settings pushed over it
the input still counted as revealed. focus() on a held composer resolved
on native with no visible cursor, and rejected with the wrong error on web.
The screen now reports its real route focus.

* fix(app): reach a compact subagent's composer and keep a focus selection

A compact sheet has no composer, so focus() on a subagent without a tab
opened a read-only sheet and timed out. It now promotes the sheet to a
tab, the same path as Open as tab.

A pending focus ran inside the effect that attached the input, before a
later effect in that commit applied a prompt set alongside it, so its
selection was clamped against the old text. It now runs after the commit.

* fix(app): show a compact subagent's tab when focus promotes its sheet

navigateToAgent stops at the sheet, so nothing closed an Explorer panel
covering the promoted tab and focus() timed out. The promoted tab now
goes through the same reveal as any other navigated tab.

* fix(app): open focus targets as tabs and focus Explorer composers on compact

Promoting a sheet after navigating could not reach an open deferred
until the workspace loaded, so the request now asks for a tab up front
with asTab, which travels with the deferred open. That removes the
post-navigation promotion.

A compact composer counted as covered whenever the panel target was not
the agent panel, so one hosted in the Explorer could never take focus.
Coverage now follows the composer's own host.

* fix(app): judge compact composer coverage by where it is hosted

The Explorer dock always reported its pane as focused, so a workspace sheet
over an Explorer composer still counted as on screen and focus() put the
cursor behind the sheet. It now suspends pane focus like the main panes do.

A composer on a screen with no panes counted as a main pane, so an Explorer
selection left over from the workspace marked New workspace as covered and
focus() rejected on a visible input. Only the agent list can cover those
screens, because the Explorer lives in the workspace.

* fix(app): close the composer's own sheets and menus when a plugin focuses it

focus() judged a composer on screen by its pane and panel alone, so a sheet
or menu the composer opened over itself, like the compact controls sheet
that hosts plugin Actions or a Subagents track page, stayed up while the
cursor went behind it. Navigation cannot close those, so focus() now closes
them itself.

Sheets and menus register with the nearest OverlayDismissalScope while open.
The composer opens one, and the agent panel opens one around its whole chat
because the tracks render outside the composer; a nested scope joins the
enclosing one.

* fix(app): close a composer's open picker when a plugin focuses it

The thinking, provider-option and plugin selectors render Combobox, which
never joined the composer's overlay dismissal scope. focus() left the
picker open, resolved on the textarea's brief focus, and the picker's
focus enforcement then took the cursor back. Combobox now registers its
close while open, like sheets and menus.

* fix(app): close a composer's modals before focus places the cursor

An image preview opened from the composer stayed over it after focus()
succeeded: the shared Modal never joined the overlay dismissal scope.
Modal now registers its onRequestClose while visible. Closing it then
exposed a second race: a web modal hands focus back to the button that
opened it in the commit that removes it, after the cursor was placed.
Dismissal now resolves once every closed overlay has unregistered, and
the composer places the cursor after that, waiting at most 1s so a
plugin modal that refuses to close cannot hold the promise forever.

A focus request replaced by a newer one resolved immediately, even when
the composer never came on screen. It now settles with the newer one.

* fix(app): close every composer sheet on focus and keep the latest request

The context usage sheet opens through the bottom sheet visibility hook
directly, so it stayed over the composer after focus(). Registration
moves to that hook, which every controlled bottom sheet uses;
AdaptiveModalSheet registers only its desktop dialog.

Two focus() calls in one turn overwrote each other's wait for an
overlay to close, so the older one resumed a second later and put its
selection back. Callers now share each overlay's close, and the latest
request places the cursor while earlier ones settle with it.

On native, focus() reported success when the input refused focus, as a
non-editable input does during dictation. It now checks isFocused().

* fix(app): let a focus call made while the input attaches decide the selection

A queued focus was dispatched in a microtask after the input attached, so a
focus() made in between (from a mounting item, for example) was overwritten
by the older selection. Each call now carries its sequence number; a queued
request that is no longer the latest settles with the newer one instead of
placing the cursor.

* fix(app): close a workspace sheet that focus dismisses before it animates open

A bottom sheet modal that unmounts after presenting but before its first
animation is still INITIAL, and the library's portal unmount returned early
for that status. Its portal stayed on the host and kept sliding open with no
owner, so composer.focus() removed the sheet from the workspace but left it
covering the input. The patch unmounts the portal in that case.

The compact Explorer focus test also waits for the phone layout before
focusing, since focus otherwise resolved against the wide layout.
2026-11-10 14:22:44 +07:00
Mohamed Boudra 4178497d3d refactor(server): give agent timeline history a single awaitable owner
Agent timeline history had several writers that appended and published
independently, so a persistent backend could not be swapped in without
reordering or losing items. AgentTimeline now owns each agent's history:
one FIFO queue per agent (child agents share the parent's queue) commits
each item and then publishes it, so subscribers never see an item before it
is recorded. Backends may be async, which prepares the SQLite swap.

Terminal turn events wait for queued writes so a finished turn never races
its own last chunks. A subscriber that throws after commit leaves the item
in history. A failed commit surfaces as an agent error ("Conversation
history could not be saved") via the existing lastError/attention path; it
survives the turn's terminal transition and clears on the next run.
2026-11-10 08:11:00 +01:00
Mohamed Boudra d46ee6558c Keep popovers above their trigger off it when their content grows (#9247)
* fix(app): keep popovers above their trigger off it when content grows

Tooltips and menu popovers placed above their trigger were positioned by
their top edge. When their content grew after they painted, the new height
painted first at the old top, extending down over the trigger, and the
correction waited for onLayout and a second placement commit. Native
delivers those after the frame, so the grown card covered the trigger for
about 150 ms on Android; web commits both in one task and hid it.

A surface above its trigger is now held by its bottom edge: it sits on the
bottom of a holder that ends at that edge, so layout keeps the edge in
place however the content changes.

* fix(app): let presses outside a popover reach its backdrop on web

The holder that keeps a popover's edge in place set pointerEvents
"box-none" as an inline style. react-native-web only polyfills box-none
for compiled styles, so the browser received an invalid value, fell back
to auto, and the full-width holder swallowed clicks meant for the menu
backdrop. Clicking outside an open menu no longer closed it.

Set box-none through the pointerEvents prop, as the rest of the app does.
2026-11-10 11:05:35 +07:00
Mohamed Boudra abcccce846 Show a streamed tool call's final title and icon from its first frame (#9248)
* fix(server): give streamed tool calls their final title from the first frame

Claude opens a tool_use block with `input: {}` and OpenCode reports a
pending part with `input: {}` before the arguments arrive. The shared
detail primitives rejected the empty input, so the first row carried an
`unknown` detail and rendered the raw tool name with a wrench icon
("Bash"), then switched to "Shell" and the terminal icon once the command
arrived. Grep/Glob, WebFetch and MultiEdit flipped the same way, and
WebFetch flipped again while only `url` had streamed.

A known tool with no arguments and no output now yields its canonical
detail with empty fields. The app already reads empty read/edit/write/
search/fetch fields as loading details; a shell with no command and no
output now reads as loading too, instead of opening an empty sheet.

* fix(server): keep a tool call's title while optional arguments arrive first

Claude writes a tool's arguments in whatever order the model chooses, so a Bash
call can stream `{description}` before `command`. That partial input failed the
Bash schema and the row fell back to "Bash" with the wrench icon until the
command arrived.

Parsing now retries a call without output as having no arguments when its
input does not yet satisfy its tool's schema. This happens once, over the whole
branch set, so tools with several input shapes (OpenCode apply_patch) still
reach the branch their complete input matches.
2026-11-10 11:05:31 +07:00
Mohamed Boudra 48a1497fc0 Load the Changes diff quickly while the daemon reconciles worktrees (#9243)
* fix(server): keep cold diffs out from behind background git fan-out

Add a background lane to the git process scheduler and run reconciliation
checkout reads, observation-driven workspace refreshes and background fetches
in it. Bound reconciliation checkout reads to four at a time. Stop the Linux
file observer's startup scan from reporting existing files as creates, which
made every cold diff compute and discard its first revision.

* fix(server): pick the git job when its start token fires

Background jobs that already reserved a start-rate token no longer start
ahead of normal work submitted after them.

* fix(server): re-read the working tree once its watcher covers it

The Linux observer no longer reports pre-existing files at startup, so an
edit made between a workspace's first snapshot and its watcher subscribing
reached no consumer. WorkspaceGit now runs one background working-tree read
once observation setup completes with a live watcher.

Demand refreshes keep the caller's Git lane instead of forcing normal, so
user actions running at high priority are not demoted.

Scope the populated-tree observer test to Linux (FSEvents replays recent
writes) and update the observer benchmark test, which expected the removed
startup batch.

* fix(server): re-read checkout metadata once observation covers it

A branch switch made before the Git metadata watcher subscribed left the
cached branch stale. The post-coverage read now refreshes structure as well
as the working tree, matching the refresh the Linux startup events used to
trigger.
2026-11-10 10:21:13 +07:00
Mohamed Boudra 2e1747a145 fix(app): keep the Changes diff on screen when its panel reopens (#9241)
Hiding the Changes panel releases its diff subscription. Reopening it starts a
new stream, and since #8907 a new stream first publishes a loading snapshot
(entries, no bodies) before its first revision commits. That snapshot replaced
the settled diff in the query cache, so the panel showed the full loading
spinner for about a second and then repainted the same diff.

A loading snapshot now only counts on the first load. Once a diff has settled,
shareCheckoutDiff keeps it until the new stream's ready revision lands, which
replaces it in one step when the content changed.
2026-11-10 09:49:11 +07:00
Mohamed Boudra 4181f8d738 fix(app): show loaded usage in the context popover's first frame (#9212)
The agent's usage query used gcTime 0, so every close dropped its reports
and every open started from the loading state. The drop existed because an
agent resumed under another login keeps its ID. The daemon now projects an
opaque usageScopeKey that follows the harness session and model, the query
key includes it, and reports are retained for as long as the scope holds.

Hover cards placed above their trigger now hold their bottom edge. Anchored
by top, a card that grew painted past the trigger until onLayout corrected
it on a later frame.
2026-11-10 09:49:07 +07:00
Mohamed Boudra 11d7267e33 fix(app): keep Updating messages hidden while an unchanged chat is checked on resume (#9242)
Every freshness check escalated to "pending" once FRESHNESS_CHECK_QUIET_MS
(500 ms) passed without an answer, so any round trip slower than that
flashed Updating messages for an idle chat the daemon then called current.
On Android that fired on a sub-second app switch over a slower link, and
after a reconnect it replaced Reconnecting to host with a second toast.

Each check now records why it was asked. Only opening a chat escalates
after the quiet period. A reconnect stays "reattaching" (Reconnecting to
host) until the answer. A foreground resume on a socket that stayed
connected stays silent: the chat never left its subscription, and a socket
that died without closing fails the foreground liveness probe and becomes
a reconnect. A "behind" answer still shows Updating messages until the
fetch lands.
2026-11-10 09:28:55 +07:00
Mohamed Boudra 82687ec95b Keep plugin composer items in the toolbar when they fit (#9209)
* fix(app): keep plugin composer items in the toolbar when they fit

The density budget guessed a plugin item's width from a fixed label and
counted every registered item, so an icon toggle or an item rendering
nothing pushed the toolbar into its overflow state with room to spare.
An item is an arbitrary component, so its width is only known by
rendering it. Measure the toolbar form in an always-mounted slot; the
width never depends on the density it decides, so moving items to the
overflow cannot flip the answer.

Sheet rows truncated short values such as "Full access" because the
value's 45% cap resolved against a content-width row. Fill the row so
the cap resolves against the sheet's width.

* fix(app): close a plugin picker when its trigger moves to the overflow

The measuring slot keeps toolbar plugin items mounted, so a picker that
was open when the toolbar condensed lost its trigger but kept its popup,
which portals to the overlay root. Mark the slot's items inactive while
they are out of the row, the contract retained panels already use to
close their popups.

* test(app): allow the Updating messages toast when the daemon answers slowly

A freshness check unanswered after 500ms reports Updating messages by
design, but the resume spec asserted no toast regardless of how long
the daemon took. On a loaded runner a cold reload's answer took 512ms,
so the toast was correct and the spec failed. Assert no toast only when
the answer came back promptly, using answer times the gate records.

* test(app): check the Updating messages toast the same way on every run

The previous repair skipped the no-toast assertion whenever the daemon
took 300ms or more to answer, so a slow run proved nothing. The toast is
correct once a check has gone unanswered for the quiet window, so the
spec now always asserts that the toast did not appear sooner than that
after the question was sent, timed in the page. The delayed-answer path
gets its own test that holds the daemon's answer.
2026-11-10 09:08:11 +07:00
Mohamed Boudra 9442b5a1b4 chore(release): reconcile 0.15.4 into main (#9240) 2026-11-10 08:50:23 +07:00
Mohamed Boudra 6cfe903127 Lay composer toolbar controls out at their full highlight on touch (#9238)
Touch-sized, each toolbar control now lays out at its whole highlight with no gap, so neighbouring highlights touch instead of hanging into gaps through negative margins. The row's overhang puts the attach glyph and a leading label on the composer's rail, and the trailing disc keeps a small clearance from its neighbour.
2026-11-10 08:33:58 +07:00
f9779a14a5 feat(claude): add Fable 5.2 and Mythos 5.2 to the model catalog (#9233)
Adds Claude Fable 5.2 and Mythos 5.2, available from Claude Code 2.1.321, and defaults both to medium effort to match Claude Code.

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
Co-authored-by: gudcks <84506708+gudcks@users.noreply.github.com>
2026-11-10 01:09:47 +00:00
276 changed files with 14376 additions and 3486 deletions
+1 -1
View File
@@ -56,7 +56,7 @@ jobs:
filters: .github/ci-paths.yml
- name: Validate CI contracts
run: node --test scripts/ci-workflow.test.mjs scripts/daemon-launch-contract.test.mjs scripts/sync-fdroid-changelogs.test.mjs scripts/upload-release-assets.test.mjs
run: node --test scripts/ci-workflow.test.mjs scripts/daemon-launch-contract.test.mjs scripts/changelog-utils.test.mjs scripts/sync-fdroid-changelogs.test.mjs scripts/sync-release-notes-from-changelog.test.mjs scripts/upload-release-assets.test.mjs
quality:
name: quality
+135
View File
@@ -1,5 +1,140 @@
# Changelog
## 0.16.0-beta.1 - 2026-11-10
- **Background tasks:** Paseo now shows the background tasks an agent is running, such as shells and monitors, above the composer, with a Stop button for each. Works with Claude Code, Codex and OpenCode v2. ([#6220](https://github.com/getpaseo/paseo/pull/6220), [#9162](https://github.com/getpaseo/paseo/pull/9162) by [@fxlelouarn](https://github.com/fxlelouarn))
- **Faster diff loading:** Opening Changes shows the file tree right away while file contents load. Large diffs arrive in batches, so the rest of the app keeps responding while they load, and diffs take priority over the daemon's background worktree scans. ([#8907](https://github.com/getpaseo/paseo/pull/8907), [#9241](https://github.com/getpaseo/paseo/pull/9241), [#9243](https://github.com/getpaseo/paseo/pull/9243))
- **Fewer reconnects on return:** Returning to the app keeps a working connection, including one in the middle of a large response, and reconnects once when it has to. On a host with several saved connections, Paseo stays on the one that works. ([#8651](https://github.com/getpaseo/paseo/pull/8651), [#9086](https://github.com/getpaseo/paseo/pull/9086), [#8855](https://github.com/getpaseo/paseo/pull/8855), [#9116](https://github.com/getpaseo/paseo/pull/9116))
- **Keyboard shortcuts on mobile:** With a keyboard connected, iPad and Android now run the same shortcuts as desktop, including your custom bindings. Shortcut hints and Settings → Shortcuts appear when a keyboard is detected. Shortcuts also work in narrow desktop and browser windows. ([#8625](https://github.com/getpaseo/paseo/pull/8625) by [@jeroenfrenken](https://github.com/jeroenfrenken), [@lstroud](https://github.com/lstroud))
- **Touch-sized tablet controls:** On iPad and other touch-screen devices, the wide layout now has touch-sized buttons, rows and bars. The composer toolbar folds its controls into the model menu when space runs out, and swiping right on the workspace reveals a collapsed sidebar as an overlay. ([#8748](https://github.com/getpaseo/paseo/pull/8748), [#8885](https://github.com/getpaseo/paseo/pull/8885), [#8891](https://github.com/getpaseo/paseo/pull/8891), [#8909](https://github.com/getpaseo/paseo/pull/8909), [#9151](https://github.com/getpaseo/paseo/pull/9151), [#9238](https://github.com/getpaseo/paseo/pull/9238), [#8739](https://github.com/getpaseo/paseo/pull/8739) by [@zeyugao](https://github.com/zeyugao))
- **Plugin sidebar views:** A plugin can replace the sidebar's content with its own layout of workspaces, agents, projects and terminals across every connected host. Pick the view per device under Settings → Sidebar; Paseo's sidebar header and footer stay in place. ([#8906](https://github.com/getpaseo/paseo/pull/8906))
- **Plugin composer controls:** Plugins can add their own controls to the composer, and read and set its prompt, attachments, provider, model, mode and thinking. They can also put the cursor in any agent's composer, add items to New workspace, and store their own metadata on agents and workspaces. ([#8744](https://github.com/getpaseo/paseo/pull/8744), [#9209](https://github.com/getpaseo/paseo/pull/9209), [#9207](https://github.com/getpaseo/paseo/pull/9207))
- **Polish:** The chat stays still when a turn ends, and returning to a chat that has not changed shows it as it is with no refresh. Tool rows show their final title and icon from the first frame, the context window popover opens with usage already loaded, and sidebar hover cards open one at a time. ([#8944](https://github.com/getpaseo/paseo/pull/8944), [#9175](https://github.com/getpaseo/paseo/pull/9175), [#9242](https://github.com/getpaseo/paseo/pull/9242), [#9248](https://github.com/getpaseo/paseo/pull/9248), [#9212](https://github.com/getpaseo/paseo/pull/9212), [#9071](https://github.com/getpaseo/paseo/pull/9071))
- **Rebuilt mobile chat timeline:** The chat timeline on iOS and Android was rebuilt. Expanding or collapsing a turn summary, todo card or tool-call group keeps the line you tapped in place, and on Android so do Show more, Show less and plan cards. ([#8717](https://github.com/getpaseo/paseo/pull/8717), [#9085](https://github.com/getpaseo/paseo/pull/9085))
- **Undo workspace archive:** Archiving a workspace in the app now shows a "Workspace archived" toast for six seconds. Undo, or Cmd/Ctrl+Z, restores the workspace with its tabs and agents. On desktop, sidebar workspace rows also get an archive button on hover. ([#8904](https://github.com/getpaseo/paseo/pull/8904), [#9104](https://github.com/getpaseo/paseo/pull/9104))
### Added
- Added a Change base picker in Changes for worktree workspaces, so diffs, the commit list and Git actions can use another branch ([#8642](https://github.com/getpaseo/paseo/pull/8642), [#8934](https://github.com/getpaseo/paseo/pull/8934) by [@Myasuka](https://github.com/Myasuka))
- Added links for issue and pull request references such as `#123` and `owner/repo#123` in agent replies, plans, the pull request panel and file previews, for GitHub, GitLab, Gitea, Forgejo and Codeberg ([#8636](https://github.com/getpaseo/paseo/pull/8636))
- Added iPhone Duo layout support: workspace actions sit in the system vertical bar, and the sidebar and workspace split on either side of the fold ([#8741](https://github.com/getpaseo/paseo/pull/8741), [#8901](https://github.com/getpaseo/paseo/pull/8901) by [@zeyugao](https://github.com/zeyugao))
- Added fx as a provider you can add from the provider catalog in Settings → Hosts → Providers ([#9160](https://github.com/getpaseo/paseo/pull/9160))
- Added background agents: an agent or plugin can create a helper with `background: true` that opens no tab, sends no notification and stays out of the sidebar and default agent lists ([#8683](https://github.com/getpaseo/paseo/pull/8683))
- Added syntax highlighting for Terraform and HCL files (`.tf`, `.tfvars`, `.hcl`) ([#9106](https://github.com/getpaseo/paseo/pull/9106))
- Added `PASEO_WORKSPACE_ID` to each agent's environment so an agent can identify, rename or archive its own workspace ([#8939](https://github.com/getpaseo/paseo/pull/8939) by [@DTTerastar](https://github.com/DTTerastar))
### Changed
- Changed opening a workspace to restore the tab you left instead of jumping to the agent that needs attention ([#8935](https://github.com/getpaseo/paseo/pull/8935))
- Changed question cards so picking an option no longer advances automatically; continue with Next or Submit ([#8525](https://github.com/getpaseo/paseo/pull/8525))
- Changed the slash command menu to show loading and error states with Retry, and to stop selecting `/exit` by default ([#8905](https://github.com/getpaseo/paseo/pull/8905))
- Changed the workspace header and tabs to switch to compact controls when Explorer or a split pane narrows the workspace ([#8740](https://github.com/getpaseo/paseo/pull/8740), [#9088](https://github.com/getpaseo/paseo/pull/9088) by [@zeyugao](https://github.com/zeyugao))
- Changed sending a message to a Pi agent whose process has exited to fail immediately with an instruction to reload the agent ([#8718](https://github.com/getpaseo/paseo/pull/8718))
### Fixed
- Fixed the working indicator disappearing when returning to a chat whose agent finished while you were away ([#9170](https://github.com/getpaseo/paseo/pull/9170))
- Fixed chats with short collapsed turns opening with empty space until you scrolled ([#8358](https://github.com/getpaseo/paseo/pull/8358), [#9083](https://github.com/getpaseo/paseo/pull/9083))
- Fixed Collapse turns hiding an earlier reply inside a later turn's summary line after an idle Claude, Codex or OpenCode agent was resumed ([#9098](https://github.com/getpaseo/paseo/pull/9098))
- Fixed the missing space between a prompt and the reply or collapsed-turn summary line below it ([#9009](https://github.com/getpaseo/paseo/pull/9009))
- Fixed Markdown tables showing as literal pipes when written directly below a paragraph with no blank line ([#8485](https://github.com/getpaseo/paseo/pull/8485) by [@ragokan](https://github.com/ragokan), [@suk-6](https://github.com/suk-6), [@thsrite](https://github.com/thsrite))
- Fixed linked badge images that share a line with text rendering as full-width blocks on web and desktop ([#8530](https://github.com/getpaseo/paseo/pull/8530))
- Fixed View diagram opening fullscreen instead of returning to the inline Mermaid diagram after View source ([#8932](https://github.com/getpaseo/paseo/pull/8932) by [@luoph](https://github.com/luoph))
- Fixed mouse-wheel scrolling running in the opposite direction in chats on Android ([#8693](https://github.com/getpaseo/paseo/pull/8693) by [@sjeon87](https://github.com/sjeon87))
- Fixed unsent composer text and attachments being deleted when an agent tab was closed ([#9079](https://github.com/getpaseo/paseo/pull/9079))
- Fixed pasting text with hyperlinks into the composer dropping the link URLs; links now paste as Markdown ([#8972](https://github.com/getpaseo/paseo/pull/8972))
- Fixed the composer losing focus after clicking Send with a mouse ([#9015](https://github.com/getpaseo/paseo/pull/9015))
- Fixed the composer resting 50pt above the bottom edge on iPhone ([#8823](https://github.com/getpaseo/paseo/pull/8823) by [@zeyugao](https://github.com/zeyugao))
- Fixed Add host sending you to Settings from the sidebar and New workspace ([#8512](https://github.com/getpaseo/paseo/pull/8512))
- Fixed a failed agent's red dot and Failed grouping staying in the sidebar after opening the agent or choosing Mark as read ([#8831](https://github.com/getpaseo/paseo/pull/8831))
- Fixed a workspace's unread indicator staying on after it was cleared while the agent's runtime was closed ([#9087](https://github.com/getpaseo/paseo/pull/9087))
- Fixed the context window popover closing by itself whenever the chat scrolled during a streaming reply ([#8742](https://github.com/getpaseo/paseo/pull/8742) by [@3ae3ae](https://github.com/3ae3ae))
- Fixed the sidebar usage summary updating only when Usage was opened ([#7806](https://github.com/getpaseo/paseo/pull/7806) by [@ragokan](https://github.com/ragokan))
- Fixed tooltips and menus above their trigger briefly covering it on Android when their content grew after opening ([#9247](https://github.com/getpaseo/paseo/pull/9247))
- Fixed History showing "Could not load history" after reloading the web app on the History screen ([#9029](https://github.com/getpaseo/paseo/pull/9029))
- Fixed section links in an HTML file preview replacing the preview with the Paseo app instead of scrolling within the file ([#8559](https://github.com/getpaseo/paseo/pull/8559) by [@3ae3ae](https://github.com/3ae3ae))
- Fixed Files Refresh leaving a newly created file hidden when its folder was collapsed during the refresh ([#8582](https://github.com/getpaseo/paseo/pull/8582) by [@hadan8977](https://github.com/hadan8977))
- Fixed the branch switcher showing "No branches found" while branches are still loading or after the request fails ([#8938](https://github.com/getpaseo/paseo/pull/8938))
- Fixed commit history showing a doubled slash, no rename status and no diff for a file moved up a directory ([#8509](https://github.com/getpaseo/paseo/pull/8509) by [@hadan8977](https://github.com/hadan8977))
- Fixed a merged pull request disappearing from its workspace when the checkout was behind the pull request's head
- Fixed workspace Git watchers staying in fallback polling after a watcher setup timeout
- Fixed abandoned connections staying open for up to 45 seconds after the web or desktop app reconnected ([#9067](https://github.com/getpaseo/paseo/pull/9067))
- Fixed a connection routed through another host closing with "The receiving client cannot keep up with host output" while fetching a very large timeline ([#9109](https://github.com/getpaseo/paseo/pull/9109))
- Fixed a connected host staying in error after it was updated to a supported version ([#8423](https://github.com/getpaseo/paseo/pull/8423) by [@DAnaeto](https://github.com/DAnaeto))
- Fixed handshake retries and a once-per-second `relay_e2ee_channel_error` warning continuing after a connection attempt to an unreachable host had timed out ([#9025](https://github.com/getpaseo/paseo/pull/9025), [#9038](https://github.com/getpaseo/paseo/pull/9038))
- Fixed app diagnostics reporting `Last online: never` for a host that had connected and then disconnected ([#9054](https://github.com/getpaseo/paseo/pull/9054), [#9058](https://github.com/getpaseo/paseo/pull/9058))
- Fixed shell commands started by a Codex turn continuing to run after the turn is stopped or interrupted ([#7525](https://github.com/getpaseo/paseo/pull/7525) by [@thomasvan](https://github.com/thomasvan))
- Fixed Claude turns that stop on an API error, such as a session limit or an invalid API key, being reported as completed instead of failed ([#8480](https://github.com/getpaseo/paseo/pull/8480) by [@RRD-dev](https://github.com/RRD-dev), [@luoph](https://github.com/luoph))
- Fixed Claude processes staying in memory until the daemon restarted when an idle chat was reopened without sending a prompt ([#9019](https://github.com/getpaseo/paseo/pull/9019) by [@Brandon168](https://github.com/Brandon168))
- Fixed Claude agents not resuming after a graceful daemon stop or restart when agent recovery is turned on ([#8686](https://github.com/getpaseo/paseo/pull/8686) by [@babo-jeonguk](https://github.com/babo-jeonguk))
- Fixed the effort control missing in the composer for Claude models that come from Claude's `settings.json` ([#8699](https://github.com/getpaseo/paseo/pull/8699) by [@ragokan](https://github.com/ragokan), [@yahao123](https://github.com/yahao123))
- Fixed importing a Claude session failing with `Invalid string length` when its transcript is very large ([#8457](https://github.com/getpaseo/paseo/pull/8457) by [@qwist1233-cpu](https://github.com/qwist1233-cpu))
- Fixed Claude turns that end at the maximum-turn limit losing their final token counts and cost ([#8519](https://github.com/getpaseo/paseo/pull/8519))
- Fixed Codex "Apply file changes" approvals showing no file name or diff before you accept ([#8585](https://github.com/getpaseo/paseo/pull/8585) by [@ragokan](https://github.com/ragokan))
- Fixed the timeline showing only the first file's diff when Codex edits several files in one patch ([#8613](https://github.com/getpaseo/paseo/pull/8613) by [@ragokan](https://github.com/ragokan))
- Fixed Codex session discovery and startup intermittently failing with a SQLite initialization error when several Codex operations start at once ([#9008](https://github.com/getpaseo/paseo/pull/9008) by [@colonelpanic8](https://github.com/colonelpanic8), [@michaelmwu](https://github.com/michaelmwu))
- Fixed OpenCode conversations that were mid-turn during a daemon restart showing idle and ignoring Stop ([#8698](https://github.com/getpaseo/paseo/pull/8698))
- Fixed OpenCode being told you declined a tool call when you sent a message while its approval was pending ([#8477](https://github.com/getpaseo/paseo/pull/8477))
- Fixed reopened Pi conversations missing the messages from before a context compaction ([#8523](https://github.com/getpaseo/paseo/pull/8523))
- Fixed archived Pi chats failing to open with `spawn pi ENOENT` after their working folder was removed ([#8442](https://github.com/getpaseo/paseo/pull/8442) by [@DAnaeto](https://github.com/DAnaeto))
- Fixed reading an archived Pi chat leaving a Pi process running when idle runtime cleanup is disabled ([#8554](https://github.com/getpaseo/paseo/pull/8554) by [@rylwin](https://github.com/rylwin))
- Fixed a prompt sent to an ACP agent whose process has exited staying in running forever instead of failing with an error ([#8406](https://github.com/getpaseo/paseo/pull/8406) by [@DAnaeto](https://github.com/DAnaeto))
- Fixed an ACP agent staying stuck on "waiting for permission" after it abandoned an approval request and ended its turn ([#8548](https://github.com/getpaseo/paseo/pull/8548) by [@3ae3ae](https://github.com/3ae3ae))
- Fixed changing the mode of an ACP agent failing with `invalid_params` after the agent sends a config update ([#8912](https://github.com/getpaseo/paseo/pull/8912) by [@a-sh](https://github.com/a-sh))
- Fixed Paseo showing and saving an unavailable model after selecting it on a running ACP agent ([#8957](https://github.com/getpaseo/paseo/pull/8957))
- Fixed importing a session switching it from its own model to the provider's default model ([#8948](https://github.com/getpaseo/paseo/pull/8948))
- Fixed a message from another agent cancelling the recipient's pending question or approval before you could answer it ([#8453](https://github.com/getpaseo/paseo/pull/8453) by [@DAnaeto](https://github.com/DAnaeto))
- Fixed an agent never being notified of completion after sending a prompt to a closed or archived agent on a connected host ([#8575](https://github.com/getpaseo/paseo/pull/8575))
- Fixed creating a child agent failing with "Caller agent not found" after a daemon restart until the parent chat had been opened ([#8714](https://github.com/getpaseo/paseo/pull/8714))
- Fixed an agent still showing as failed after it resumed from a provider error and completed its next turn ([#8083](https://github.com/getpaseo/paseo/pull/8083) by [@luoph](https://github.com/luoph))
- Fixed a detached subagent still showing under its archived parent until refresh ([#8903](https://github.com/getpaseo/paseo/pull/8903) by [@luoph](https://github.com/luoph))
- Fixed opening an agent failing with "Working directory does not exist" instead of showing its saved conversation ([#8737](https://github.com/getpaseo/paseo/pull/8737))
- Fixed the daemon re-reading all archived agent and workspace history on every workspace update ([#8735](https://github.com/getpaseo/paseo/pull/8735))
- Fixed the daemon repeatedly running Git commands in a workspace folder that was deleted while its file watcher was in fallback polling ([#8462](https://github.com/getpaseo/paseo/pull/8462) by [@DAnaeto](https://github.com/DAnaeto))
- Fixed workspace service connections staying open after the browser tab closes or reloads ([#8421](https://github.com/getpaseo/paseo/pull/8421) by [@DAnaeto](https://github.com/DAnaeto))
- Fixed the daemon web UI answering missing `/.well-known/*` requests with the app page instead of a 404 ([#8622](https://github.com/getpaseo/paseo/pull/8622) by [@HomicideZero](https://github.com/HomicideZero))
- Fixed schedules accepting cron steps after a single number, such as `0/15 * * * *`, and running them hourly; they are now rejected ([#8607](https://github.com/getpaseo/paseo/pull/8607) by [@Mar-garet](https://github.com/Mar-garet))
- Fixed cron schedules that set both day-of-month and day-of-week running only when both matched instead of when either matches ([#8611](https://github.com/getpaseo/paseo/pull/8611) by [@Mar-garet](https://github.com/Mar-garet))
- Fixed `paseo logs --json` printing the plain-text transcript instead of JSON ([#8589](https://github.com/getpaseo/paseo/pull/8589) by [@hadan8977](https://github.com/hadan8977))
- Fixed the `create_agent` tool not telling agents that creating an agent on another provider needs a mode ([#8483](https://github.com/getpaseo/paseo/pull/8483) by [@guerinnoah](https://github.com/guerinnoah))
- Fixed terminals failing to open on Windows machines where Windows PowerShell cannot run `Get-CimInstance` ([#8818](https://github.com/getpaseo/paseo/pull/8818))
- Fixed the terminal typing its version reply into the shell when a program queries the terminal version ([#8419](https://github.com/getpaseo/paseo/pull/8419) by [@DAnaeto](https://github.com/DAnaeto))
- Fixed a finger drag in the web terminal scrolling history about twice as far as the drag ([#8776](https://github.com/getpaseo/paseo/pull/8776) by [@sinomoe](https://github.com/sinomoe))
- Fixed mouse scrolling in terminal programs such as Copilot CLI stopping after switching away from the terminal and back ([#9004](https://github.com/getpaseo/paseo/pull/9004) by [@imink](https://github.com/imink))
- Fixed dropping several files onto a remote terminal pasting their paths with no space between them ([#8946](https://github.com/getpaseo/paseo/pull/8946) by [@sebastianarena](https://github.com/sebastianarena))
- Fixed the `browser_fill` tool reporting success without updating React-controlled inputs and textareas ([#9022](https://github.com/getpaseo/paseo/pull/9022))
- Fixed `browser_evaluate` failing on pages whose Content Security Policy omits `unsafe-eval` ([#8973](https://github.com/getpaseo/paseo/pull/8973) by [@amustapha](https://github.com/amustapha))
- Fixed `browser_logs` omitting the HTTP status of each network request ([#8956](https://github.com/getpaseo/paseo/pull/8956) by [@amustapha](https://github.com/amustapha))
### Plugins
- Added plugin subscriptions, which stream live events from the daemon to a plugin's panels, and binary (`Uint8Array`) payloads for plugin RPCs and events ([#8527](https://github.com/getpaseo/paseo/pull/8527))
- Changed Settings → Plugins to open a plugin's settings from a cog button, with a tab per settings screen ([#8675](https://github.com/getpaseo/paseo/pull/8675))
- Added an upward chevron to plugin composer pills that open a menu or popover ([#8673](https://github.com/getpaseo/paseo/pull/8673))
- Changed daemon startup to launch configured plugins concurrently instead of one after another ([#8427](https://github.com/getpaseo/paseo/pull/8427) by [@DAnaeto](https://github.com/DAnaeto))
- Fixed registry plugin installs failing with "npm artifact changed since review" when the host's npm uses a registry mirror ([#8756](https://github.com/getpaseo/paseo/pull/8756) by [@tlipoca9](https://github.com/tlipoca9))
- Fixed the published `@getpaseo/*` npm packages declaring no license; they now declare Apache-2.0 ([#7816](https://github.com/getpaseo/paseo/pull/7816) by [@mdlawson](https://github.com/mdlawson), [@ragokan](https://github.com/ragokan))
- Fixed forking from an earlier reply failing with "This checkpoint changed after it was created" after a plugin updated one of its timeline cards ([#8746](https://github.com/getpaseo/paseo/pull/8746) by [@monarchmaisuriya](https://github.com/monarchmaisuriya))
- Fixed Collapse turns leaving a turn open, or folding to an earlier reply, when a plugin renders the final reply ([#8811](https://github.com/getpaseo/paseo/pull/8811) by [@geoqiao](https://github.com/geoqiao))
- Fixed plugin screens showing "This plugin surface is unavailable" while the plugin catalog is still loading after an app reload ([#8123](https://github.com/getpaseo/paseo/pull/8123) by [@vs-tahir](https://github.com/vs-tahir))
- Fixed the Workspaces list being pushed out of the mobile sidebar on short screens when plugins add many navigation items ([#8727](https://github.com/getpaseo/paseo/pull/8727) by [@jaredwang](https://github.com/jaredwang))
- Fixed timeline plugins not receiving the sender, event and original text on agent-message tool calls ([#8492](https://github.com/getpaseo/paseo/pull/8492) by [@RRD-dev](https://github.com/RRD-dev), [@luoph](https://github.com/luoph))
- Fixed plugin RPCs failing when their input or output schema uses a transform ([#8550](https://github.com/getpaseo/paseo/pull/8550))
### Notes
- Background tasks, the Change base picker, archive undo, background agents, plugin subscriptions and fx need both the host and the app on this version
## 0.15.4 - 2026-11-10
### Added
- Added Claude Fable 5.2 and Mythos 5.2 with medium default effort ([#9233](https://github.com/getpaseo/paseo/pull/9233) by [@gengjiawen](https://github.com/gengjiawen), [@gudcks](https://github.com/gudcks))
### Notes
- Both models require Claude Code 2.1.321 or newer; Mythos 5.2 appears only when Claude Code advertises access
## 0.15.3 - 2026-11-06
### Fixed
+135
View File
@@ -1,5 +1,140 @@
# Changelog
## 0.16.0-beta.1 - 2026-11-10
- **后台任务**:Paseo 现在会在 Composer 上方显示 Agent 正在运行的后台任务,例如 shell 和监视器,每项都带“停止”按钮。支持 Claude Code、Codex 和 OpenCode v2。 ([#6220](https://github.com/getpaseo/paseo/pull/6220), [#9162](https://github.com/getpaseo/paseo/pull/9162),贡献者:[@fxlelouarn](https://github.com/fxlelouarn))
- **更快的 diff 加载**:打开“更改”时会立即显示文件树,文件内容随后加载。大型 diff 分批到达,加载期间应用的其他部分照常响应,并且 diff 优先于 daemon 的后台 worktree 扫描。 ([#8907](https://github.com/getpaseo/paseo/pull/8907), [#9241](https://github.com/getpaseo/paseo/pull/9241), [#9243](https://github.com/getpaseo/paseo/pull/9243))
- **返回时更少重连**:返回应用时会保留仍可用的连接,包括正在传输大型响应的连接,只在必要时重连一次。主机保存了多个连接时,Paseo 会留在可用的那个连接上。 ([#8651](https://github.com/getpaseo/paseo/pull/8651), [#9086](https://github.com/getpaseo/paseo/pull/9086), [#8855](https://github.com/getpaseo/paseo/pull/8855), [#9116](https://github.com/getpaseo/paseo/pull/9116))
- **移动端键盘快捷键**:连接键盘后,iPad 和 Android 现在可使用与桌面端相同的快捷键,包括你自定义的绑定。检测到键盘时会显示快捷键提示和“设置 → 快捷键”。在较窄的桌面端和浏览器窗口中快捷键同样可用。 ([#8625](https://github.com/getpaseo/paseo/pull/8625),贡献者:[@jeroenfrenken](https://github.com/jeroenfrenken)、[@lstroud](https://github.com/lstroud))
- **适合触控的平板控件**:在 iPad 和其他触屏设备上,宽屏布局现在使用适合触控尺寸的按钮、行和栏。Composer 工具栏在空间不足时会把控件收进模型菜单;在工作区上向右滑动,可将已折叠的侧边栏以浮层方式显示。 ([#8748](https://github.com/getpaseo/paseo/pull/8748), [#8885](https://github.com/getpaseo/paseo/pull/8885), [#8891](https://github.com/getpaseo/paseo/pull/8891), [#8909](https://github.com/getpaseo/paseo/pull/8909), [#9151](https://github.com/getpaseo/paseo/pull/9151), [#9238](https://github.com/getpaseo/paseo/pull/9238), [#8739](https://github.com/getpaseo/paseo/pull/8739),贡献者:[@zeyugao](https://github.com/zeyugao))
- **插件侧边栏视图**:插件可以用自己的布局替换侧边栏内容,展示所有已连接主机上的工作区、Agent、项目和终端。在“设置 → 侧边栏”中为每台设备选择视图;Paseo 的侧边栏页眉和页脚保持不变。 ([#8906](https://github.com/getpaseo/paseo/pull/8906))
- **插件 Composer 控件**:插件可以向 Composer 添加自己的控件,并读取和设置其中的提示词、附件、Provider、模型、模式和思考强度。插件还可以把光标放到任意 Agent 的 Composer 中、向“新建工作区”添加条目,并在 Agent 和工作区上保存自己的元数据。 ([#8744](https://github.com/getpaseo/paseo/pull/8744), [#9209](https://github.com/getpaseo/paseo/pull/9209), [#9207](https://github.com/getpaseo/paseo/pull/9207))
- **细节打磨**:一轮对话结束时聊天保持不动,返回没有变化的聊天时直接显示原样,无需刷新。工具行从第一帧起就显示最终标题和图标,上下文窗口浮层打开时用量已加载完成,侧边栏悬停卡片一次只打开一个。 ([#8944](https://github.com/getpaseo/paseo/pull/8944), [#9175](https://github.com/getpaseo/paseo/pull/9175), [#9242](https://github.com/getpaseo/paseo/pull/9242), [#9248](https://github.com/getpaseo/paseo/pull/9248), [#9212](https://github.com/getpaseo/paseo/pull/9212), [#9071](https://github.com/getpaseo/paseo/pull/9071))
- **重建移动端聊天时间线**:iOS 和 Android 上的聊天时间线已重建。展开或折叠轮次摘要、待办卡片或工具调用组时,你点按的那一行保持原位;在 Android 上,“显示更多”“收起”和计划卡片也是如此。 ([#8717](https://github.com/getpaseo/paseo/pull/8717), [#9085](https://github.com/getpaseo/paseo/pull/9085))
- **撤销工作区归档**:在应用中归档工作区时,现在会显示“Workspace 已归档”提示,持续六秒。点击“撤销”或按 Cmd/Ctrl+Z 可恢复该工作区及其标签页和 Agent。在桌面端,侧边栏的工作区行在悬停时还会显示归档按钮。 ([#8904](https://github.com/getpaseo/paseo/pull/8904), [#9104](https://github.com/getpaseo/paseo/pull/9104))
### 新增
- 在“更改”中为 worktree 工作区新增基准分支选择器(Change base),diff、提交列表和 Git 操作可改用其他分支 ([#8642](https://github.com/getpaseo/paseo/pull/8642), [#8934](https://github.com/getpaseo/paseo/pull/8934),贡献者:[@Myasuka](https://github.com/Myasuka))
- 新增 Issue 和 PR 引用链接:Agent 回复、计划、PR 面板和文件预览中的 `#123`、`owner/repo#123` 等引用会链接到 GitHub、GitLab、Gitea、Forgejo 或 Codeberg ([#8636](https://github.com/getpaseo/paseo/pull/8636))
- 新增 iPhone Duo 布局支持:工作区操作位于系统竖向栏中,侧边栏和工作区分列折痕两侧 ([#8741](https://github.com/getpaseo/paseo/pull/8741), [#8901](https://github.com/getpaseo/paseo/pull/8901),贡献者:[@zeyugao](https://github.com/zeyugao))
- 新增 fx Provider,可在“设置 → Hosts → 提供方”的 Provider 目录中添加 ([#9160](https://github.com/getpaseo/paseo/pull/9160))
- 新增后台 Agent:Agent 或插件可通过 `background: true` 创建辅助 Agent,它不打开标签页、不发送通知,也不出现在侧边栏和默认 Agent 列表中 ([#8683](https://github.com/getpaseo/paseo/pull/8683))
- 新增 Terraform 和 HCL 文件(`.tf`、`.tfvars`、`.hcl`)的语法高亮 ([#9106](https://github.com/getpaseo/paseo/pull/9106))
- 在每个 Agent 的环境中新增 `PASEO_WORKSPACE_ID`,Agent 可据此识别、重命名或归档自己的工作区 ([#8939](https://github.com/getpaseo/paseo/pull/8939),贡献者:[@DTTerastar](https://github.com/DTTerastar))
### 变更
- 打开工作区时恢复你离开时的标签页,不再跳转到需要关注的 Agent ([#8935](https://github.com/getpaseo/paseo/pull/8935))
- 问题卡片中选择选项后不再自动前进;请用“下一步”或“提交”继续 ([#8525](https://github.com/getpaseo/paseo/pull/8525))
- 斜杠命令菜单现在显示加载和错误状态并提供“重试”,且不再默认选中 `/exit` ([#8905](https://github.com/getpaseo/paseo/pull/8905))
- 文件浏览器(Explorer)或分屏使工作区变窄时,工作区页眉和标签页改用紧凑控件 ([#8740](https://github.com/getpaseo/paseo/pull/8740), [#9088](https://github.com/getpaseo/paseo/pull/9088),贡献者:[@zeyugao](https://github.com/zeyugao))
- 向进程已退出的 Pi Agent 发送消息时立即失败,并提示重新加载该 Agent ([#8718](https://github.com/getpaseo/paseo/pull/8718))
### 修复
- 修复 Agent 在你离开期间完成后,返回聊天时工作指示器消失的问题 ([#9170](https://github.com/getpaseo/paseo/pull/9170))
- 修复包含较短折叠轮次的聊天打开时留有空白、需滚动后才填满的问题 ([#8358](https://github.com/getpaseo/paseo/pull/8358), [#9083](https://github.com/getpaseo/paseo/pull/9083))
- 修复空闲的 Claude、Codex 或 OpenCode Agent 恢复后,“折叠轮次”把较早的回复藏进后一轮摘要行的问题 ([#9098](https://github.com/getpaseo/paseo/pull/9098))
- 修复提示词与其下方的回复或折叠轮次摘要行之间缺少间距的问题 ([#9009](https://github.com/getpaseo/paseo/pull/9009))
- 修复紧跟在段落之后、中间没有空行的 Markdown 表格显示为原始竖线的问题 ([#8485](https://github.com/getpaseo/paseo/pull/8485),贡献者:[@ragokan](https://github.com/ragokan)、[@suk-6](https://github.com/suk-6)、[@thsrite](https://github.com/thsrite))
- 修复网页版和桌面端中与文字同行的带链接徽章图片被渲染成整行宽度的问题 ([#8530](https://github.com/getpaseo/paseo/pull/8530))
- 修复“查看源码”后点击“查看图表”会打开全屏、而不是回到内联 Mermaid 图表的问题 ([#8932](https://github.com/getpaseo/paseo/pull/8932),贡献者:[@luoph](https://github.com/luoph))
- 修复 Android 上聊天中鼠标滚轮滚动方向相反的问题 ([#8693](https://github.com/getpaseo/paseo/pull/8693),贡献者:[@sjeon87](https://github.com/sjeon87))
- 修复关闭 Agent 标签页时未发送的 Composer 文本和附件被删除的问题 ([#9079](https://github.com/getpaseo/paseo/pull/9079))
- 修复向 Composer 粘贴带超链接的文本时丢失链接地址的问题;链接现在以 Markdown 形式粘贴 ([#8972](https://github.com/getpaseo/paseo/pull/8972))
- 修复用鼠标点击“发送”后 Composer 失去焦点的问题 ([#9015](https://github.com/getpaseo/paseo/pull/9015))
- 修复 iPhone 上 Composer 停在距底部边缘 50pt 处的问题 ([#8823](https://github.com/getpaseo/paseo/pull/8823),贡献者:[@zeyugao](https://github.com/zeyugao))
- 修复从侧边栏和“新建工作区”点击“添加主机”会跳转到“设置”的问题 ([#8512](https://github.com/getpaseo/paseo/pull/8512))
- 修复打开失败的 Agent 或选择 Mark as read 后,其红点和“失败”分组仍留在侧边栏的问题 ([#8831](https://github.com/getpaseo/paseo/pull/8831))
- 修复在 Agent 运行时已关闭的情况下清除未读后,工作区的未读指示仍然亮着的问题 ([#9087](https://github.com/getpaseo/paseo/pull/9087))
- 修复流式回复期间聊天一滚动上下文窗口浮层就自行关闭的问题 ([#8742](https://github.com/getpaseo/paseo/pull/8742),贡献者:[@3ae3ae](https://github.com/3ae3ae))
- 修复侧边栏用量摘要只在打开“使用情况”时才更新的问题 ([#7806](https://github.com/getpaseo/paseo/pull/7806),贡献者:[@ragokan](https://github.com/ragokan))
- 修复 Android 上位于触发元素上方的提示和菜单在打开后内容变多时短暂盖住触发元素的问题 ([#9247](https://github.com/getpaseo/paseo/pull/9247))
- 修复在“历史”页面刷新网页版后显示“无法加载历史”的问题 ([#9029](https://github.com/getpaseo/paseo/pull/9029))
- 修复 HTML 文件预览中的章节链接把预览替换成 Paseo 应用、而不是在文件内滚动的问题 ([#8559](https://github.com/getpaseo/paseo/pull/8559),贡献者:[@3ae3ae](https://github.com/3ae3ae))
- 修复“文件”刷新期间文件夹处于折叠状态时,新建的文件一直不显示的问题 ([#8582](https://github.com/getpaseo/paseo/pull/8582),贡献者:[@hadan8977](https://github.com/hadan8977))
- 修复分支仍在加载或请求失败时,分支切换器显示“没有找到分支。”的问题 ([#8938](https://github.com/getpaseo/paseo/pull/8938))
- 修复文件被移到上一级目录时,提交历史显示双斜杠、没有重命名状态也没有 diff 的问题 ([#8509](https://github.com/getpaseo/paseo/pull/8509),贡献者:[@hadan8977](https://github.com/hadan8977))
- 修复检出落后于 PR 头部时,已合并的 PR 从其工作区中消失的问题
- 修复工作区 Git 监视器在设置超时后一直停留在回退轮询的问题
- 修复网页版或桌面端重连后,被放弃的连接最多保持打开 45 秒的问题 ([#9067](https://github.com/getpaseo/paseo/pull/9067))
- 修复通过另一台主机路由的连接在获取超大时间线时以“The receiving client cannot keep up with host output”关闭的问题 ([#9109](https://github.com/getpaseo/paseo/pull/9109))
- 修复已连接的主机在更新到受支持的版本后仍停留在错误状态的问题 ([#8423](https://github.com/getpaseo/paseo/pull/8423),贡献者:[@DAnaeto](https://github.com/DAnaeto))
- 修复连接不可达主机的尝试超时后,握手重试和每秒一次的 `relay_e2ee_channel_error` 警告仍在继续的问题 ([#9025](https://github.com/getpaseo/paseo/pull/9025), [#9038](https://github.com/getpaseo/paseo/pull/9038))
- 修复应用诊断对曾经连接后又断开的主机报告 `Last online: never` 的问题 ([#9054](https://github.com/getpaseo/paseo/pull/9054), [#9058](https://github.com/getpaseo/paseo/pull/9058))
- 修复 Codex 轮次被停止或中断后,该轮次启动的 shell 命令仍继续运行的问题 ([#7525](https://github.com/getpaseo/paseo/pull/7525),贡献者:[@thomasvan](https://github.com/thomasvan))
- 修复因 API 错误(例如会话限额或 API key 无效)而停止的 Claude 轮次被报告为已完成而非失败的问题 ([#8480](https://github.com/getpaseo/paseo/pull/8480),贡献者:[@RRD-dev](https://github.com/RRD-dev)、[@luoph](https://github.com/luoph))
- 修复重新打开空闲聊天但未发送提示词时,Claude 进程一直留在内存中直到 daemon 重启的问题 ([#9019](https://github.com/getpaseo/paseo/pull/9019),贡献者:[@Brandon168](https://github.com/Brandon168))
- 修复开启 Agent 恢复后,daemon 正常停止或重启时 Claude Agent 不恢复的问题 ([#8686](https://github.com/getpaseo/paseo/pull/8686),贡献者:[@babo-jeonguk](https://github.com/babo-jeonguk))
- 修复来自 Claude `settings.json` 的 Claude 模型在 Composer 中缺少推理强度控件的问题 ([#8699](https://github.com/getpaseo/paseo/pull/8699),贡献者:[@ragokan](https://github.com/ragokan)、[@yahao123](https://github.com/yahao123))
- 修复对话记录非常大时导入 Claude 会话因 `Invalid string length` 失败的问题 ([#8457](https://github.com/getpaseo/paseo/pull/8457),贡献者:[@qwist1233-cpu](https://github.com/qwist1233-cpu))
- 修复在最大轮次限制处结束的 Claude 轮次丢失最终 token 数和费用的问题 ([#8519](https://github.com/getpaseo/paseo/pull/8519))
- 修复 Codex 的“Apply file changes”审批在你接受之前不显示文件名和 diff 的问题 ([#8585](https://github.com/getpaseo/paseo/pull/8585),贡献者:[@ragokan](https://github.com/ragokan))
- 修复 Codex 在一个补丁中编辑多个文件时,时间线只显示第一个文件的 diff 的问题 ([#8613](https://github.com/getpaseo/paseo/pull/8613),贡献者:[@ragokan](https://github.com/ragokan))
- 修复多个 Codex 操作同时启动时,Codex 会话发现和启动偶发 SQLite 初始化错误的问题 ([#9008](https://github.com/getpaseo/paseo/pull/9008),贡献者:[@colonelpanic8](https://github.com/colonelpanic8)、[@michaelmwu](https://github.com/michaelmwu))
- 修复 daemon 重启时正处于轮次中的 OpenCode 对话显示为空闲并忽略“停止”的问题 ([#8698](https://github.com/getpaseo/paseo/pull/8698))
- 修复审批待处理期间你发送消息时,OpenCode 被告知你拒绝了工具调用的问题 ([#8477](https://github.com/getpaseo/paseo/pull/8477))
- 修复重新打开的 Pi 对话缺少上下文压缩之前的消息的问题 ([#8523](https://github.com/getpaseo/paseo/pull/8523))
- 修复工作文件夹被移除后,已归档的 Pi 聊天因 `spawn pi ENOENT` 无法打开的问题 ([#8442](https://github.com/getpaseo/paseo/pull/8442),贡献者:[@DAnaeto](https://github.com/DAnaeto))
- 修复关闭空闲运行时清理的情况下,读取已归档的 Pi 聊天会遗留一个 Pi 进程的问题 ([#8554](https://github.com/getpaseo/paseo/pull/8554),贡献者:[@rylwin](https://github.com/rylwin))
- 修复向进程已退出的 ACP Agent 发送提示词后一直停留在运行中、而不是报错失败的问题 ([#8406](https://github.com/getpaseo/paseo/pull/8406),贡献者:[@DAnaeto](https://github.com/DAnaeto))
- 修复 ACP Agent 放弃审批请求并结束轮次后一直卡在“waiting for permission”的问题 ([#8548](https://github.com/getpaseo/paseo/pull/8548),贡献者:[@3ae3ae](https://github.com/3ae3ae))
- 修复 ACP Agent 发送配置更新后,更改其模式因 `invalid_params` 失败的问题 ([#8912](https://github.com/getpaseo/paseo/pull/8912),贡献者:[@a-sh](https://github.com/a-sh))
- 修复在运行中的 ACP Agent 上选择不可用的模型后,Paseo 仍显示并保存该模型的问题 ([#8957](https://github.com/getpaseo/paseo/pull/8957))
- 修复导入会话时把它从自身的模型切换为 Provider 默认模型的问题 ([#8948](https://github.com/getpaseo/paseo/pull/8948))
- 修复来自其他 Agent 的消息在你作答之前取消接收方待处理的提问或审批的问题 ([#8453](https://github.com/getpaseo/paseo/pull/8453),贡献者:[@DAnaeto](https://github.com/DAnaeto))
- 修复向已连接主机上已关闭或已归档的 Agent 发送提示词后,发送方 Agent 一直收不到完成通知的问题 ([#8575](https://github.com/getpaseo/paseo/pull/8575))
- 修复 daemon 重启后,在打开父聊天之前创建子 Agent 因“Caller agent not found”失败的问题 ([#8714](https://github.com/getpaseo/paseo/pull/8714))
- 修复 Agent 从 Provider 错误中恢复并完成下一轮次后仍显示为失败的问题 ([#8083](https://github.com/getpaseo/paseo/pull/8083),贡献者:[@luoph](https://github.com/luoph))
- 修复已分离的子 Agent 在刷新之前仍显示在其已归档的父 Agent 之下的问题 ([#8903](https://github.com/getpaseo/paseo/pull/8903),贡献者:[@luoph](https://github.com/luoph))
- 修复打开 Agent 时因“Working directory does not exist”失败、而不是显示其已保存对话的问题 ([#8737](https://github.com/getpaseo/paseo/pull/8737))
- 修复 daemon 在每次工作区更新时重新读取全部已归档 Agent 和工作区历史的问题 ([#8735](https://github.com/getpaseo/paseo/pull/8735))
- 修复工作区文件夹在其文件监视器处于回退轮询期间被删除后,daemon 仍反复在其中运行 Git 命令的问题 ([#8462](https://github.com/getpaseo/paseo/pull/8462),贡献者:[@DAnaeto](https://github.com/DAnaeto))
- 修复浏览器标签页关闭或刷新后,工作区服务连接仍保持打开的问题 ([#8421](https://github.com/getpaseo/paseo/pull/8421),贡献者:[@DAnaeto](https://github.com/DAnaeto))
- 修复 daemon 网页界面对不存在的 `/.well-known/*` 请求返回应用页面而非 404 的问题 ([#8622](https://github.com/getpaseo/paseo/pull/8622),贡献者:[@HomicideZero](https://github.com/HomicideZero))
- 修复定时任务接受单个数字后的 cron 步长(例如 `0/15 * * * *`)并按每小时运行的问题;现在会拒绝这类表达式 ([#8607](https://github.com/getpaseo/paseo/pull/8607),贡献者:[@Mar-garet](https://github.com/Mar-garet))
- 修复同时设置了日期和星期的 cron 定时任务只在两者都匹配时运行、而不是任一匹配即运行的问题 ([#8611](https://github.com/getpaseo/paseo/pull/8611),贡献者:[@Mar-garet](https://github.com/Mar-garet))
- 修复 `paseo logs --json` 输出纯文本对话记录而非 JSON 的问题 ([#8589](https://github.com/getpaseo/paseo/pull/8589),贡献者:[@hadan8977](https://github.com/hadan8977))
- 修复 `create_agent` 工具没有告知 Agent 在其他 Provider 上创建 Agent 需要指定模式的问题 ([#8483](https://github.com/getpaseo/paseo/pull/8483),贡献者:[@guerinnoah](https://github.com/guerinnoah))
- 修复在 Windows PowerShell 无法运行 `Get-CimInstance` 的 Windows 机器上终端无法打开的问题 ([#8818](https://github.com/getpaseo/paseo/pull/8818))
- 修复程序查询终端版本时,终端把版本应答输入到 shell 中的问题 ([#8419](https://github.com/getpaseo/paseo/pull/8419),贡献者:[@DAnaeto](https://github.com/DAnaeto))
- 修复在网页版终端中用手指拖动时,历史滚动距离约为拖动距离两倍的问题 ([#8776](https://github.com/getpaseo/paseo/pull/8776),贡献者:[@sinomoe](https://github.com/sinomoe))
- 修复切换离开终端再返回后,Copilot CLI 等终端程序中的鼠标滚动失效的问题 ([#9004](https://github.com/getpaseo/paseo/pull/9004),贡献者:[@imink](https://github.com/imink))
- 修复向远程终端拖入多个文件时,粘贴的路径之间没有空格的问题 ([#8946](https://github.com/getpaseo/paseo/pull/8946),贡献者:[@sebastianarena](https://github.com/sebastianarena))
- 修复 `browser_fill` 工具在未更新 React 受控输入框和文本域的情况下报告成功的问题 ([#9022](https://github.com/getpaseo/paseo/pull/9022))
- 修复 `browser_evaluate` 在内容安全策略未包含 `unsafe-eval` 的页面上失败的问题 ([#8973](https://github.com/getpaseo/paseo/pull/8973),贡献者:[@amustapha](https://github.com/amustapha))
- 修复 `browser_logs` 缺少每个网络请求的 HTTP 状态的问题 ([#8956](https://github.com/getpaseo/paseo/pull/8956),贡献者:[@amustapha](https://github.com/amustapha))
### 插件
- 新增插件订阅,可将 daemon 的实时事件流式推送到插件面板;插件 RPC 和事件支持二进制(`Uint8Array`)负载 ([#8527](https://github.com/getpaseo/paseo/pull/8527))
- “设置 → 插件”改为通过齿轮按钮打开插件设置,每个设置页面对应一个标签页 ([#8675](https://github.com/getpaseo/paseo/pull/8675))
- 为会打开菜单或浮层的插件 Composer 胶囊按钮新增向上的箭头 ([#8673](https://github.com/getpaseo/paseo/pull/8673))
- daemon 启动时改为并发启动已配置的插件,不再逐个启动 ([#8427](https://github.com/getpaseo/paseo/pull/8427),贡献者:[@DAnaeto](https://github.com/DAnaeto))
- 修复主机的 npm 使用镜像源时,从插件库安装插件因“npm artifact changed since review”失败的问题 ([#8756](https://github.com/getpaseo/paseo/pull/8756),贡献者:[@tlipoca9](https://github.com/tlipoca9))
- 修复已发布的 `@getpaseo/*` npm 包未声明许可证的问题;现在声明为 Apache-2.0 ([#7816](https://github.com/getpaseo/paseo/pull/7816),贡献者:[@mdlawson](https://github.com/mdlawson)、[@ragokan](https://github.com/ragokan))
- 修复插件更新了自己的某张时间线卡片后,从较早的回复分叉因“This checkpoint changed after it was created”失败的问题 ([#8746](https://github.com/getpaseo/paseo/pull/8746),贡献者:[@monarchmaisuriya](https://github.com/monarchmaisuriya))
- 修复由插件渲染最终回复时,“折叠轮次”让轮次保持展开或折叠到较早回复的问题 ([#8811](https://github.com/getpaseo/paseo/pull/8811),贡献者:[@geoqiao](https://github.com/geoqiao))
- 修复应用刷新后插件目录仍在加载时,插件页面显示“This plugin surface is unavailable”的问题 ([#8123](https://github.com/getpaseo/paseo/pull/8123),贡献者:[@vs-tahir](https://github.com/vs-tahir))
- 修复插件添加了许多导航项时,矮屏上的工作区列表被挤出移动端侧边栏的问题 ([#8727](https://github.com/getpaseo/paseo/pull/8727),贡献者:[@jaredwang](https://github.com/jaredwang))
- 修复时间线插件在 Agent 消息工具调用上收不到发送方、事件和原始文本的问题 ([#8492](https://github.com/getpaseo/paseo/pull/8492),贡献者:[@RRD-dev](https://github.com/RRD-dev)、[@luoph](https://github.com/luoph))
- 修复输入或输出 schema 使用 transform 时插件 RPC 失败的问题 ([#8550](https://github.com/getpaseo/paseo/pull/8550))
### 说明
- 后台任务、基准分支选择器、归档撤销、后台 Agent、插件订阅和 fx 需要主机和应用都更新到此版本
## 0.15.4 - 2026-11-10
### 新增
- 新增 Claude Fable 5.2 和 Mythos 5.2,默认推理强度为 medium ([#9233](https://github.com/getpaseo/paseo/pull/9233),贡献者:[@gengjiawen](https://github.com/gengjiawen)、[@gudcks](https://github.com/gudcks))
### 说明
- 两个模型均需 Claude Code 2.1.321 或更高版本;仅在 Claude Code 提供访问权限时显示 Mythos 5.2
## 0.15.3 - 2026-11-06
### 修复
+1
View File
@@ -54,6 +54,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
| [docs/permissions.md](docs/permissions.md) | Semantic daemon permissions, principals, credentials, pairing invitations, and Hub authority |
| [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
| [docs/terminal-rendering.md](docs/terminal-rendering.md) | Native terminal view: xterm owns the grid, cluster placement, emoji widths, mixed-direction limits, render regression |
| [docs/timeline-sync.md](docs/timeline-sync.md) | Owned agent history, rewind retirements, live stream vs authoritative fetch, paged catch-up, client replica |
| [docs/agent-stream-performance.md](docs/agent-stream-performance.md) | Assistant text pipeline — coalescing window, paced reveal, why arrival lumps are smoothed at render |
| [docs/agent-stream-disclosure.md](docs/agent-stream-disclosure.md) | Expand/collapse in the chat timeline — hold contract, native anchor sequence, frame spacing, render-window cost |
| [docs/markdown-renderer.md](docs/markdown-renderer.md) | Shared Markdown engine, native selection, links, images and library constraints |
+6 -3
View File
@@ -39,8 +39,8 @@ An unarchived agent may be `closed` without being deleted or archived. Closing r
processes and subscriptions while retaining its Paseo identity, persistence handle, timeline,
workspace, labels, title, usage, attention, timestamps, and parent relationship. Opening or prompting
the agent runs through `ensureAgentLoaded()`, which resumes the durable provider session under the
same Paseo agent ID. Provider history is not appended again when the canonical timeline is already
primed.
same Paseo agent ID. Resume does not read provider history when the agent already has a timeline;
see [owned history](timeline-sync.md#owned-history).
Reload releases the old runtime before resuming its durable session: an idle provider process can
still own an exclusive writer. A close failure retains that runtime for cleanup and blocks the
@@ -239,7 +239,8 @@ lifecycle is separate from public workspace visibility and is not a public creat
Archive is a **soft delete**: the agent record stays on disk with `archivedAt` set, the runtime is closed, and the agent disappears from active lists. Archive is **global** — it lives on the server and propagates to every connected client.
Archive sets `archivedAt`, invokes the provider's native archive hook, and cascades to managed
children.
children. The timeline and the provider children stay, so an archived chat reads them without its
provider; deleting the agent removes them.
`create_agent_request` can opt an agent into `autoArchive`. In that mode the daemon archives the agent after the first terminal turn event (`turn_completed`, `turn_failed`, or `turn_canceled`). When the agent owns an isolated workspace, auto-archive archives that workspace too; the managed worktree is removed when its final workspace reference is gone.
@@ -332,6 +333,8 @@ 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.
Only the parent's current runtime makes a provider child running. A child's stored status is the last report about it, and that report can come from provider history or from a runtime an earlier daemon ran, so a stored `running` child does not make the workspace running or keep the parent's runtime alive. It counts again once the live session reports it running. Closing a runtime cancels the children that runtime reported running; children it never reported keep their stored status.
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
+1
View File
@@ -94,6 +94,7 @@ workspace for a directory clears it. The store lives in memory and resets on dae
| `server/directory-sync/` | Daemon-global latest-state sequences for projects, workspaces, and agents |
| `server/workspace-labels/` | Host-local label catalog, assignment mutations, and explicit subscriptions |
| `server/agent/agent-manager.ts` | Agent lifecycle state machine, timeline tracking, subscriber management |
| `server/agent/timeline/` | Owned agent history, provider children, rewind retirements, behind a backend |
| `server/agent/agent-storage.ts` | File-backed JSON persistence at `$PASEO_HOME/agents/` |
| `server/agent/tools/` | Transport-neutral catalog for workspaces, agents, permissions, and automation |
| `server/agent/mcp-server.ts` | Thin MCP adapter that registers the Paseo tool catalog with the MCP SDK |
+16
View File
@@ -180,6 +180,14 @@ Terminal activity contributes to the workspace status bucket **per `workspaceId`
---
## Runtime-only Agent Timelines
Agent timelines are not written to `$PASEO_HOME`. `AgentTimeline` owns each agent's history behind
a backend, and the only backend is in memory, so a daemon restart loses it and the next resume
imports provider history. See [owned history](timeline-sync.md#owned-history).
---
## 2. Daemon Configuration
**Path:** `$PASEO_HOME/config.json`
@@ -340,6 +348,14 @@ the number of Git processes that have started but not exited. Every Git command
including initial workspace reads, filesystem-triggered refreshes, background checks, and explicit
requests.
Commands wait in one of three lanes, drained in order: `high` (worktree creation and archive),
`normal` (everything a caller waits for, including diffs and explicit refreshes), and `background`
(workspace reconciliation, refreshes triggered by watchers, polls and fetches, and the fetches
themselves). Daemon-initiated fan-out must run in the background lane: one rescan over ~90 worktrees
queues over 1,000 commands, and in the normal lane every command a cold diff runs waited behind
them. Set the lane on the runner with `createRunGitCommand(provenance, { priority })`, or around a
whole operation with `runWithGitCommandPriority`.
Environment variables override `config.json`:
| Environment variable | Setting |
+3 -3
View File
@@ -70,9 +70,9 @@ 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.
Dense toolbar rows, such as the composer's, are the exception to the 44pt control. Touch-sized the row is 44pt tall and each control is a 36pt circle (`TOOLBAR_DISC_SIZE`): send and stop fill it, and every other control's pressed highlight fills it, so every highlight is the same fully rounded shape. Labelled pickers are 36pt pills. A control lays out at its full highlight (`toolbarSlot`, `toolbarContentSlot`) with no gap between controls (`TOOLBAR_CONTROL_GAP`), so neighbouring highlights touch and never overlap. Toolbar glyphs match the header icons: 20pt, the app's 1.5px stroke, resting at `foreground` (`toolbarGlyphsFor`). The context ring is 18pt with a 2px stroke (`TOOLBAR_RINGS`): a circle fills its box where an outline glyph does not, and a thin circle reads lighter than a glyph at the same stroke. A disc's solid glyph, such as the stop square, stays at the `md` size (`TOOLBAR_DISC_GLYPH_SIZES`). Pointer-sized toolbars keep 28px controls, a 4px gap, and a 16pt muted glyph that brightens on hover.
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`).
In the composer, the first glyph's ink starts on the text column's edge, and the trailing control is always a disc, so the row does not shift as it changes between voice mode, send and stop. The disc nests into the corner, as far from the right edge as from the bottom, and touch-sized the composer's corner radius is concentric with it (`packages/app/src/composer/input/toolbar-rails.ts`). Touch-sized the disc keeps a 6pt clearance from its neighbour, because it is always painted and a pressed neighbour would merge with it. The attach button sits 2pt closer to the picker than its highlight would put it: the plus is all thin arms, so the full gap reads wide. That offset is set by eye.
One gate, `resolveControlSizing`, picks touch or pointer sizing: touch when the layout is compact or the device has a touch screen. An iPad with the sidebar docked is wide and touch-sized; a desktop is pointer-sized; a phone is both compact and touch. The touch-screen fact is read once at startup (`hasTouchScreen` in `hooks/use-fine-pointer.ts`), so attaching a mouse or keyboard to a tablet never shrinks its controls. Stylesheets take sizes from `bySizing({ touch, pointer })`, which resolves to a breakpoint value, so a caller never sees which sizing it got. Rows, icon buttons and icon slots take theirs from `styles/density.ts`, which uses the same gate. An `{ xs, md }` literal for a hit target or glyph is wrong: on an iPad it yields the pointer size.
@@ -144,7 +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 composer toolbar is the worked example: its outermost ink, the attach `+` on the left and the trailing glyph or send/stop disc on the right, sits on the text column's edges, and the 44pt touch slots overhang into the composer padding by each end control's ink inset (`composerToolbarOverhang`).
The pressable is bigger than the glyph, and that is fine. Hit areas grow outward from the aligned content — they never move it. A button that looks two pixels off because its padding is asymmetric is misaligned even though its box is correct. 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 touch-sized the end highlights 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.
+1 -1
View File
@@ -8,7 +8,7 @@ Closing an observation must remain safe after its root is renamed or removed. Wo
`unsubscribe()` is an awaited barrier. Once it resolves, no scan, reconciliation, queued event, native handle, or callback may remain. `updateIgnore()` is the equivalent barrier for excluded paths: events queued below a newly excluded root cannot be delivered after it resolves. Treat path disappearance while scanning, attaching, or receiving a watcher error as rename/archive churn. Surface permission and resource errors so WorkspaceGit can enter bounded polling.
Linux topology repair is trailing-debounced and scoped to the renamed path. A file rename needs one classification stat; a new or moved directory scans only its subtree. Full-tree scans are reserved for startup and ignore-set replacement. The initial Linux scan conservatively emits existing files discovered before their directory watcher was installed; WorkspaceGit batches these into one refresh. Benchmark this startup cost with pre-populated trees.
Linux topology repair is trailing-debounced and scoped to the renamed path. A file rename needs one classification stat; a new or moved directory scans only its subtree. Full-tree scans are reserved for startup and ignore-set replacement. A subscription reports changes after `subscribe()` resolves, not the files that already existed. macOS FSEvents can still replay writes made moments before the stream started. Consumers read their initial state after `subscribe()` resolves; WorkspaceGit re-reads its checkout once observation setup completes, because its first refresh runs in parallel with setup. The Linux startup scan therefore emits nothing; only a scoped reconciliation of new topology reports files found before their directory's watcher was installed. Startup creates made every cold diff compute and discard a first revision. Benchmark startup cost with pre-populated trees.
Windows and macOS use one native recursive watcher plus an ignored-pruned, directory-indexed inventory. Native events deliver immediately. Named events trigger serialized shallow scans of their parent directories; known directory events also reconcile that subtree. This recovers concrete create and delete paths omitted by coalescing without walking the whole root for each edit. Classification is admission-controlled to 32 concurrent `fs.stat` calls, since `fs.stat` runs on the libuv threadpool the whole daemon shares. Startup and exclusion changes use an awaited full inventory barrier. Activity arms a full safety audit 30 seconds after it settles, with a 30-second minimum interval and a five-minute starvation bound; pathless native events also trigger an immediate shallow root scan. Observation fails into polling rather than retaining more than 250,000 file and directory entries per root. A repository that large is also expensive to poll, so degraded polling doubles its gap from 5 seconds to a 60-second ceiling while the workspace snapshot comes back unchanged, and resets to 5 seconds on the first change. Explicit reads still force a refresh; the ceiling only bounds how long an edit made outside Paseo stays undiscovered.
+7
View File
@@ -446,6 +446,13 @@ Before you write a new one, ask:
positioning (`bottom: windowHeight - anchor.y + gap`) and skip the
`contentSize` round-trip entirely. **But only if the height is genuinely
bounded**. Verify before you commit.
6. **Can the content change size after the panel paints, and does the panel
sit above its anchor?** Hold it by its bottom edge with `holdSurfaceEdge`
and `surfaceFrameStyles` from `components/ui/anchor.ts`. Native delivers
`onLayout` after the frame that shows the new size, so a panel placed by
`top` paints over its anchor until the next placement lands, about 150 ms
on Android. Web hides this because React commits the resize and the
re-placement in one task.
Then copy the closest canonical file and trim.
+23
View File
@@ -712,6 +712,29 @@ implementations provide it:
in `composer/session.tsx`: `Composer` provides `ComposerInput` (prompt, attachments),
`DraftAgentControls` provides `ComposerAgent` (settings), and the adapter enforces the lock.
`focus` differs per implementation on purpose. A running agent's model reveals the agent with
`navigateToAgent` when `ComposerInput.isRevealed()` is false, and holds one pending request until an
input that is revealed attaches. `isRevealed` is stricter than `isLive`: a compact overlay panel and
an unfocused pane leave the input live but unable to take the keyboard, so the agent is revealed
first, which also moves pane focus. `Composer` makes `revealed` part of `ComposerInput`, so a change
re-runs `attachInput`, which is what tells the pending request the input is on screen. The draft
half has nothing to navigate to and does not pull the user back to a draft they left, so it rejects
when not revealed. Sheets, menus, pickers and modals the composer opens over itself do not count
against `revealed`: `takeInputFocus` closes them through the `OverlayDismissalScope` in
`components/ui/overlay-dismissal.tsx`, and places the cursor only after the commit that removes
them. A web modal hands focus back to the button that opened it in that commit, so placing the
cursor earlier loses it. The overlay primitives register while open: every controlled bottom
sheet through `useIsolatedBottomSheetVisibility`, `AdaptiveModalSheet`'s desktop dialog, `MenuRoot`,
`Combobox`, and `Modal` through its `onRequestClose`. Their close must close; a surface built on none
of them must call `useDismissibleOverlay`. Overlapping requests share each overlay's close, and the
latest request places the cursor while the earlier ones settle with it. A native input reports
focus synchronously and refuses it while not editable, such as during dictation, so the native
branch checks `isFocused()` instead of trusting the call. The agent panel opens the scope around its whole chat
because the tracks above the composer render outside `Composer`, and the composer's own scope
joins it. `toPluginComposer` clears the Command Center's focus restore, which would
otherwise take the cursor back when the Command Center closes. Native `focus` opens the keyboard, so
the app's web-only `focusInput` stays separate and plugins go through `takeInputFocus`.
`ComposerAgent` stays for the built-in controls. Add a capability to the `ComposerModel`, both
implementations and `toPluginComposer`; never branch on `context` in the host.
+55 -4
View File
@@ -594,7 +594,7 @@ Release notes depend on the changelog heading format. The heading **must** be st
No prefix (`v`), no extra text. `Release Notes Sync` matches the `## X.Y.Z` (or `## X.Y.Z-beta.N`) line for the pushed tag to extract the version. A malformed heading breaks the release-notes sync for that tag.
`CHANGELOG.md` on `main` is also what the app's **What's new** sheet fetches and renders, so the file is a shipped product surface, not just a release input. `##` starts a release and `###` starts a section; the app reads section titles from the document, so renaming or adding one needs no app change. Everything under a section is rendered as Markdown: prose, lists, links, inline code, fenced code, block quotes, tables, and images. Raw HTML does not render — the shared Markdown parser runs with `html: false`, so a `<video>`, `<iframe>` or `<embed>` tag reaches the reader as visible markup. Keep media out of the changelog, or link to it. A GitHub callout renders as a block quote with its `[!NOTE]` marker still in the text. A release entry is what a user reads on a phone the moment they are offered the update — write it for them.
`CHANGELOG.md` on `main` is also what the app's **What's new** sheet fetches and renders, so the file is a shipped product surface, not just a release input. `##` starts a release and `###` starts a section; the app reads section titles from the document, so renaming or adding one needs no app change. Content between the release heading and the first `###` is the release's [highlights](#changelog-highlights). The What's new sheet, the website changelog and the GitHub release body show the highlights and keep the sections collapsed behind one control: Show all changes in the app and on the website, and an All changes `<details>` block that `Release Notes Sync` writes into the release body, so the file itself never needs HTML. A release with nothing before its first `###` shows its sections. Older entries that open with summary paragraphs are treated as highlights. The app, the website and the release scripts each parse the file with their own code, and installed apps keep the parser they shipped with, so those two heading rules are fixed: new kinds of content go inside the highlights or a section as Markdown, never behind a new heading level or marker. `scripts/test-support/changelog-structure.json` holds the cases all three parsers are tested against; change a structure rule there first. Everything under a section is rendered as Markdown: prose, lists, links, inline code, fenced code, block quotes, tables, and images. Raw HTML does not render — the shared Markdown parser runs with `html: false`, so a `<video>`, `<iframe>` or `<embed>` tag reaches the reader as visible markup. Keep media out of the changelog, or link to it. A GitHub callout renders as a block quote with its `[!NOTE]` marker still in the text. A release entry is what a user reads on a phone the moment they are offered the update — write it for them.
## Changelog policy
@@ -613,7 +613,7 @@ Older prerelease bodies on GitHub keep the notes they were synced with; `Release
- The release agent writes the Chinese entry in the changelog commit, after the English entry is approved. Every beta that rewrites the English entry rewrites the Chinese one too, and promotion renames both. `packages/website/e2e/i18n.spec.ts` fails CI on the release commit when any entry has no current translation.
- A translated entry uses the English heading unchanged: `## X.Y.Z - YYYY-MM-DD`. The website uses it only while the version and date both match the English entry. Otherwise it shows that release whole in English under a notice. Delete Chinese entries whose English entry was renamed.
- Keep the English sections, bullets and links one to one. Section titles: Added 新增, Improved 改进, Changed 变更, Fixed 修复, Security 安全, Breaking 破坏性变更, Plugins 插件, Notes 说明. Wording follows [website-i18n.md](website-i18n.md#chinese-style).
- Keep the English highlights, sections, bullets and links one to one. Section titles: Added 新增, Improved 改进, Changed 变更, Fixed 修复, Security 安全, Breaking 破坏性变更, Plugins 插件, Notes 说明. Wording follows [website-i18n.md](website-i18n.md#chinese-style).
## Changelog ownership
@@ -656,7 +656,7 @@ claims a general improvement, rewrite it with the concrete change.
## Changelog conciseness
Every bullet must be scannable at a glance. The changelog is not release documentation — it's a list.
Every section bullet must be scannable at a glance. The changelog is not release documentation — it's a list. [Highlights](#changelog-highlights) get more room; every other bullet follows these limits.
- **One sentence per bullet, max.** If a bullet contains two sentences, the second one is doing work that belongs in product docs, not the changelog. Cut it.
- **No trailing periods.** Bullets are list items, not prose. Drop the period at the end of every bullet, including the period inside any bolded lead-in. `**Configurable terminal scrollback**` not `**Configurable terminal scrollback.**`.
@@ -666,6 +666,57 @@ Every bullet must be scannable at a glance. The changelog is not release documen
- **Stop after identifying the change.** Do not explain LAN/WAN topology, TLS handshakes, IPC, or other architecture in a changelog bullet. Put necessary background in product docs.
- **Attribution follows the split.** When you split a dense bullet, move each PR/author to the bullet it belongs to. Never duplicate the same PR across multiple bullets.
## Changelog highlights
Every release entry leads with highlights: the changes a user would name first when asked what is
new. They are a list above the first `###` section, biggest first. The sections below are the
full record; a reader who stops after the highlights should have missed nothing they would ask
about.
```markdown
## 0.16.0 - 2026-11-20
- **Undo workspace archive:** Archiving a workspace in the app now shows a "Workspace archived" toast for six seconds. Undo, or Cmd/Ctrl+Z, restores the workspace with its tabs and agents. ([#8904](https://github.com/getpaseo/paseo/pull/8904))
### Added
```
- **The lead-in names what is new in four words at most.** Bold it, colon inside the bold. A reader
who sees only the bold words should know what this release added or changed. A category lead-in
such as `**Polish:**` gathers several related changes.
- **The lead-in is not a place or something Paseo already had.** A device, screen or area names
where the change landed. A feature name that was true of the last release hides the news: Paseo
already had keyboard shortcuts, so the lead-in says they now work on mobile.
- **The first sentence says the plain thing.** Write what a user would tell a friend: Paseo now
shows the background tasks an agent is running. Leave out the conditions and mechanics that make
it true.
- **Describe the new behavior, not the old one.** Say what is faster or better now, and do not
dwell on how slow or broken it was before. A number is welcome as a short aside after the new
behavior is stated: seconds, a percentage, a multiple.
- **Do not list what a sentence already covers.** After "the same shortcuts as desktop", naming
five of them adds nothing. Keep a list only when it sets the scope, such as the providers a
feature works with.
- **The description runs one to three sentences.** Sentences end with periods, and the attribution
follows the last one. [Changelog wording](#changelog-wording) applies in full.
- **A highlight replaces the section bullets it covers.** A PR appears once in an entry, so its
attribution moves to the highlight.
- **A highlight is something a user would name.** Everything else stays a section bullet. A patch
release still has one: the change it ships for.
- **Fixes are routine and stay in `### Fixed`.** Do not add a summary of fixes. A fix, or a group
of related fixes, becomes a highlight only when it passes the same test as a feature, and then
it is written as what works now: `**Fewer reconnects on return:**`.
| Avoid | Write |
| ------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- |
| `**iPad and touch screens:**` | `**Touch-sized tablet controls:**` |
| `**Hardware keyboard shortcuts:**` | `**Keyboard shortcuts on mobile:**` |
| When an agent ends its turn while shells it started are still running, a pill lists them | Paseo now shows the background tasks an agent is running |
| A diff no longer waits 4 to 40 seconds behind worktree scans | Opening Changes shows the file tree right away, and diffs load about 6 times sooner on hosts with many worktrees |
| The same shortcuts as desktop: command center, file picker, new workspace, workspace jumps | The same shortcuts as desktop, including your custom bindings |
Before showing a draft, read only the bold lead-ins top to bottom. Each one must answer "what is
new?" without its description.
## Changelog attribution
Every changelog bullet must credit contributors and link to the PR(s) that delivered the change. This is not one-PR-per-line — a single bullet describes a user-facing change and may reference multiple PRs.
@@ -697,7 +748,7 @@ Rules:
## Changelog ordering
Entries within each section (Added, Improved, Fixed) are ordered by user impact:
Highlights come first, ordered by user impact. Entries within each section (Added, Improved, Fixed) follow the same order:
1. **User-facing features and changes first** — things users will notice, want to try, or that change their workflow.
2. **Quality-of-life improvements** — polish, performance, smoother interactions.
+69 -16
View File
@@ -5,15 +5,9 @@ Agent chat delivery has two paths:
1. **Live stream** — `agent_stream` WebSocket messages for immediacy. These may be delta-shaped lifecycle updates.
2. **Authoritative history** — `fetch_agent_timeline_request` for correctness. This always returns full projected timeline items, never lifecycle deltas.
The daemon retains projected items in memory. Each source event advances the stream sequence,
then replaces the previous tool state or merges into the current text item. Intermediate payloads
are never retained for history or catch-up. Provider history is the durable transcript authority
and rebuilds the projection when an agent resumes.
Pagination bounds each read, not total resident history. Active and idle agents retain their
projected transcript; archive releases it and later history reads rebuild it from the provider.
Streaming overhead must remain proportional to the retained transcript. Cumulative text snapshots
must be released after their deltas have been consumed.
Pagination bounds each read, not total resident history. Streaming overhead must remain
proportional to the retained transcript. Cumulative text snapshots must be released after their
deltas have been consumed.
The invariants are:
@@ -32,6 +26,44 @@ bounded item is used for runtime timeline rows and live stream events. Provider
applies the same rule so reopening an agent cannot restore an oversized payload. Fetches apply it
again to assistant and reasoning rows the projection merges from many chunks.
## Owned history
The daemon owns each agent's history in `AgentTimeline`
(`packages/server/src/server/agent/timeline/`): one ordered history per agent, with each provider
child's descriptor and history in a child scope of the same agent. Each source event advances the
stream sequence, then replaces the previous tool state or merges into the current text item.
Intermediate payloads are never retained for history or catch-up.
History is append-only: rewind, import, reload and archive never delete or rewrite a row. Only
deleting the agent, or a provider removing one of its own children, deletes history.
- **Rewind** retires a range of rows. They leave the conversation and stay stored, in the same
epoch. See [Rewind retires rows](#rewind-retires-rows).
- **Provider history** is imported only into an agent that has no history. After that, resume
does not read it. Reload agent and Unarchive append only the rows the provider has after the
owned conversation, as ordinary live rows. Any other difference, including an answer Paseo saw only part of or a prompt sent with
attachments, changes nothing and is reported as diverged. A provider child Paseo already holds,
listed or retired, is never imported again.
- **Archive and reload** keep the history and the provider children. An archived chat reads them
without its provider. Deleting the agent deletes its history and every child's, retired ones
included.
`epoch` is the stored history's identity, minted once when the history is created.
`AgentTimeline` serializes every operation of one agent, child scopes included, and keeps its
state in an `AgentTimelineBackend`: the sequence, the rows and retirements, prompt identities, the
open rewind, and the children. The backend contract suite is `agent-timeline.test.ts`, run over
`TIMELINE_CONTRACT_BACKENDS` in `timeline/testing.ts`. The only backend keeps everything in
memory, so a daemon restart loses the owned history: the next resume imports provider history into
a new epoch, and clients take the destructive reset below. Rewinds retired before the restart come
back if the provider history still holds them. Retained history costs memory for every loaded,
closed or archived agent until the daemon exits or the agent is deleted.
An open rewind is stored with the history. Resuming an agent whose history holds one settles it
before the agent takes prompts: a recorded provider cut is committed, and an unknown outcome is
judged from the resumed provider history. When that history proves nothing, the rewind stays open
and the agent refuses prompts with the unknown-outcome error.
## Presence is not delivery
Client heartbeat reports presence:
@@ -117,11 +149,13 @@ agent, so an agent the daemon has not loaded answers `behind` and resumes in tha
Cached history remains readable during recovery. The chat shows Reconnecting to host while the host
is offline and until a reconnected chat's check answers, so an unchanged chat never shows a second
toast. A check on cached content shows nothing. Updating messages appears for a chat that is behind,
until the fetch completes, and for a check still unanswered `FRESHNESS_CHECK_QUIET_MS` after it was
sent. That delay measures the daemon, so it starts at the request: on a cold start the cache read
alone can take longer. Socket connectivity alone cannot certify that the displayed conversation is
current. The timeline owner publishes freshness; the view renders it without a toast timer or a
toast. Returning from background on a socket that stayed connected shows nothing until the check
answers: the chat stayed subscribed, so a slow answer is no reason to doubt it, and a socket that
died without closing fails the foreground liveness probe and becomes a reconnect. Updating messages
appears for a chat that is behind, until the fetch completes, and for an opened chat whose check is
still unanswered `FRESHNESS_CHECK_QUIET_MS` after it was sent. That delay measures the daemon, so it
starts at the request: on a cold start the cache read alone can take longer. Socket connectivity
alone cannot certify that the displayed conversation is current. The timeline owner publishes freshness; the view renders it without a toast timer or a
separate resume workflow.
The turn footer in a chat follows its messages, not the agent status. Turn activity comes from the
@@ -157,7 +191,7 @@ or retry backoff. This cannot keep a mobile socket alive after the operating sys
bookkeeping without replacing timeline arrays, preserving an upward-scrolled viewport.
- When the page overlaps or is adjacent to the local end cursor, only projected items newer than
that cursor are applied. Already-covered rows are not replayed.
- A true middle gap, epoch change, or rewind atomically replaces stale canonical history with the
- A true middle gap or epoch change atomically replaces stale canonical history with the
latest tail. The replacement reconciles positioned live rows beyond its coverage and unresolved
local submissions; it never retains two discontiguous canonical ranges.
@@ -170,6 +204,24 @@ history replay. The pending approval UI can hide that tool from presentation, bu
must retain its position. Creating a new history card on rejection places it after the prompt that
rejected it; changing steer-event ordering would also put new assistant output before that prompt.
## Rewind retires rows
A conversation rewind commits a retirement `{ seq, retireFromSeq }`: active rows anchored in
`[retireFromSeq, seq)` leave the conversation, and `seq` is the retirement's own position in the
sequence. The provider children spawned by retired tool calls, and their descendants, are retired
in the same commit. They leave the listing, keep their history, and drop late provider events.
Clients learn of a retirement live through `agent.timeline.rewind`, and on pages through
`retirements`, which lists the retirements whose `seq` lies inside the page's span. The client
drops the rows it holds in that range; rows without a timeline position are unechoed local sends
and stay. Retired ranges and the retirement's `seq` count as covered, so forward cursors advance
through them. Tail, before and prompt-jump pages are served from active rows, so one served before
a retirement the client already applied is stale and is refetched (`viewed-timeline-sync.ts`).
Clients without `timeline_rewind_operations` cannot drop retired rows. For them the session derives
a wire epoch that changes with each retirement, so they take the destructive replacement they
always took on rewind. The shim is tagged `COMPAT(timelineRewindOperations)`.
## Provider child history
Child transcripts use the same projected-page reconciliation as the main conversation. The client
@@ -334,7 +386,7 @@ the draft-create handoff is the one boundary that also permits the legacy canoni
arrived first. Generic reducers and consumers do not reimplement message identity matching.
Ordinary bootstrap, same-epoch reset, and catch-up replacement preserve unmatched locally submitted
rows because a provider may never echo them. A known epoch change or rewind replaces history and
rows because a provider may never echo them. A known epoch change replaces history and
drops acknowledged local rows omitted by the new canonical epoch; every transaction not yet
acknowledged by the provider, and no other local row, crosses that destructive boundary. A cold
reset without an existing epoch is destructive because the client has no continuity anchor.
@@ -350,6 +402,7 @@ canonical assistant prefix, it stays in the head lane. No row may be returned in
## Relevant code
- Server owned history: `packages/server/src/server/agent/timeline/`
- Server live stream forwarding: `packages/server/src/server/session.ts`
- App sync planning: `packages/app/src/timeline/timeline-sync-plan.ts`
- App viewed-agent synchronization: `packages/app/src/timeline/viewed-timeline-sync.ts`
@@ -0,0 +1,7 @@
Added
- Added Claude Fable 5.2 and Mythos 5.2 with medium default effort
Notes
- Both models require Claude Code 2.1.321 or newer; Mythos 5.2 appears only when Claude Code advertises access
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,7 @@
Added
- Added Claude Fable 5.2 and Mythos 5.2 with medium default effort
Notes
- Both models require Claude Code 2.1.321 or newer; Mythos 5.2 appears only when Claude Code advertises access
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,7 @@
Added
- Added Claude Fable 5.2 and Mythos 5.2 with medium default effort
Notes
- Both models require Claude Code 2.1.321 or newer; Mythos 5.2 appears only when Claude Code advertises access
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,7 @@
Added
- Added Claude Fable 5.2 and Mythos 5.2 with medium default effort
Notes
- Both models require Claude Code 2.1.321 or newer; Mythos 5.2 appears only when Claude Code advertises access
Full notes: https://paseo.sh/changelog
+1 -1
View File
@@ -1 +1 @@
sha256-vwivjBU5VFpM9VD7Wx4IKHN6xEVlg/KBMJCCMuA1JEw=
sha256-21qteVXVoL7S/wF0aGxHNEkTX8in6UF6+wWEZi8N3HA=
+28 -28
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.15.3",
"version": "0.15.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.15.3",
"version": "0.15.4",
"hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
@@ -37468,7 +37468,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.15.3",
"version": "0.15.4",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -39637,13 +39637,13 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.15.3",
"version": "0.15.4",
"license": "Apache-2.0",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.15.3",
"@getpaseo/protocol": "0.15.3",
"@getpaseo/server": "0.15.3",
"@getpaseo/client": "0.15.4",
"@getpaseo/protocol": "0.15.4",
"@getpaseo/server": "0.15.4",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -39890,11 +39890,11 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.15.3",
"version": "0.15.4",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/protocol": "0.15.3",
"@getpaseo/relay": "0.15.3",
"@getpaseo/protocol": "0.15.4",
"@getpaseo/relay": "0.15.4",
"ws": "^8.20.0",
"zod": "^4.4.3"
},
@@ -39906,7 +39906,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.15.3",
"version": "0.15.4",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/cli": "*",
@@ -40149,7 +40149,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.15.3",
"version": "0.15.4",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -40195,7 +40195,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.15.3",
"version": "0.15.4",
"license": "Apache-2.0",
"dependencies": {
"@codemirror/language": "6.12.4",
@@ -40225,7 +40225,7 @@
},
"packages/plugin": {
"name": "@getpaseo/plugin",
"version": "0.15.3",
"version": "0.15.4",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "^1.4.0",
@@ -40233,8 +40233,8 @@
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.15.3",
"@getpaseo/protocol": "0.15.3",
"@getpaseo/client": "0.15.4",
"@getpaseo/protocol": "0.15.4",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.2.3",
@@ -40244,8 +40244,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.15.3",
"@getpaseo/protocol": "0.15.3",
"@getpaseo/client": "0.15.4",
"@getpaseo/protocol": "0.15.4",
"react": ">=19.1.0 <20",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
@@ -40267,7 +40267,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.15.3",
"version": "0.15.4",
"license": "Apache-2.0",
"dependencies": {
"semver": "^7.8.5",
@@ -40295,7 +40295,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.15.3",
"version": "0.15.4",
"license": "Apache-2.0",
"dependencies": {
"@noble/ciphers": "^2.4.0",
@@ -40517,17 +40517,17 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.15.3",
"version": "0.15.4",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.15.3",
"@getpaseo/highlight": "0.15.3",
"@getpaseo/plugin": "0.15.3",
"@getpaseo/protocol": "0.15.3",
"@getpaseo/relay": "0.15.3",
"@getpaseo/client": "0.15.4",
"@getpaseo/highlight": "0.15.4",
"@getpaseo/plugin": "0.15.4",
"@getpaseo/protocol": "0.15.4",
"@getpaseo/relay": "0.15.4",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -41474,7 +41474,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.15.3",
"version": "0.15.4",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
@@ -43084,7 +43084,7 @@
},
"plugins": {
"name": "@getpaseo/builtin-plugins",
"version": "0.15.3",
"version": "0.15.4",
"dependencies": {
"@getpaseo/plugin": "*",
"zod": "^4.4.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.15.3",
"version": "0.15.4",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -11,6 +11,13 @@ import {
import { expectNearBottom, scrollChatAwayFromBottom } from "../support/helpers/agent-bottom-anchor";
import { holdRewindCompletion } from "../support/helpers/agent-timeline-gate";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { userScrollsTimelineToHistoryStart } from "../support/helpers/timeline-pagination";
import {
closeWorkspaceAgentTab,
fetchAgentArchivedAt,
primeAdditionalPage,
resetSeededPageState,
} from "../support/helpers/archive-tab";
async function completeSubmittedTurn(
page: Page,
@@ -46,26 +53,102 @@ async function expectTurnCompletesNormally(
await expectAgentIdle(page);
}
async function openAgentOnAnotherClient(
page: Page,
agent: Awaited<ReturnType<typeof seedMockAgentWorkspace>>,
): Promise<void> {
await primeAdditionalPage(page);
await resetSeededPageState(page);
await openAgentRoute(page, agent);
await expectComposerVisible(page);
}
/** The prompts the chat shows, in order, each exactly once. */
async function expectPrompts(page: Page, prompts: string[]): Promise<void> {
const messages = page.getByTestId("user-message");
await expect(messages).toHaveCount(prompts.length);
await expect(messages).toContainText(prompts);
}
async function reloadAgentFromTabMenu(page: Page, agentId: string): Promise<void> {
const tabId = `workspace-tab-agent_${agentId}`;
await page.getByTestId(tabId).filter({ visible: true }).first().click({ button: "right" });
await page.getByTestId(`workspace-tab-context-agent_${agentId}-reload-agent`).click();
}
async function archiveAgentByClosingTab(
page: Page,
agent: Awaited<ReturnType<typeof seedMockAgentWorkspace>>,
): Promise<void> {
await closeWorkspaceAgentTab(page, agent.agentId);
await expect.poll(() => fetchAgentArchivedAt(agent.client, agent.agentId)).not.toBeNull();
}
async function unarchiveAgent(page: Page): Promise<void> {
await page.getByRole("button", { name: "Unarchive", exact: true }).click();
await expect(page.getByText("This agent is archived", { exact: true })).toHaveCount(0, {
timeout: 30_000,
});
}
async function expectToast(page: Page, message: string): Promise<void> {
await expect(page.getByTestId("app-toast-message")).toHaveText(message, { timeout: 30_000 });
}
// The mock provider's resumed session holds no history, so it never matches what Paseo shows.
const UNSYNCED_RELOAD =
"Reloaded agent, but the provider's history doesn't match this chat, so it wasn't synced";
/** The prompts of the whole conversation, after scrolling up through its older pages. */
async function expectConversationPrompts(page: Page, prompts: string[]): Promise<void> {
const messages = page.getByTestId("user-message");
await expect
.poll(async () => {
await userScrollsTimelineToHistoryStart(page);
return await messages.count();
})
.toBe(prompts.length);
await expect(messages).toContainText(prompts);
}
// A row React remounts loses this attribute; one that stays mounted keeps it.
async function markMounted(row: Locator): Promise<void> {
await row.evaluate((element) => element.setAttribute("data-e2e-mounted", "before-rewind"));
}
async function expectStillMounted(row: Locator): Promise<void> {
await expect(row).toHaveAttribute("data-e2e-mounted", "before-rewind");
}
test.describe("Agent message rewind", () => {
test("rewinds a submitted prompt without replaying history and preserves a human draft", async ({
test("rewinds a prompt in place on every open client and keeps a human draft", async ({
page,
}) => {
test.setTimeout(90_000);
test.setTimeout(150_000);
const retainedPrompt = "Keep this completed turn after rewind.";
const rewoundPrompt = "Remove this completed turn after rewind.";
const nextPrompt = "Continue after the rewind.";
const preservedDraft = "Keep this human draft after rewind.";
const agent = await seedMockAgentWorkspace({
repoPrefix: "message-rewind-roundtrip-",
title: "Message rewind roundtrip",
model: "ten-second-stream",
});
const otherClient = await page.context().newPage();
try {
const rewindCompletion = await holdRewindCompletion(page, agent.agentId);
await openAgentRoute(page, agent);
await expectComposerVisible(page);
await completeSubmittedTurn(page, agent, retainedPrompt);
const retainedMessage = await completeSubmittedTurn(page, agent, retainedPrompt);
const userMessage = await completeSubmittedTurn(page, agent, rewoundPrompt);
await test.step("a second client opens the same chat", async () => {
await openAgentOnAnotherClient(otherClient, agent);
await expectPrompts(otherClient, [retainedPrompt, rewoundPrompt]);
});
await markMounted(retainedMessage);
await scrollChatAwayFromBottom(page, {
deltaY: -900,
minDistanceFromBottom: 300,
@@ -73,18 +156,91 @@ test.describe("Agent message rewind", () => {
await fillComposerDraft(page, preservedDraft);
rewindCompletion.clearTimelineStreamCount();
const rewind = rewindConversation(page, userMessage, rewoundPrompt);
await rewindCompletion.waitForDelayedResponse();
await expect(page.getByTestId("rewind-menu-conversation")).toBeDisabled();
expect(rewindCompletion.timelineStreamCount()).toBe(0);
rewindCompletion.release();
await rewind;
await test.step("the rewind stays pending until the daemon answers", async () => {
const rewind = rewindConversation(page, userMessage, rewoundPrompt);
await rewindCompletion.waitForDelayedResponse();
await expect(page.getByTestId("rewind-menu-conversation")).toBeDisabled();
expect(rewindCompletion.timelineStreamCount()).toBe(0);
rewindCompletion.release();
await rewind;
});
await expect(page.getByTestId("user-message").filter({ hasText: rewoundPrompt })).toHaveCount(
0,
);
await expectComposerDraft(page, preservedDraft);
await expectNearBottom(page);
await test.step("the rewound turn leaves without reloading the chat", async () => {
await expectPrompts(page, [retainedPrompt]);
await expectStillMounted(retainedMessage);
expect(rewindCompletion.timelineFetchCount()).toBe(0);
await expectComposerDraft(page, preservedDraft);
await expectNearBottom(page);
await expectPrompts(otherClient, [retainedPrompt]);
});
await test.step("the next prompt appears once on both clients", async () => {
await completeSubmittedTurn(otherClient, agent, nextPrompt);
await expectPrompts(otherClient, [retainedPrompt, nextPrompt]);
await expectPrompts(page, [retainedPrompt, nextPrompt]);
});
await test.step("a reconnecting client loads the rewound conversation", async () => {
await otherClient.reload();
await expectPrompts(otherClient, [retainedPrompt, nextPrompt]);
});
} finally {
await otherClient.close();
await agent.cleanup();
}
});
test("keeps the rewound conversation through Reload agent, archive and unarchive", async ({
page,
}) => {
test.setTimeout(150_000);
const keptPrompt = "Keep this turn through reload and archive.";
const rewoundPrompt = "Rewind this turn before reloading.";
const nextPrompt = "Continue after the rewind, then reload.";
const agent = await seedMockAgentWorkspace({
repoPrefix: "message-rewind-reload-archive-",
title: "Message rewind reload archive",
model: "ten-second-stream",
});
try {
await openAgentRoute(page, agent);
await expectComposerVisible(page);
await completeSubmittedTurn(page, agent, keptPrompt);
const rewound = await completeSubmittedTurn(page, agent, rewoundPrompt);
await rewindConversation(page, rewound, rewoundPrompt);
await expectPrompts(page, [keptPrompt]);
await completeSubmittedTurn(page, agent, nextPrompt);
await expectPrompts(page, [keptPrompt, nextPrompt]);
await test.step("Reload agent keeps the chat and says the provider's history was not synced", async () => {
await reloadAgentFromTabMenu(page, agent.agentId);
await expectToast(page, UNSYNCED_RELOAD);
await expectPrompts(page, [keptPrompt, nextPrompt]);
await expectComposerVisible(page);
});
await test.step("the archived chat shows the rewound conversation", async () => {
await archiveAgentByClosingTab(page, agent);
await openAgentRoute(page, agent);
await expect(page.getByText("This agent is archived", { exact: true })).toBeVisible({
timeout: 30_000,
});
await expectConversationPrompts(page, [keptPrompt, nextPrompt]);
});
await test.step("unarchiving keeps the rewound conversation", async () => {
await unarchiveAgent(page);
await expectToast(page, UNSYNCED_RELOAD);
await expectComposerVisible(page);
await expectConversationPrompts(page, [keptPrompt, nextPrompt]);
});
await test.step("a reconnecting client loads the same conversation", async () => {
await page.reload();
await expectComposerVisible(page);
await expectConversationPrompts(page, [keptPrompt, nextPrompt]);
});
} finally {
await agent.cleanup();
}
@@ -11,6 +11,7 @@ import {
seedLongMockAgentTimeline,
} from "../support/helpers/timeline-pagination";
import {
answerFreshnessCheck,
commitTimelineTurnsWhileDisconnected,
disconnectViewedTimeline,
expectResumeAnsweredWithoutFetch,
@@ -21,6 +22,7 @@ import {
restoreUnchangedViewedTimeline,
restoreViewedTimelineHoldingCatchUp,
restoreViewedTimelineWithHeldResponse,
reopenWithFreshnessCheckUnanswered,
waitForResumeAnswer,
reopenAppWatchingForUpdatingToast,
watchForUpdatingToast,
@@ -101,6 +103,27 @@ test.describe("Agent timeline resume", () => {
}
});
test("opening cached history reports an unanswered check until the daemon answers", async ({
page,
}) => {
test.setTimeout(120_000);
const gate = await installDaemonWebSocketGate(page);
const agent = await seedLongMockAgentTimeline({ turns: 3 });
try {
await openAgentTimeline(page, agent);
await expectTimelinePromptVisible(page, agent.newestPrompt);
const requests = rememberTimelineRequestCounts(gate);
await reopenWithFreshnessCheckUnanswered(page, gate, agent);
await answerFreshnessCheck(page, gate);
expectResumeAnsweredWithoutFetch(gate, requests);
} finally {
gate.restore();
await agent.cleanup();
}
});
test("returning to a changed chat reports the update and shows the new messages", async ({
page,
}) => {
@@ -306,6 +306,24 @@ test("streamed Changes shows the tree before completion and applies a one-file r
await page.screenshot({ path: testInfo.outputPath("refreshed.png") });
});
test("reopening compact Changes keeps the painted diff while it resubscribes", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff();
const stream = await holdDiffCompletion(page);
const explorer = await openCompactChanges(page, workspace);
stream.finish();
const firstFile = explorer.getByTestId("diff-file-0");
await expect(firstFile).toBeVisible({ timeout: 30_000 });
const painted = await firstFile.getAttribute("aria-label");
await explorer.getByTestId("workspace-tab-files").click();
stream.hold();
await explorer.getByTestId("workspace-tab-changes_tree").click();
await expect.poll(() => stream.held, { timeout: 30_000 }).toBeGreaterThan(0);
await expect(page.getByRole("progressbar", { name: "Loading..." })).toHaveCount(0);
await expect(firstFile).toHaveAccessibleName(painted ?? "");
stream.finish();
});
async function createLargeStreamedWorkspace(): Promise<DirtyWorkspace> {
const workspace = await createWorkspaceWithManyTinyDiffs(252);
await Promise.all(
@@ -371,6 +389,12 @@ async function holdDiffCompletion(page: Page) {
get completed() {
return completed;
},
get held() {
return completions.length;
},
hold: () => {
holding = true;
},
finish: () => {
holding = false;
completions.splice(0).forEach((send) => send());
@@ -19,6 +19,7 @@ import {
openSheetAsTab,
expectDeclinedDiscardPrompt,
expectSheetAtSnap,
promptMentioning,
replaceSheetFileText,
selectTabFromSwitcher,
sheet,
@@ -28,17 +29,6 @@ import {
const FILE_LINK = "linked.ts:3";
function promptMentioning(target: string): string {
return [
"Generate a title and a git branch name for a coding agent from the user prompt and attachments.",
"Return JSON only with fields 'title' and 'branch'.",
"",
"<user-prompt>",
`Open \`${target}\` now`,
"</user-prompt>",
].join("\n");
}
test("compact opens files and subagents in a sheet, leaving the tabs for agents", async ({
page,
}) => {
@@ -21,6 +21,7 @@ import {
openAgent,
pressContextWindowMeter,
qaScreenshot,
recordContextWindowFrames,
refreshUsageCard,
reloadAgent,
scriptAgentUsage,
@@ -299,9 +300,10 @@ for (const theme of ["light", "dark"] as const) {
const slowSource = gate();
usage.answerNext({ stream: [slowSource.promise, onWorkLogin(claude!)] });
const closedEarly = await hoverContextWindowMeter(page);
await expect(closedEarly.getByText("Loading usage...", { exact: true })).toBeVisible();
await expect(usageCard(closedEarly, "claude:work").getByText(LOGIN_EXPIRED)).toBeVisible();
await leaveContextWindowMeter(page);
await usage.resumeAgent(agent);
const resumed = gate();
const finished = gate();
usage.answerNext({
@@ -385,6 +387,58 @@ for (const theme of ["light", "dark"] as const) {
});
}
test("an open with loaded usage paints it in its first frame and never moves", async ({
page,
agent,
}) => {
const [claude] = claudeAndCodexReports();
const usage = await scriptAgentUsage(page);
await page.setViewportSize(DESKTOP);
await openAgent(page, agent);
usage.answerNext([onWorkLogin(claude!)]);
const loaded = await hoverContextWindowMeter(page);
await expect(usageCard(loaded, "claude:work")).toBeVisible();
await leaveContextWindowMeter(page);
// The reopen's own request stays unanswered until the card has been on screen a while.
const refetch = gate();
usage.answerNext({ stream: [refetch.promise, onWorkLogin(claude!)] });
const recording = await recordContextWindowFrames(page);
const reopened = await hoverContextWindowMeter(page);
await page.waitForTimeout(300);
refetch.open();
await page.waitForTimeout(300);
await expect(usageCard(reopened, "claude:work")).toBeVisible();
const frames = (await recording.stop()).filter((frame) => frame.mounted);
await leaveContextWindowMeter(page);
expect(frames.filter((frame) => frame.loading)).toEqual([]);
expect(frames.filter((frame) => frame.usageCards !== 1)).toEqual([]);
expect(new Set(frames.map((frame) => frame.bottom)).size).toBe(1);
});
test("usage arriving after the card opens never moves its bottom edge", async ({ page, agent }) => {
const [claude] = claudeAndCodexReports();
const usage = await scriptAgentUsage(page);
await page.setViewportSize(DESKTOP);
await openAgent(page, agent);
const arrival = gate();
usage.answerNext({ stream: [arrival.promise, onWorkLogin(claude!)] });
const recording = await recordContextWindowFrames(page);
const card = await hoverContextWindowMeter(page);
await expect(card.getByText("Loading usage...", { exact: true })).toBeVisible();
arrival.open();
await expect(usageCard(card, "claude:work")).toBeVisible();
await page.waitForTimeout(300);
const frames = (await recording.stop()).filter((frame) => frame.mounted);
await leaveContextWindowMeter(page);
expect(frames.some((frame) => frame.loading)).toBe(true);
expect(frames.at(-1)?.usageCards).toBe(1);
expect(new Set(frames.map((frame) => frame.bottom)).size).toBe(1);
});
test("context details support keyboard entry and dismiss when the viewport moves", async ({
page,
agent,
@@ -13,6 +13,7 @@ import {
ensureExplorerSidebar,
openFilesPanel,
waitForWorkspaceTabsVisible,
dragTabIntoExplorer,
} from "../support/helpers/workspace-tabs";
function explorerSidebar(page: Parameters<typeof ensureExplorerSidebar>[0]) {
@@ -194,26 +195,6 @@ test("Explorer keeps Files and Changes close actions in the context menu", async
}
});
async function dragTabIntoExplorer(
page: Parameters<typeof ensureExplorerSidebar>[0],
tabId: string,
) {
const source = page.getByTestId("workspace-pane-main").getByTestId(`workspace-tab-${tabId}`);
const destination = explorerSidebar(page).getByTestId("workspace-tab-files");
const sourceBox = await source.boundingBox();
const destinationBox = await destination.boundingBox();
if (!sourceBox || !destinationBox)
throw new Error("Tab drag requires visible source and destination");
const start = { x: sourceBox.x + 12, y: sourceBox.y + sourceBox.height / 2 };
await page.mouse.move(start.x, start.y);
await page.mouse.down();
await page.mouse.move(start.x + 12, start.y + 4);
await page.mouse.move(destinationBox.x + 12, destinationBox.y + destinationBox.height / 2, {
steps: 20,
});
await page.mouse.up();
}
test("moves agents into Explorer and keeps the shared Agent launcher available", async ({
page,
}, testInfo) => {
@@ -1,5 +1,11 @@
import { test } from "../support/fixtures";
import { withComposerControlsExample } from "../support/helpers/plugin-composer-items";
import {
expectToolbarNeverCondensedOrClipped,
recordToolbarFramesFromLoad,
withComposerControlsExample,
withPickerComposerItem,
withToggleAndWatcherComposerItems,
} from "../support/helpers/plugin-composer-items";
test("a narrow composer's overflow sheet shows a plugin item's row", async ({ page }) => {
test.setTimeout(120_000);
@@ -10,3 +16,66 @@ test("a narrow composer's overflow sheet shows a plugin item's row", async ({ pa
await composer.expectFeatureRow("Insert checklist");
});
});
test("a toolbar with room keeps a plugin toggle inline at full density", async ({ page }) => {
test.setTimeout(120_000);
await withToggleAndWatcherComposerItems(page, async (composer) => {
// Wider than what renders, too narrow for two 125-wide "Plugin item" estimates.
await composer.setWindowWidth(1000);
await composer.expectToolbarWidthBetween(470, 620);
await composer.expectToggleInlineAtFullDensity();
});
});
test("a toolbar without room moves a plugin toggle to the overflow sheet and back", async ({
page,
}) => {
test.setTimeout(120_000);
await withToggleAndWatcherComposerItems(page, async (composer) => {
await composer.setWindowWidth(1000);
await composer.expectToggleInlineAtFullDensity();
// Too narrow for full labels, wide enough that the controls have not folded away.
await composer.setWindowWidth(880);
await composer.expectToolbarWidthBetween(300, 395);
await composer.expectToggleInOverflow();
await composer.closeOverflowSheet();
await composer.setWindowWidth(1000);
await composer.expectToggleInlineAtFullDensity();
});
});
test("an open plugin picker closes when its trigger moves to the overflow", async ({ page }) => {
test.setTimeout(120_000);
await withPickerComposerItem(page, async (composer) => {
await composer.setWindowWidth(1000);
await composer.openInlinePicker();
await composer.setWindowWidth(880);
await composer.expectToolbarWidthBetween(300, 395);
await composer.expectPickerClosed();
});
});
test("the toolbar settles on one density without flipping as the window widens", async ({
page,
}) => {
test.setTimeout(120_000);
await withToggleAndWatcherComposerItems(page, async (composer) => {
await composer.setWindowWidth(880);
await composer.expectToolbarWidthBetween(300, 395);
await composer.startRecordingDensity();
for (let width = 880; width <= 1000; width += 8) await composer.setWindowWidth(width);
await composer.expectAtMostOneDensityChange();
});
});
test("a toolbar with room never paints condensed or clipped while the plugin loads", async ({
page,
}) => {
test.setTimeout(120_000);
await page.setViewportSize({ width: 1000, height: 800 });
await recordToolbarFramesFromLoad(page);
await withToggleAndWatcherComposerItems(page, async (composer) => {
await composer.expectToggleInlineAtFullDensity();
await expectToolbarNeverCondensedOrClipped(page);
});
});
@@ -2,6 +2,10 @@ import { test } from "../support/fixtures";
import {
CHECKLIST,
withComposerToolsExample,
withPluginFocusingAComposer,
withPluginFocusingASubagent,
withPluginFocusingAnAgentWithContextUsage,
withPluginHoldingNewWorkspaceComposer,
withPluginWritingToAnotherWorkspace,
} from "../support/helpers/plugin-composers-by-reference";
@@ -39,6 +43,17 @@ test("the panel follows which agent is focused", async ({ page }) => {
});
});
test("an Action in the compact controls sheet closes the sheet and focuses the composer", async ({
page,
}) => {
test.setTimeout(120_000);
await withComposerToolsExample(page, async (author) => {
await author.useCompactLayout();
await author.insertChecklistFromControlsSheet();
await author.expectComposerReadyToType(CHECKLIST);
});
});
test("a plugin writes to an agent's composer while its workspace is not on screen", async ({
page,
}) => {
@@ -51,3 +66,158 @@ test("a plugin writes to an agent's composer while its workspace is not on scree
await author.expectComposerText("A note from a plugin");
});
});
test("a pill that inserts text and focuses leaves the cursor in the composer, ready to type", async ({
page,
}) => {
test.setTimeout(120_000);
await withPluginFocusingAComposer(page, async (author) => {
await author.openTargetAgent();
await author.moveFocusOutOfComposer();
await author.pressPill();
await author.expectComposerFocused();
await author.typeAtCursor(" world");
await author.expectComposerText("HELLO world");
});
});
test("focus reaches an agent in another tab of the workspace", async ({ page }) => {
test.setTimeout(120_000);
await withPluginFocusingAComposer(page, async (author) => {
await author.openTargetAgent();
await author.openOtherAgent();
await author.focusFromCommandCenter();
await author.expectOnTargetAgent();
await author.expectComposerFocused();
});
});
test("focus reaches an agent in another workspace, with the selection asked for", async ({
page,
}) => {
test.setTimeout(120_000);
await withPluginFocusingAComposer(page, async (author) => {
await author.openTargetAgent();
await author.fillComposer("typed before");
await author.goToAnotherWorkspace();
await author.selectPromptFromCommandCenter();
await author.expectOnTargetAgent();
await author.expectComposerFocused();
await author.typeAtCursor("replaced");
await author.expectComposerText("replaced");
});
});
test("focus closes the compact Explorer panel covering the composer", async ({ page }) => {
test.setTimeout(120_000);
await withPluginFocusingAComposer(page, async (author) => {
await author.useCompactLayout();
await author.openTargetAgent();
await author.coverComposerWithExplorerPanel();
await author.pressPanelFocusButton();
await author.expectExplorerPanelClosed();
await author.expectComposerFocused();
});
});
test("focus rejects on a New workspace composer the user has left", async ({ page }) => {
test.setTimeout(120_000);
await withPluginHoldingNewWorkspaceComposer(page, async (author) => {
await author.openNewWorkspace();
await author.expectHeldComposerFocusResult("focused");
await author.openSettings();
await author.expectHeldComposerFocusResult("The composer is not on screen.");
});
});
test("focus closes a picker the composer opened over itself", async ({ page }) => {
test.setTimeout(120_000);
await withPluginHoldingNewWorkspaceComposer(page, async (author) => {
await author.openNewWorkspace();
await author.openThinkingPicker();
await author.expectHeldComposerFocusResult("focused");
await author.expectHeldComposerReadyToType();
});
});
test("focus closes an image preview the composer opened over itself", async ({ page }) => {
test.setTimeout(120_000);
await withPluginHoldingNewWorkspaceComposer(page, async (author) => {
await author.openNewWorkspace();
await author.previewAttachedImage();
await author.expectHeldComposerFocusResult("focused");
await author.expectHeldComposerReadyToType();
});
});
test("overlapping focus calls end at the later selection while a preview closes", async ({
page,
}) => {
test.setTimeout(120_000);
await withPluginHoldingNewWorkspaceComposer(page, async (author) => {
await author.openNewWorkspace();
await author.typeInHeldComposer("HELLO");
await author.previewAttachedImage();
await author.expectOverlappingFocusesKeepTheLatestSelection();
});
});
test("focus closes the context usage sheet on a phone", async ({ page }) => {
test.setTimeout(120_000);
await withPluginFocusingAnAgentWithContextUsage(page, async (author) => {
await author.openContextUsageOnPhone();
await author.focusAgent();
await author.expectReadyToType();
});
});
test("focus closes a page the composer's Subagents track opened over it", async ({ page }) => {
test.setTimeout(120_000);
await withPluginFocusingASubagent(page, async (author) => {
await author.openParentAgent();
await author.openSubagentPageInParentComposer();
await author.focusParent();
await author.expectParentReadyToType();
});
});
test("focus reaches a New workspace composer opened from a compact Explorer", async ({ page }) => {
test.setTimeout(120_000);
await withPluginHoldingNewWorkspaceComposer(page, async (author) => {
await author.leaveCompactExplorerForNewWorkspace();
await author.expectHeldComposerFocusResult("focused");
});
});
test("focus opens a compact subagent as a tab with its composer", async ({ page }) => {
test.setTimeout(120_000);
await withPluginFocusingASubagent(page, async (author) => {
await author.openParentAgent();
await author.openExplorer();
await author.focusSubagent();
await author.expectSubagentTabReadyToType();
});
});
test("focus closes a file sheet still opening over an Explorer composer", async ({ page }) => {
test.setTimeout(120_000);
await withPluginFocusingASubagent(page, async (author) => {
await author.keepParentInExplorerOnCompactScreen();
await author.focusParent();
await author.expectParentReadyToType();
await author.focusParentAsAFileSheetOpens();
await author.expectParentReadyToType();
});
});
test("focus reaches an agent kept in the Explorer on a compact screen", async ({ page }) => {
test.setTimeout(120_000);
await withPluginFocusingASubagent(page, async (author) => {
await author.keepParentInExplorerOnCompactScreen();
await author.focusParent();
await author.expectParentReadyToType();
await author.coverExplorerComposerWithFileSheet();
await author.focusParent();
await author.expectParentReadyToType();
});
});
@@ -215,14 +215,14 @@ test.describe("Rewind sheet", () => {
await expect(page.getByTestId("rewind-menu-content")).toHaveCount(0);
await expect(userMessage(page, secondPrompt)).toHaveCount(0);
await expect(page.getByText("Cycle 1", { exact: true })).toHaveCount(0);
await expectUserMessageCount(page, 1);
await expectUserMessageCount(page, 0);
await expectComposerDraft(page, firstPrompt);
await submitMessage(page, replacementPrompt);
await expectUserMessageVisible(page, replacementPrompt);
await expect(userMessage(page, secondPrompt)).toHaveCount(0);
await expect(page.getByText("Cycle 1", { exact: true })).toHaveCount(0);
await expectUserMessageCount(page, 2);
await expectUserMessageCount(page, 1);
await fillComposerDraft(page, "");
await composerLocator(page).evaluate((element) => element.blur());
@@ -232,7 +232,7 @@ test.describe("Rewind sheet", () => {
await selectRewindOption(page, "Rewind files");
await expect(page.getByTestId("rewind-menu-content")).toHaveCount(0);
await expectComposerDraft(page, "");
await expectUserMessageCount(page, 2);
await expectUserMessageCount(page, 1);
const preservedDraft = "Keep this human draft after rewind.";
await fillComposerDraft(page, preservedDraft);
@@ -243,7 +243,7 @@ test.describe("Rewind sheet", () => {
await selectRewindOption(page, "Rewind files");
await expect(page.getByTestId("rewind-menu-content")).toHaveCount(0);
await expectComposerDraft(page, preservedDraft);
await expectUserMessageCount(page, 2);
await expectUserMessageCount(page, 1);
await fillComposerDraft(page, "");
await composerLocator(page).evaluate((element) => element.blur());
@@ -253,7 +253,7 @@ test.describe("Rewind sheet", () => {
await selectRewindOption(page, "Rewind conversation and files");
await expect(page.getByTestId("rewind-menu-content")).toHaveCount(0);
await expectComposerDraft(page, replacementPrompt);
await expectUserMessageCount(page, 1);
await expectUserMessageCount(page, 0);
} finally {
await session.cleanup();
}
+43 -14
View File
@@ -1,7 +1,14 @@
import { expect, test, type Page } from "../support/fixtures";
import { gotoAppShell, openSettings } from "../support/helpers/app";
import { openSettingsSection } from "../support/helpers/settings";
import { openWhatsNew, release, serveChangelog } from "../support/helpers/changelog";
import {
allChangesToggle,
hideAllChanges,
openWhatsNew,
release,
serveChangelog,
showAllChanges,
} from "../support/helpers/changelog";
const DISCORD_DESTINATION =
/^https:\/\/(?:discord\.gg\/jz8T2uahpH|discord\.com\/invite\/jz8T2uahpH)(?:[/?#]|$)/;
@@ -73,26 +80,30 @@ test("opens troubleshooting and support destinations", async ({ page }) => {
});
test("renders the changelog in the app and links the website", async ({ page }) => {
// A callout, a section name the app has never seen, and a fenced sample whose
// contents look like a release heading.
// Highlights, a section name the app has never seen, a callout, and a fenced
// sample whose contents look like a release heading.
await serveChangelog(page, [
"# Changelog",
"",
"## 9.1.0 - 2026-03-04",
"",
"Headline release note.",
"",
"> [!WARNING]",
"> Read this before upgrading.",
"- **Background tasks:** Paseo now shows the tasks an agent is running.",
"",
"### Sparkles",
"",
"- Added a brand new thing",
"",
"> [!WARNING]",
"> Read this before upgrading.",
"",
"```md",
"## 0.0.0 - 1999-01-01",
"```",
"",
"### Fixed",
"",
"- Fixed a crash",
"",
"## 9.0.0 - 2026-02-01",
"",
"### Fixed",
@@ -104,14 +115,32 @@ test("renders the changelog in the app and links the website", async ({ page })
const sheet = await openWhatsNew(page);
const latest = release(sheet, "9.1.0");
const older = release(sheet, "9.0.0");
await expect(latest.getByText("March 4, 2026", { exact: true })).toBeVisible();
await expect(latest.getByText("Headline release note.")).toBeVisible();
await expect(latest.getByText("Read this before upgrading.")).toBeVisible();
await expect(latest.getByText("Sparkles", { exact: true })).toBeVisible();
await expect(latest.getByText("Added a brand new thing")).toBeVisible();
await expect(release(sheet, "9.0.0")).toBeVisible();
await expect(release(sheet, "0.0.0")).toHaveCount(0);
await test.step("leads a release with its highlights and tucks the rest away", async () => {
await expect(latest.getByText("March 4, 2026", { exact: true })).toBeVisible();
await expect(latest.getByText("Paseo now shows the tasks an agent is running.")).toBeVisible();
await expect(latest.getByText("Added a brand new thing")).toHaveCount(0);
await expect(latest.getByText("Fixed a crash")).toHaveCount(0);
});
await test.step("shows every section on request and tucks them away again", async () => {
await showAllChanges(latest);
await expect(latest.getByText("Sparkles", { exact: true })).toBeVisible();
await expect(latest.getByText("Added a brand new thing")).toBeVisible();
await expect(latest.getByText("Read this before upgrading.")).toBeVisible();
await expect(latest.getByText("Fixed a crash")).toBeVisible();
await expect(release(sheet, "0.0.0")).toHaveCount(0);
await hideAllChanges(latest);
await expect(latest.getByText("Added a brand new thing")).toHaveCount(0);
await expect(latest.getByText("Paseo now shows the tasks an agent is running.")).toBeVisible();
});
await test.step("shows a release without highlights whole", async () => {
await expect(older.getByText("Fixed an older thing")).toBeVisible();
await expect(allChangesToggle(older)).toHaveCount(0);
});
await expectExternalPage(page, "changelog-open-website", CHANGELOG_DESTINATION);
await closeSheet(page, "changelog-sheet");
@@ -32,6 +32,8 @@ export interface RewindCompletionGate {
clearTimelineStreamCount(): void;
release(): void;
timelineStreamCount(): number;
/** Timeline reads this page sent since the last clear. */
timelineFetchCount(): number;
waitForDelayedResponse(): Promise<void>;
}
@@ -187,12 +189,20 @@ export async function holdDaemonHydration(page: Page): Promise<DaemonHydrationGa
};
}
// The rewind response and the timeline change it causes; the change removes the rewound row too.
const REWIND_COMPLETION_MESSAGES = new Set([
"agent.rewind.response",
"agent.timeline.rewind",
"agent.timeline.replacement",
]);
export async function holdRewindCompletion(
page: Page,
agentId: string,
): Promise<RewindCompletionGate> {
let released = false;
let timelineStreamCount = 0;
let timelineFetchCount = 0;
const delayedForwards: Array<() => void> = [];
let resolveDelayedResponse: (() => void) | null = null;
const delayedResponse = new Promise<void>((resolve) => {
@@ -201,7 +211,16 @@ export async function holdRewindCompletion(
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => server.send(message));
ws.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
if (
sessionMessage?.type === "fetch_agent_timeline_request" &&
sessionMessage.agentId === agentId
) {
timelineFetchCount += 1;
}
server.send(message);
});
server.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
const payload = sessionMessage ? getPayload(sessionMessage) : null;
@@ -217,11 +236,11 @@ export async function holdRewindCompletion(
}
if (
!released &&
(sessionMessage?.type === "agent.rewind.response" ||
sessionMessage?.type === "agent.timeline.replacement") &&
sessionMessage &&
REWIND_COMPLETION_MESSAGES.has(String(sessionMessage.type)) &&
payload?.agentId === agentId
) {
// The replacement also signals completion and removes the rewound row/menu.
// The rewind also signals completion and removes the rewound row/menu.
// Hold it with the response so the pending-state assertion observes a pending rewind.
delayedForwards.push(() => ws.send(message));
if (sessionMessage.type === "agent.rewind.response") resolveDelayedResponse?.();
@@ -234,6 +253,7 @@ export async function holdRewindCompletion(
return {
clearTimelineStreamCount() {
timelineStreamCount = 0;
timelineFetchCount = 0;
},
release() {
released = true;
@@ -242,6 +262,7 @@ export async function holdRewindCompletion(
}
},
timelineStreamCount: () => timelineStreamCount,
timelineFetchCount: () => timelineFetchCount,
waitForDelayedResponse: () => delayedResponse,
};
}
@@ -27,3 +27,18 @@ export async function openWhatsNew(page: Page): Promise<Locator> {
export function release(sheet: Locator, version: string): Locator {
return sheet.getByTestId(`changelog-release-${version}`);
}
/** The one control a release with highlights has for its sections, in either state. */
export function allChangesToggle(entry: Locator): Locator {
return entry.getByRole("button", { name: /^(Show|Hide) all changes$/ });
}
export async function showAllChanges(entry: Locator): Promise<void> {
await entry.getByRole("button", { name: "Show all changes" }).click();
await expect(entry.getByRole("button", { name: "Hide all changes" })).toBeVisible();
}
export async function hideAllChanges(entry: Locator): Promise<void> {
await entry.getByRole("button", { name: "Hide all changes" }).click();
await expect(entry.getByRole("button", { name: "Show all changes" })).toBeVisible();
}
@@ -49,6 +49,83 @@ export async function moveOntoContextWindowDetails(page: Page, card: Locator): P
await page.mouse.move(meter.x + meter.width / 2, box.y + box.height / 2, { steps: 20 });
}
/** The hover card as one painted frame showed it, or unmounted. */
export interface ContextWindowFrame {
mounted: boolean;
bottom: number | null;
loading: boolean;
usageCards: number;
}
/**
* Reads the hover card as it was painted, every frame, until stopped, so a state that lasted one
* frame is seen. Stop it only after everything has settled.
*/
export async function recordContextWindowFrames(
page: Page,
): Promise<{ stop: () => Promise<ContextWindowFrame[]> }> {
await page.evaluate(() => {
const recorder = window as unknown as {
__contextWindowFrames: ContextWindowFrame[];
__contextWindowRecording: boolean;
};
recorder.__contextWindowFrames = [];
recorder.__contextWindowRecording = true;
const findCard = () =>
document.querySelector<HTMLElement>('[data-testid="context-window-details"]');
const read = (card: HTMLElement | null): ContextWindowFrame => ({
mounted: card !== null,
bottom: card?.getBoundingClientRect().bottom ?? null,
loading: card?.innerText.includes("Loading usage...") ?? false,
usageCards: card?.querySelectorAll('[data-testid^="usage-report-"]').length ?? 0,
});
// A card that just resized has not been painted yet: its frame is read from an observer,
// which runs before that paint.
let watched: HTMLElement | null = null;
let paintedHeight: number | null = null;
let observer: ResizeObserver | null = null;
const watch = (card: HTMLElement) => {
observer = new ResizeObserver(() => {
paintedHeight = card.offsetHeight;
recorder.__contextWindowFrames.push(read(card));
});
observer.observe(card);
};
const channel = new MessageChannel();
// A message posted from an animation frame runs after that frame's paint.
channel.port1.addEventListener("message", () => {
const card = findCard();
if (card !== watched) {
observer?.disconnect();
watched = card;
paintedHeight = null;
if (card) watch(card);
}
if (!card || card.offsetHeight === paintedHeight) {
recorder.__contextWindowFrames.push(read(card));
}
});
channel.port1.start();
const sample = () => {
if (!recorder.__contextWindowRecording) return;
channel.port2.postMessage(null);
requestAnimationFrame(sample);
};
requestAnimationFrame(sample);
});
return {
stop: () =>
page.evaluate(() => {
const recorder = window as unknown as {
__contextWindowFrames: ContextWindowFrame[];
__contextWindowRecording: boolean;
};
recorder.__contextWindowRecording = false;
return recorder.__contextWindowFrames;
}),
};
}
export async function leaveContextWindowMeter(page: Page): Promise<void> {
await page.mouse.move(0, 0);
await expect(contextWindowDetails(page)).toHaveCount(0);
@@ -139,6 +216,8 @@ export interface AgentUsageScript {
agentRequests(): string[];
/** Each scoped agent Refresh, by agent ID. */
refreshedAgents(): string[];
/** Reloads the agent's session, which starts it under a new usage scope, as a resume does. */
resumeAgent(agent: MockAgentSession): Promise<void>;
}
/**
@@ -169,6 +248,8 @@ export async function scriptAgentUsage(page: Page): Promise<AgentUsageScript> {
usage
.listRequests()
.flatMap((request) => (request.forceRefresh && request.agentId ? [request.agentId] : [])),
resumeAgent: (agent) =>
usage.untilUsageScopeChanges(agent.agentId, () => agent.client.refreshAgent(agent.agentId)),
};
}
@@ -1,8 +1,11 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, test, type Page } from "@playwright/test";
import { gotoAppShell } from "./app";
import { openAgentRoute } from "./mock-agent";
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
import { copyPluginExample } from "./plugin-fixture";
import { copyPluginExample, pluginRequirements } from "./plugin-fixture";
import { seedWorkspace } from "./seed-client";
const PLUGIN_ID = "composer-controls-example";
@@ -57,3 +60,236 @@ export async function withComposerControlsExample(
await workspace.cleanup();
}
}
const ONE_TOGGLE_PLUGIN_ID = "composer-toggle-and-watcher-e2e";
const ONE_TOGGLE_NAME = "Sandbox flag";
const ONE_TOGGLE_PLUGIN_SOURCE = `import { useComposer } from "@getpaseo/plugin/client";
import { Toggle } from "@getpaseo/plugin/client/ui";
import { useState } from "react";
function PromptWatcher() {
useComposer();
return null;
}
function SandboxFlag() {
const [value, setValue] = useState(false);
return (
<Toggle
title="${ONE_TOGGLE_NAME}"
icon="Lock"
value={value}
onValueChange={({ value: next }) => setValue(next)}
/>
);
}
export default function contribute(client) {
client.addComposerItem({ id: "prompt-watcher", Component: PromptWatcher });
client.addComposerItem({ id: "sandbox-flag", Component: SandboxFlag });
return () => {};
}`;
const PICKER_NAME = "Review state";
const PICKER_PLUGIN_SOURCE = `import { Select } from "@getpaseo/plugin/client/ui";
import { useState } from "react";
const OPTIONS = [
{ value: "draft", label: "Draft" },
{ value: "ready", label: "Ready" },
];
function ReviewState() {
const [value, setValue] = useState("draft");
return (
<Select
title="${PICKER_NAME}"
icon="CircleDot"
value={value}
options={OPTIONS}
onValueChange={({ value: next }) => setValue(next)}
/>
);
}
export default function contribute(client) {
client.addComposerItem({ id: "review-state", Component: ReviewState });
return () => {};
}`;
function toolbar(page: Page) {
return page.getByTestId("agent-controls-toolbar").filter({ visible: true }).first();
}
interface ToolbarFrame {
overflowButton: boolean;
clipped: boolean;
}
declare global {
interface Window {
__pluginToolbarFrames?: ToolbarFrame[];
}
}
/** From the first frame on, records whether the toolbar condensed or clipped. Call before loading. */
export async function recordToolbarFramesFromLoad(page: Page): Promise<void> {
await page.addInitScript(() => {
const frames: ToolbarFrame[] = [];
window.__pluginToolbarFrames = frames;
const tick = () => {
const row = Array.from(
document.querySelectorAll('[data-testid="agent-controls-toolbar"]'),
).find((node) => node.getClientRects().length > 0);
if (row) {
frames.push({
overflowButton: row.querySelector('[data-testid="agent-controls-features"]') !== null,
clipped: row.scrollWidth > row.clientWidth,
});
}
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
});
}
export async function expectToolbarNeverCondensedOrClipped(page: Page): Promise<void> {
const frames = await page.evaluate(() => window.__pluginToolbarFrames ?? []);
expect(frames.length).toBeGreaterThan(0);
expect(frames.filter((frame) => frame.overflowButton || frame.clipped)).toEqual([]);
}
function agentFeaturesButton(page: Page) {
return page.getByRole("button", { name: "Open agent features", exact: true });
}
interface ToolbarPluginComposer {
setWindowWidth(width: number): Promise<void>;
expectToolbarWidthBetween(min: number, max: number): Promise<void>;
expectToggleInlineAtFullDensity(): Promise<void>;
expectToggleInOverflow(): Promise<void>;
closeOverflowSheet(): Promise<void>;
startRecordingDensity(): Promise<void>;
expectAtMostOneDensityChange(): Promise<void>;
openInlinePicker(): Promise<void>;
expectPickerClosed(): Promise<void>;
}
/** Runs `run` in an agent's composer with one icon toggle and one item that renders nothing. */
export function withToggleAndWatcherComposerItems(
page: Page,
run: (composer: ToolbarPluginComposer) => Promise<void>,
) {
return withToolbarPlugin(page, ONE_TOGGLE_PLUGIN_SOURCE, run);
}
/** Runs `run` in an agent's composer with one plugin `Select` in the toolbar. */
export function withPickerComposerItem(
page: Page,
run: (composer: ToolbarPluginComposer) => Promise<void>,
) {
return withToolbarPlugin(page, PICKER_PLUGIN_SOURCE, run);
}
async function withToolbarPlugin(
page: Page,
pluginSource: string,
run: (composer: ToolbarPluginComposer) => Promise<void>,
) {
const workspace = await seedWorkspace({ repoPrefix: "composer-measured-items-" });
const agent = await workspace.client.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Measured items agent",
model: "ten-second-stream",
modeId: "load-test",
});
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previous = await client.getDaemonConfig();
const directory = await mkdtemp(path.join(tmpdir(), "paseo-composer-measured-items-"));
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: ONE_TOGGLE_PLUGIN_ID, requirements: pluginRequirements }),
);
await writeFile(path.join(directory, "index.client.tsx"), pluginSource);
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(directory);
await gotoAppShell(page);
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: agent.id });
await expect(toolbar(page)).toBeVisible({ timeout: 30_000 });
await run({
setWindowWidth: (width) =>
test.step(`set the window to ${width} wide`, () =>
page.setViewportSize({ width, height: 800 })),
expectToolbarWidthBetween: (min, max) =>
test.step(`the toolbar is between ${min} and ${max} wide`, async () => {
await expect
.poll(async () => (await toolbar(page).boundingBox())?.width ?? 0)
.toBeGreaterThanOrEqual(min);
expect((await toolbar(page).boundingBox())?.width ?? 0).toBeLessThanOrEqual(max);
}),
expectToggleInlineAtFullDensity: () =>
test.step("the toggle is inline and the toolbar keeps its labels", async () => {
const toggle = toolbar(page).getByRole("button", { name: ONE_TOGGLE_NAME, exact: true });
await expect(toggle).toBeVisible();
await expect(agentFeaturesButton(page)).toHaveCount(0);
await expect(toolbar(page).getByTestId("agent-thinking-selector")).toHaveText("Low");
const row = await toolbar(page).boundingBox();
const box = await toggle.boundingBox();
expect(box && row && box.x + box.width <= row.x + row.width).toBe(true);
}),
expectToggleInOverflow: () =>
test.step("the toggle has moved to the overflow sheet", async () => {
await expect(
toolbar(page).getByRole("button", { name: ONE_TOGGLE_NAME, exact: true }),
).toHaveCount(0);
await agentFeaturesButton(page).click();
await expect(
page.getByRole("button", { name: ONE_TOGGLE_NAME, exact: true }),
).toBeVisible();
}),
closeOverflowSheet: () =>
test.step("close the overflow sheet", async () => {
await page.keyboard.press("Escape");
await expect(page.getByTestId("agent-features-sheet")).toHaveCount(0);
}),
startRecordingDensity: () =>
test.step("record which density each frame paints", () =>
page.evaluate(() => {
const frames: boolean[] = [];
const paint = () => {
frames.push(
document.querySelector('[data-testid="agent-controls-features"]') !== null,
);
window.requestAnimationFrame(paint);
};
window.requestAnimationFrame(paint);
Object.assign(window, { __densityFrames: frames });
})),
expectAtMostOneDensityChange: () =>
test.step("the density changed once at most", async () => {
const changes = await page.evaluate(() => {
const frames = (window as unknown as { __densityFrames: boolean[] }).__densityFrames;
return frames.filter((frame, index) => index > 0 && frame !== frames[index - 1]).length;
});
expect(changes).toBeLessThanOrEqual(1);
}),
openInlinePicker: () =>
test.step("open the plugin picker from the toolbar", async () => {
await toolbar(page).getByRole("button", { name: PICKER_NAME, exact: true }).click();
await expect(page.getByTestId("combobox-desktop-container")).toBeVisible();
}),
expectPickerClosed: () =>
test.step("the plugin picker has closed", () =>
expect(page.getByTestId("combobox-desktop-container")).toHaveCount(0)),
});
} finally {
await client.removePlugin(ONE_TOGGLE_PLUGIN_ID);
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
await client.close();
await rm(directory, { recursive: true, force: true });
await workspace.cleanup();
}
}
@@ -3,16 +3,37 @@ import { tmpdir } from "node:os";
import path from "node:path";
import { expect, test, type Page } from "@playwright/test";
import { gotoAppShell } from "./app";
import { attachComposerTestImage, openImageLightbox } from "./composer";
import {
contextWindowDetails,
pressContextWindowMeter,
seedAgentWithContextWindow,
} from "./context-window";
import { gotoWorkspace } from "./launcher";
import { openCommandCenter } from "./command-center";
import { openAgentRoute } from "./mock-agent";
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
import { openAgentRoute, seedMockAgentWorkspace } from "./mock-agent";
import { connectNewWorkspaceDaemonClient, openGlobalNewWorkspaceComposer } from "./new-workspace";
import { copyPluginExample, pluginRequirements } from "./plugin-fixture";
import { seedWorkspace } from "./seed-client";
import {
expectNoSheetOpen,
expectOpenTabCount,
promptMentioning,
sheet,
tapSheetBackdrop,
} from "./sheet";
import { openSubagentFromCompactTrack, seedSubagent } from "./subagents";
import { getServerId } from "./server-id";
import { createAgentTabFromMenu, ensureExplorerSidebar } from "./workspace-tabs";
import {
createAgentTabFromMenu,
dragTabIntoExplorer,
ensureExplorerSidebar,
} from "./workspace-tabs";
const WIDE = { width: 1440, height: 900 };
const COMPACT = { width: 390, height: 844 };
const CHECKLIST = "- [ ] Tests\n- [ ] Docs\n";
const FILE_LINK = "linked.ts:3";
type Workspace = Awaited<ReturnType<typeof seedWorkspace>>;
@@ -50,6 +71,9 @@ export async function withComposerToolsExample(
focusTheAgentTab(): Promise<void>;
expectPanelFocusedAgent(focused: boolean): Promise<void>;
expectPanelShowsReview(needsReview: boolean): Promise<void>;
useCompactLayout(): Promise<void>;
insertChecklistFromControlsSheet(): Promise<void>;
expectComposerReadyToType(text: string): Promise<void>;
}) => Promise<void>,
) {
const workspace = await seedWorkspace({ repoPrefix: "composer-tools-", title: "Composer tools" });
@@ -115,6 +139,23 @@ export async function withComposerToolsExample(
expect(
page.getByText(`Needs review: ${needsReview ? "yes" : "no"}`, { exact: true }),
).toBeVisible()),
useCompactLayout: () =>
test.step("narrow the window to a phone", async () => {
await page.setViewportSize(COMPACT);
await expect(prompt(page).filter({ visible: true })).toBeVisible();
}),
insertChecklistFromControlsSheet: () =>
test.step("press the Action in the composer's controls sheet", async () => {
await page.getByRole("button", { name: /^Select model/ }).click();
await page.getByText("Insert checklist", { exact: true }).click();
}),
expectComposerReadyToType: (text) =>
test.step("the sheet is closed and the cursor is in the composer", async () => {
await expectNoSheetOpen(page);
const input = prompt(page).filter({ visible: true });
await expect(input).toHaveValue(text);
await expect(input).toBeFocused();
}),
});
} finally {
await client.removePlugin("composer-controls-example");
@@ -205,4 +246,516 @@ export async function withPluginWritingToAnotherWorkspace(
}
}
/**
* A plugin that reaches one agent's composer from a pill and from Command Center items, and asks
* for focus, wherever the user is.
*/
export async function withPluginFocusingAComposer(
page: Page,
run: (author: {
openOtherAgent(): Promise<void>;
openTargetAgent(): Promise<void>;
goToAnotherWorkspace(): Promise<void>;
moveFocusOutOfComposer(): Promise<void>;
pressPill(): Promise<void>;
focusFromCommandCenter(): Promise<void>;
selectPromptFromCommandCenter(): Promise<void>;
useCompactLayout(): Promise<void>;
coverComposerWithExplorerPanel(): Promise<void>;
pressPanelFocusButton(): Promise<void>;
expectExplorerPanelClosed(): Promise<void>;
fillComposer(text: string): Promise<void>;
expectOnTargetAgent(): Promise<void>;
expectComposerFocused(): Promise<void>;
typeAtCursor(text: string): Promise<void>;
expectComposerText(text: string): Promise<void>;
}) => Promise<void>,
) {
const workspace = await seedWorkspace({ repoPrefix: "composer-focus-", title: "Composer focus" });
const elsewhere = await seedWorkspace({ repoPrefix: "focus-away-", title: "Focus away" });
const target = await createMockAgent(workspace, "Target agent");
const other = await createMockAgent(workspace, "Other agent");
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previous = await client.getDaemonConfig();
const directory = await mkdtemp(path.join(tmpdir(), "paseo-composer-focus-"));
const targetId = JSON.stringify(target.id);
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: "composer-focus", requirements: pluginRequirements }),
);
await writeFile(
path.join(directory, "index.client.tsx"),
`import { Pressable, Text } from "react-native";
export default function contribute(client) {
client.addComposerPill({
id: "hello",
workspaceId: ${JSON.stringify(workspace.workspaceId)},
agentId: ${targetId},
button: {
title: "Add HELLO to the composer",
icon: "Hand",
label: "Hello",
behavior: {
kind: "action",
async onPress() {
for (const composer of client.composers({ agentId: ${targetId} })) {
composer.insertText("HELLO");
await composer.focus();
}
},
},
},
});
client.addWorkspacePanel({
id: "tools",
title: "Focus tools",
icon: "ListChecks",
locations: ["explorer"],
Component() {
return (
<Pressable
accessibilityRole="button"
accessibilityLabel="Focus the target agent"
onPress={() => client.composers({ agentId: ${targetId} })[0]?.focus()}
>
<Text>Focus the target agent</Text>
</Pressable>
);
},
});
client.addCommandCenterItem({
id: "focus",
title: "Composer focus: focus the target",
icon: "Flag",
context: "global",
onSelect({ composers }) {
return composers({ agentId: ${targetId} })[0].focus();
},
});
client.addCommandCenterItem({
id: "select",
title: "Composer focus: select the prompt",
icon: "Flag",
context: "global",
onSelect({ composers }) {
const composer = composers({ agentId: ${targetId} })[0];
return composer.focus({ selection: { start: 0, end: composer.prompt.length } });
},
});
return () => {};
}`,
);
const targetTab = () => page.getByTestId(`workspace-tab-agent_${target.id}`);
const workspaceRow = (id: string) =>
page.getByTestId(`sidebar-workspace-row-${getServerId()}:${id}`);
const panelFocusButton = () =>
page.getByRole("button", { name: "Focus the target agent", exact: true });
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(directory);
await page.setViewportSize(WIDE);
await gotoAppShell(page);
await run({
openOtherAgent: () =>
test.step("open another agent, so the target's tab is not showing", async () => {
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: other.id });
await expect(prompt(page)).toBeVisible({ timeout: 30_000 });
}),
openTargetAgent: () =>
test.step("open the target agent", async () => {
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: target.id });
await expect(prompt(page)).toBeVisible({ timeout: 30_000 });
}),
goToAnotherWorkspace: () =>
test.step("go to another workspace", async () => {
await workspaceRow(elsewhere.workspaceId).click();
await expect(targetTab().first()).toBeHidden();
}),
moveFocusOutOfComposer: () =>
test.step("the composer does not have focus", async () => {
await prompt(page).blur();
await expect(prompt(page)).not.toBeFocused();
}),
pressPill: () =>
test.step("press the pill", () => page.getByRole("button", { name: "Hello" }).click()),
focusFromCommandCenter: () =>
test.step("run the plugin's focus command", () =>
runCommand(page, "Composer focus: focus the target")),
selectPromptFromCommandCenter: () =>
test.step("run the plugin's select command", () =>
runCommand(page, "Composer focus: select the prompt")),
useCompactLayout: () =>
test.step("use a phone-sized window", () => page.setViewportSize(COMPACT)),
coverComposerWithExplorerPanel: () =>
test.step("open the plugin's panel in the Explorer, covering the composer", async () => {
const explorer = await ensureExplorerSidebar(page);
await explorer.getByRole("button", { name: "New tab", exact: true }).click();
await page
.getByTestId("workspace-new-tab-menu")
.filter({ visible: true })
.getByRole("menuitem", { name: "Focus tools", exact: true })
.click();
await expect(panelFocusButton()).toBeVisible();
}),
pressPanelFocusButton: () =>
test.step("press the panel's button", () => panelFocusButton().click()),
expectExplorerPanelClosed: () =>
test.step("the Explorer no longer covers the composer", () =>
expect(panelFocusButton()).toBeHidden({ timeout: 30_000 })),
fillComposer: (text) =>
test.step(`write "${text}" in the composer`, () => prompt(page).fill(text)),
expectOnTargetAgent: () =>
test.step("the target agent's tab is showing", async () => {
await expect(targetTab().filter({ visible: true }).first()).toHaveAttribute(
"aria-selected",
"true",
{ timeout: 30_000 },
);
}),
expectComposerFocused: () =>
test.step("the composer input has focus", () =>
expect(prompt(page)).toBeFocused({ timeout: 30_000 })),
typeAtCursor: (text) => test.step(`type "${text}"`, () => page.keyboard.type(text)),
expectComposerText: (text) =>
test.step("the composer holds the text", () => expect(prompt(page)).toHaveValue(text)),
});
} finally {
await client.removePlugin("composer-focus");
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
await client.close();
await rm(directory, { recursive: true, force: true });
await elsewhere.cleanup();
await workspace.cleanup();
}
}
export { CHECKLIST };
type HeldComposerGlobal = typeof globalThis & {
heldComposer?: {
focus(options?: { selection?: { start: number; end: number } }): Promise<void>;
};
};
export async function withPluginHoldingNewWorkspaceComposer(
page: Page,
run: (author: {
openNewWorkspace(): Promise<void>;
leaveCompactExplorerForNewWorkspace(): Promise<void>;
openSettings(): Promise<void>;
openThinkingPicker(): Promise<void>;
previewAttachedImage(): Promise<void>;
typeInHeldComposer(text: string): Promise<void>;
expectOverlappingFocusesKeepTheLatestSelection(): Promise<void>;
expectHeldComposerFocusResult(result: string): Promise<void>;
expectHeldComposerReadyToType(): Promise<void>;
}) => Promise<void>,
) {
const workspace = await seedWorkspace({ repoPrefix: "held-composer-", title: "Held composer" });
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previous = await client.getDaemonConfig();
const directory = await mkdtemp(path.join(tmpdir(), "paseo-held-composer-"));
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: "held-composer", requirements: pluginRequirements }),
);
await writeFile(
path.join(directory, "index.client.tsx"),
`import { useComposer } from "@getpaseo/plugin/client";
function Holder() {
globalThis.heldComposer = useComposer();
return null;
}
export default function contribute(client) {
client.addComposerItem({ id: "holder", Component: Holder });
return () => {};
}`,
);
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(directory);
await page.setViewportSize(WIDE);
await gotoAppShell(page);
await run({
openNewWorkspace: () =>
test.step("open the New workspace composer", async () => {
await openGlobalNewWorkspaceComposer(page);
await page.waitForFunction(() => (globalThis as HeldComposerGlobal).heldComposer);
}),
leaveCompactExplorerForNewWorkspace: () =>
test.step("leave a compact Explorer for New workspace with the shortcut", async () => {
await gotoWorkspace(page, workspace.workspaceId);
await page.setViewportSize(COMPACT);
await expect(await ensureExplorerSidebar(page)).toBeVisible();
await page.evaluate(() => {
(globalThis as HeldComposerGlobal).heldComposer = undefined;
});
await page.keyboard.press("ControlOrMeta+n");
await expect(page).toHaveURL(/\/new(?:\?.*)?$/);
// A compact composer mounts plugin items inside its controls sheet.
await page.getByRole("button", { name: /^Select model/ }).click();
await page.waitForFunction(() => (globalThis as HeldComposerGlobal).heldComposer);
await tapSheetBackdrop(page);
await expectNoSheetOpen(page);
}),
openSettings: () =>
test.step("push Settings over the New workspace screen", async () => {
await page.getByTestId("sidebar-settings").filter({ visible: true }).click();
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
}),
openThinkingPicker: () =>
test.step("open the thinking picker in the New workspace composer", async () => {
await page
.getByRole("button", { name: /^Select thinking option/ })
.filter({ visible: true })
.click();
await expect(page.getByText("High", { exact: true })).toBeVisible();
}),
previewAttachedImage: () =>
test.step("attach an image to the New workspace composer and preview it", async () => {
await attachComposerTestImage(page);
await openImageLightbox(page);
}),
typeInHeldComposer: (text) =>
test.step(`type ${text} in the New workspace composer`, async () => {
await prompt(page).filter({ visible: true }).fill(text);
}),
expectOverlappingFocusesKeepTheLatestSelection: () =>
test.step("two overlapping focus() calls leave the later selection", async () => {
const selection = await page.evaluate(async () => {
const composer = (globalThis as HeldComposerGlobal).heldComposer!;
await Promise.all([
composer.focus({ selection: { start: 0, end: 1 } }),
composer.focus({ selection: { start: 2, end: 4 } }),
]);
const input = document.activeElement as HTMLTextAreaElement;
return [input.selectionStart, input.selectionEnd];
});
expect(selection).toEqual([2, 4]);
}),
expectHeldComposerReadyToType: () =>
test.step("nothing covers the composer and the cursor is in it", async () => {
await expect(page.getByText("High", { exact: true })).toHaveCount(0);
await expect(page.getByTestId("attachment-lightbox-close")).toHaveCount(0);
await expect(prompt(page).filter({ visible: true })).toBeFocused();
}),
expectHeldComposerFocusResult: (result) =>
test.step(`the held composer's focus() reports ${result}`, async () => {
const actual = await page.evaluate(() =>
(globalThis as HeldComposerGlobal).heldComposer?.focus().then(
() => "focused",
(error: Error) => error.message,
),
);
expect(actual).toBe(result);
}),
});
} finally {
await client.removePlugin("held-composer");
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
await client.close();
await rm(directory, { recursive: true, force: true });
await workspace.cleanup();
}
}
type FocusAgentGlobal = typeof globalThis & {
focusAgent?: (agentId: string) => Promise<void>;
};
export async function withPluginFocusingASubagent(
page: Page,
run: (author: {
openParentAgent(): Promise<void>;
openExplorer(): Promise<void>;
focusSubagent(): Promise<void>;
expectSubagentTabReadyToType(): Promise<void>;
keepParentInExplorerOnCompactScreen(): Promise<void>;
focusParent(): Promise<void>;
expectParentReadyToType(): Promise<void>;
coverExplorerComposerWithFileSheet(): Promise<void>;
focusParentAsAFileSheetOpens(): Promise<void>;
openSubagentPageInParentComposer(): Promise<void>;
}) => Promise<void>,
) {
const workspace = await seedMockAgentWorkspace({
repoPrefix: "focus-subagent-",
title: "Focus parent",
initialPrompt: promptMentioning(FILE_LINK),
});
await writeFile(path.join(workspace.cwd, "linked.ts"), "one\ntwo\nthree\n", "utf8");
const child = await seedSubagent(
{ client: workspace.client, repoPath: workspace.cwd, workspaceId: workspace.workspaceId },
{ parentId: workspace.agentId, title: "Focus child" },
);
const plugin = await installFocusingPlugin(page);
const { focusAgent } = plugin;
try {
await page.setViewportSize(COMPACT);
await gotoAppShell(page);
await run({
keepParentInExplorerOnCompactScreen: () =>
test.step("move the parent into the Explorer, then narrow the window", async () => {
await page.setViewportSize(WIDE);
await openAgentRoute(page, {
workspaceId: workspace.workspaceId,
agentId: workspace.agentId,
});
await page.waitForFunction(() => (globalThis as FocusAgentGlobal).focusAgent);
// The seeded turn must finish first, so the drag isn't racing its updates to the tab.
await expect(page.getByText(FILE_LINK, { exact: true })).toBeVisible({ timeout: 15_000 });
const explorer = await ensureExplorerSidebar(page);
await dragTabIntoExplorer(page, `agent_${workspace.agentId}`);
await expect(
explorer.getByTestId(`workspace-tab-agent_${workspace.agentId}`),
).toBeVisible();
await page.setViewportSize(COMPACT);
// The phone layout hides the Explorer; focus must find it there, not in the wide layout.
await expect(explorer).toBeHidden();
}),
focusParent: () =>
test.step("focus the agent kept in the Explorer", () => focusAgent(workspace.agentId)),
expectParentReadyToType: () =>
test.step("no sheet is open and the cursor is in the composer", async () => {
await expectNoSheetOpen(page);
await expect(prompt(page).filter({ visible: true })).toBeFocused();
}),
openSubagentPageInParentComposer: () =>
test.step("open the subagent's page from the parent's Subagents track", async () => {
await openSubagentFromCompactTrack(page, child.id);
await expect(sheet(page, child.title)).toBeVisible();
}),
coverExplorerComposerWithFileSheet: () =>
test.step("open a file's sheet over the Explorer's composer", async () => {
await page.getByText(FILE_LINK, { exact: true }).filter({ visible: true }).click();
await expect(sheet(page, "linked.ts")).toBeVisible();
}),
focusParentAsAFileSheetOpens: () =>
test.step("focus the agent while a file's sheet is opening over it", async () => {
// The click looks the file up first. Focus in the commit that mounts the sheet, before
// its opening animation has started.
await page.evaluate((id) => {
const global = globalThis as FocusAgentGlobal & {
focusedAsSheetOpens?: Promise<string>;
};
global.focusedAsSheetOpens = new Promise((resolve) => {
const observer = new MutationObserver(() => {
if (!document.querySelector('[aria-label="Bottom sheet backdrop"]')) return;
observer.disconnect();
void global.focusAgent?.(id).then(
() => resolve("focused"),
(error: Error) => resolve(error.message),
);
});
observer.observe(document.body, { childList: true, subtree: true });
});
}, workspace.agentId);
await page.getByText(FILE_LINK, { exact: true }).filter({ visible: true }).click();
const outcome = await page.evaluate(
() => (globalThis as { focusedAsSheetOpens?: Promise<string> }).focusedAsSheetOpens,
);
expect(outcome).toBe("focused");
}),
openParentAgent: () =>
test.step("open the parent agent on a phone", async () => {
await openAgentRoute(page, {
workspaceId: workspace.workspaceId,
agentId: workspace.agentId,
});
await expect(prompt(page)).toBeVisible({ timeout: 30_000 });
await expectOpenTabCount(page, 1);
await page.waitForFunction(() => (globalThis as FocusAgentGlobal).focusAgent);
}),
openExplorer: () =>
test.step("open the Explorer over the parent's composer", async () => {
await expect(await ensureExplorerSidebar(page)).toBeVisible();
}),
focusSubagent: () =>
test.step("focus the subagent, which has no tab", () => focusAgent(child.id)),
expectSubagentTabReadyToType: () =>
test.step("the subagent is a tab with the cursor in its composer", async () => {
await expectNoSheetOpen(page);
await expectOpenTabCount(page, 2);
await expect(prompt(page)).toBeFocused();
}),
});
} finally {
await plugin.remove();
await workspace.cleanup();
}
}
/** A plugin that focuses any agent's composer, as a callback holding the agent's id would. */
async function installFocusingPlugin(page: Page) {
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previous = await client.getDaemonConfig();
const directory = await mkdtemp(path.join(tmpdir(), "paseo-focus-agent-"));
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: "focus-agent", requirements: pluginRequirements }),
);
await writeFile(
path.join(directory, "index.client.tsx"),
`export default function contribute(client) {
globalThis.focusAgent = (agentId) => client.composers({ agentId })[0].focus();
return () => {};
}`,
);
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(directory);
return {
focusAgent: async (agentId: string) => {
const result = await page.evaluate(
(id) =>
(globalThis as FocusAgentGlobal).focusAgent?.(id).then(
() => "focused",
(error: Error) => error.message,
),
agentId,
);
expect(result).toBe("focused");
},
remove: async () => {
await client.removePlugin("focus-agent");
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
await client.close();
await rm(directory, { recursive: true, force: true });
},
};
}
export async function withPluginFocusingAnAgentWithContextUsage(
page: Page,
run: (author: {
openContextUsageOnPhone(): Promise<void>;
focusAgent(): Promise<void>;
expectReadyToType(): Promise<void>;
}) => Promise<void>,
) {
const agent = await seedAgentWithContextWindow();
const plugin = await installFocusingPlugin(page);
try {
await page.setViewportSize(COMPACT);
await gotoAppShell(page);
await run({
openContextUsageOnPhone: () =>
test.step("open the agent's context usage on a phone", async () => {
await openAgentRoute(page, agent);
await page.waitForFunction(() => (globalThis as FocusAgentGlobal).focusAgent);
await pressContextWindowMeter(page);
}),
focusAgent: () => test.step("focus the agent", () => plugin.focusAgent(agent.agentId)),
expectReadyToType: () =>
test.step("the context usage is closed and the cursor is in the composer", async () => {
await expect(contextWindowDetails(page)).toHaveCount(0);
await expect(prompt(page).filter({ visible: true })).toBeFocused();
}),
});
} finally {
await plugin.remove();
await agent.cleanup();
}
}
+12
View File
@@ -112,3 +112,15 @@ export async function expectDeclinedDiscardPrompt(
await close();
expect(await prompt).toContain("discard the draft");
}
/** A seeded prompt the mock agent echoes, so the chat shows `target` as a tappable file link. */
export function promptMentioning(target: string): string {
return [
"Generate a title and a git branch name for a coding agent from the user prompt and attachments.",
"Return JSON only with fields 'title' and 'branch'.",
"",
"<user-prompt>",
`Open \`${target}\` now`,
"</user-prompt>",
].join("\n");
}
@@ -1,5 +1,6 @@
import { expect, type Page } from "@playwright/test";
import type { installDaemonWebSocketGate } from "./daemon-websocket-gate";
import { readReplicaCache } from "./replica-cache-storage";
import type { LongTimelineAgent } from "./timeline-pagination";
import {
expectReconnectingToastGone,
@@ -13,6 +14,12 @@ const SUBSCRIPTION_REQUEST = "agent.timeline.set_subscription.request";
const SUBSCRIPTION_RESPONSE = "agent.timeline.set_subscription.response";
const TIMELINE_REQUEST = "fetch_agent_timeline_request";
const TIMELINE_RESPONSE = "fetch_agent_timeline_response";
/**
* `FRESHNESS_CHECK_QUIET_MS` is 500: an opening check unanswered that long reports Updating messages by
* design, however long the daemon takes. The app starts that wait just before it sends the question
* this helper times, so the bound stops short of 500.
*/
const QUIET_WINDOW_FLOOR_MS = 300;
export interface TimelineRequestCounts {
agentId?: string;
@@ -77,48 +84,99 @@ export async function waitForResumeAnswer(
]);
}
interface UpdatingToastTiming {
askedAt: number | null;
shownAt: number | null;
}
declare global {
interface Window {
__updatingToastTiming?: UpdatingToastTiming;
}
}
/** Runs in the page: times the freshness question and the toast's first commit on its own clock. */
function recordUpdatingToastTiming(): void {
const noteToast = (timing: UpdatingToastTiming) => {
if (document.querySelector('[data-testid="agent-updating-toast"]') === null) return;
timing.shownAt ??= performance.now();
};
const existing = window.__updatingToastTiming;
if (existing) {
existing.askedAt = null;
existing.shownAt = null;
noteToast(existing);
return;
}
const timing: UpdatingToastTiming = { askedAt: null, shownAt: null };
window.__updatingToastTiming = timing;
const send = WebSocket.prototype.send;
WebSocket.prototype.send = function sendRecordingQuestion(data) {
if (
typeof data === "string" &&
data.includes("agent.timeline.set_subscription.request") &&
data.includes('"agentIds":["')
) {
timing.askedAt ??= performance.now();
}
return send.call(this, data);
};
new MutationObserver(() => noteToast(timing)).observe(document, {
childList: true,
subtree: true,
});
noteToast(timing);
}
/** A toast may only follow an unanswered question; nothing about the daemon's speed changes that. */
async function expectToastWaitedForTheDaemon(page: Page): Promise<void> {
await waitForNextPaint(page);
const showedTooSoon = await page.evaluate((floorMs) => {
const { askedAt, shownAt } = window.__updatingToastTiming as UpdatingToastTiming;
if (shownAt === null) return false;
return askedAt === null || shownAt - askedAt < floorMs;
}, QUIET_WINDOW_FLOOR_MS);
expect(showedTooSoon, "the Updating messages toast did not wait for the daemon").toBe(false);
}
/** Records every appearance of the toast, including one that lasts a single commit. */
export async function watchForUpdatingToast(page: Page): Promise<() => Promise<void>> {
const watch = await page.evaluateHandle(() => {
let shown = false;
const check = () => {
shown ||= document.querySelector('[data-testid="agent-updating-toast"]') !== null;
};
const observer = new MutationObserver(check);
observer.observe(document.body, { childList: true, subtree: true });
check();
return {
stop() {
observer.disconnect();
return shown;
},
};
});
return async () => {
await waitForNextPaint(page);
const shown = await watch.evaluate((recorder) => recorder.stop());
await watch.dispose();
expect(shown, "the Updating messages toast appeared").toBe(false);
};
await page.evaluate(recordUpdatingToastTiming);
return () => expectToastWaitedForTheDaemon(page);
}
/** Reopens the app on this chat, recording the toast from the first paint of the new page. */
export async function reopenAppWatchingForUpdatingToast(page: Page): Promise<() => Promise<void>> {
await page.addInitScript(() => {
const recorder = window as Window & { updatingToastShown?: boolean };
new MutationObserver(() => {
recorder.updatingToastShown ||=
document.querySelector('[data-testid="agent-updating-toast"]') !== null;
}).observe(document, { childList: true, subtree: true });
});
await page.addInitScript(recordUpdatingToastTiming);
await page.reload();
return async () => {
await waitForNextPaint(page);
const shown = await page.evaluate(
() => (window as Window & { updatingToastShown?: boolean }).updatingToastShown === true,
);
expect(shown, "the Updating messages toast appeared").toBe(false);
};
return () => expectToastWaitedForTheDaemon(page);
}
/** Opens persisted history with the daemon's freshness answer held. */
export async function reopenWithFreshnessCheckUnanswered(
page: Page,
gate: DaemonWebSocketGate,
agent: LongTimelineAgent,
): Promise<void> {
// A cold open needs a persisted authoritative cursor to ask a freshness question.
await expect
.poll(async () => {
const cache = await readReplicaCache(page);
return cache?.hosts
?.flatMap((host) => host.timelines)
.find((timeline) => timeline.agentId === agent.agentId)?.range?.endSeq;
})
.toBeGreaterThan(0);
gate.holdNextClientRequest(SUBSCRIPTION_REQUEST);
await page.reload();
await expect(page.getByText(agent.newestPrompt, { exact: true })).toBeVisible();
await gate.waitForHeldClientRequest();
await expect(page.getByRole("alert").filter({ hasText: "Updating messages" })).toBeVisible();
}
export async function answerFreshnessCheck(page: Page, gate: DaemonWebSocketGate): Promise<void> {
gate.releaseHeldClientRequest();
await expect(page.getByRole("alert").filter({ hasText: "Updating messages" })).toHaveCount(0);
}
async function waitForNextPaint(page: Page): Promise<void> {
@@ -11,6 +11,8 @@ export interface UsageListRequest {
export interface UsageReportsFixture {
listRequests(): UsageListRequest[];
waitForListRequests(count: number): Promise<void>;
/** Runs `action`, then waits until the app has received the agent's next usage scope. */
untilUsageScopeChanges(agentId: string, action: () => Promise<unknown>): Promise<void>;
}
interface UsageReportsFixtureOptions {
@@ -97,6 +99,26 @@ function withUsageSupportFeature(
});
}
function agentUsageScope(message: WebSocketMessage): { agentId: string; key: string } | null {
const envelope = parseJson(message) as {
type?: unknown;
message?: {
type?: unknown;
payload?: { kind?: unknown; agent?: { id?: unknown; usageScopeKey?: unknown } };
};
} | null;
const agent = envelope?.message?.payload?.agent;
if (
envelope?.message?.type !== "agent_update" ||
envelope.message.payload?.kind !== "upsert" ||
typeof agent?.id !== "string" ||
typeof agent.usageScopeKey !== "string"
) {
return null;
}
return { agentId: agent.id, key: agent.usageScopeKey };
}
function pick<T>(values: T[], index: number): T {
const value = values[Math.min(index, values.length - 1)];
if (value === undefined) throw new Error("Usage fixture requires at least one response.");
@@ -127,6 +149,16 @@ export async function installUsageReportsFixture(
): Promise<UsageReportsFixture> {
const listRequests: UsageListRequest[] = [];
const listCounter = createCounter();
const usageScopes = new Map<string, string>();
const usageScopeChanges = new Map<string, ReturnType<typeof createCounter>>();
const usageScopeCounts = new Map<string, number>();
const usageScopeCounter = (agentId: string) => {
const existing = usageScopeChanges.get(agentId);
if (existing) return existing;
const created = createCounter();
usageScopeChanges.set(agentId, created);
return created;
};
const usageSupported = options.usageSupported ?? true;
const isUsageSupported = () =>
typeof usageSupported === "function" ? usageSupported() : usageSupported;
@@ -239,6 +271,15 @@ export async function installUsageReportsFixture(
options.usageSettingSupported ?? true,
)
: null;
const scope = agentUsageScope(message);
if (scope && usageScopes.get(scope.agentId) !== scope.key) {
usageScopes.set(scope.agentId, scope.key);
usageScopeCounts.set(scope.agentId, (usageScopeCounts.get(scope.agentId) ?? 0) + 1);
// The app must hold the new scope before a test acts on it.
ws.send(serverInfo ?? message);
usageScopeCounter(scope.agentId).increment();
return;
}
ws.send(serverInfo ?? message);
});
});
@@ -246,5 +287,10 @@ export async function installUsageReportsFixture(
return {
listRequests: () => [...listRequests],
waitForListRequests: (count) => listCounter.waitFor(count),
untilUsageScopeChanges: async (agentId, action) => {
const next = (usageScopeCounts.get(agentId) ?? 0) + 1;
await action();
await usageScopeCounter(agentId).waitFor(next);
},
};
}
@@ -61,6 +61,23 @@ function explorerSidebar(page: Page) {
return visibleTestId(page, "workspace-explorer-sidebar").first();
}
export async function dragTabIntoExplorer(page: Page, tabId: string): Promise<void> {
const source = page.getByTestId("workspace-pane-main").getByTestId(`workspace-tab-${tabId}`);
const destination = explorerSidebar(page).getByTestId("workspace-tab-files");
const sourceBox = await source.boundingBox();
const destinationBox = await destination.boundingBox();
if (!sourceBox || !destinationBox)
throw new Error("Tab drag requires visible source and destination");
const start = { x: sourceBox.x + 12, y: sourceBox.y + sourceBox.height / 2 };
await page.mouse.move(start.x, start.y);
await page.mouse.down();
await page.mouse.move(start.x + 12, start.y + 4);
await page.mouse.move(destinationBox.x + 12, destinationBox.y + destinationBox.height / 2, {
steps: 20,
});
await page.mouse.up();
}
async function selectWorkspaceTab(tab: Locator): Promise<void> {
if ((await tab.getAttribute("aria-selected")) !== "true") {
// The close action overlays the chip's trailing edge on hover. Click the
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.15.3",
"version": "0.15.4",
"private": true,
"main": "index.ts",
"scripts": {
@@ -56,6 +56,7 @@ export function ChatFind({
agentId,
serverId,
epoch,
rewindSeq,
items,
viewportRef,
revealLoadedMessage,
@@ -95,8 +96,8 @@ export function ChatFind({
);
const state = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot);
useEffect(() => {
model.updateHistory(epoch, items);
}, [model, epoch, items]);
model.updateHistory(epoch, items, rewindSeq);
}, [model, epoch, items, rewindSeq]);
useEffect(() => () => model.close(), [model]);
useEffect(() => {
if (!active) model.close();
@@ -18,7 +18,7 @@ function message(seq: number, text = "target"): StreamItem {
kind: "assistant_message",
text,
timestamp: new Date(0),
timelineCursor: { epoch: "epoch", seq },
timelineCursor: { epoch: "epoch", seq, anchorSeq: seq },
};
}
@@ -39,7 +39,7 @@ it("loads an older location and searches a response merged across suppressed tas
source.map((item, index) => ({
event: { type: "timeline" as const, provider: "claude" as const, item },
timestamp: new Date(0),
timelineCursor: { epoch: "epoch", seq: index + 1 },
timelineCursor: { epoch: "epoch", seq: index + 1, anchorSeq: index + 1 },
})),
{ source: "canonical" },
);
@@ -55,7 +55,7 @@ it("loads an older location and searches a response merged across suppressed tas
},
async load(_epoch, seq) {
loaded.push(seq);
model.updateHistory("epoch", history);
model.updateHistory("epoch", history, null);
},
async reveal(_id, _query, occurrence) {
revealed.push(occurrence);
@@ -64,7 +64,7 @@ it("loads an older location and searches a response merged across suppressed tas
clear() {},
});
try {
model.updateHistory("epoch", []);
model.updateHistory("epoch", [], null);
model.open();
model.setQuery("target");
await expect.poll(() => model.getSnapshot().phase).toBe("ready");
@@ -99,7 +99,7 @@ it("skips a source-only candidate and highlights a displayed match", async () =>
clear() {},
});
try {
model.updateHistory("epoch", [message(1), message(2)]);
model.updateHistory("epoch", [message(1), message(2)], null);
model.open();
model.setQuery("target");
await expect.poll(() => model.getSnapshot().selectedItemId).toBe("row-2");
@@ -129,7 +129,7 @@ it("ignores replies after closing and does not navigate", async () => {
},
clear() {},
});
model.updateHistory("epoch", [message(1)]);
model.updateHistory("epoch", [message(1)], null);
model.open();
model.setQuery("target");
await expect.poll(() => calls).toEqual(["search"]);
@@ -155,7 +155,7 @@ it("retains a failed search for retry and distinguishes failure from zero matche
};
const model = new ChatFindModel(operations);
try {
model.updateHistory("epoch", []);
model.updateHistory("epoch", [], null);
model.open();
model.setQuery("target");
await expect.poll(() => model.getSnapshot().phase).toBe("error");
@@ -185,7 +185,7 @@ it("reports a failed timeline load as a connection failure", async () => {
clear() {},
});
try {
model.updateHistory("epoch", []);
model.updateHistory("epoch", [], null);
model.open();
model.setQuery("target");
await expect.poll(() => model.getSnapshot().phase).toBe("error");
@@ -207,24 +207,160 @@ it("reports a search result that outlived its epoch as a changed history", async
clear() {},
});
try {
model.updateHistory("epoch", [message(1)]);
model.updateHistory("epoch", [message(1)], null);
model.open();
model.setQuery("target");
await expect.poll(() => model.getSnapshot().phase).toBe("ready");
model.updateHistory("epoch-2", [message(1)]);
model.updateHistory("epoch-2", [message(1)], null);
expect(model.getSnapshot()).toMatchObject({ phase: "error", failure: "historyChanged" });
} finally {
model.close();
}
});
it("reports results that a rewind took messages from as a changed history", async () => {
const model = new ChatFindModel({
async search() {
return response([{ seq: 2, role: "assistant" }]);
},
async load() {},
async reveal() {
return { count: 1, occurrence: 0 };
},
clear() {},
});
try {
model.updateHistory("epoch", [message(1), message(2)], null);
model.open();
model.setQuery("target");
await expect.poll(() => model.getSnapshot().phase).toBe("ready");
expect(model.getSnapshot()).toMatchObject({ selectedItemId: "row-2", count: 1 });
// Another client rewinds the turn holding the match; the epoch stays.
model.updateHistory("epoch", [message(1)], 3);
expect(model.getSnapshot()).toMatchObject({
phase: "error",
failure: "historyChanged",
selectedItemId: null,
});
} finally {
model.close();
}
});
it("stops a search still receiving pages when a rewind lands between them", async () => {
let pages = 0;
let releaseSecondPage!: () => void;
const secondPage = new Promise<void>((resolve) => {
releaseSecondPage = resolve;
});
const model = new ChatFindModel({
async search(_query, cursor) {
pages += 1;
if (cursor === undefined)
return { ...response([{ seq: 2, role: "assistant" }]), nextCursor: 2 };
await secondPage;
return response([{ seq: 1, role: "assistant" }]);
},
async load() {},
async reveal() {
return { count: 1, occurrence: 0 };
},
clear() {},
});
try {
model.updateHistory("epoch", [message(1), message(2)], null);
model.open();
model.setQuery("target");
await expect.poll(() => pages).toBe(2);
model.updateHistory("epoch", [message(1)], 3);
releaseSecondPage();
await expect.poll(() => model.getSnapshot().failure).toBe("historyChanged");
expect(model.getSnapshot()).toMatchObject({ phase: "error", selectedItemId: null });
} finally {
model.close();
}
});
it.each([
["Next", "next"],
["Previous", "previous"],
] as const)("reports a rewind after %s as a changed history", async (_label, step) => {
const model = new ChatFindModel({
async search() {
return response([
{ seq: 1, role: "assistant" },
{ seq: 2, role: "assistant" },
]);
},
async load() {},
async reveal() {
return { count: 1, occurrence: 0 };
},
clear() {},
});
try {
model.updateHistory("epoch", [message(1), message(2)], null);
model.open();
model.setQuery("target");
await expect.poll(() => model.getSnapshot().phase).toBe("ready");
model[step]();
await expect.poll(() => model.getSnapshot().selectedItemId).toBe("row-2");
await expect.poll(() => model.getSnapshot().phase).toBe("ready");
model.updateHistory("epoch", [message(1)], 3);
expect(model.getSnapshot()).toMatchObject({
phase: "error",
failure: "historyChanged",
selectedItemId: null,
});
} finally {
model.close();
}
});
it("stops a pending Next when a rewind lands before it reveals", async () => {
let reveals = 0;
let revealSecond!: () => void;
const secondReveal = new Promise<void>((resolve) => {
revealSecond = resolve;
});
const model = new ChatFindModel({
async search() {
return response([
{ seq: 1, role: "assistant" },
{ seq: 2, role: "assistant" },
]);
},
async load() {},
async reveal() {
reveals += 1;
if (reveals > 1) await secondReveal;
return { count: 1, occurrence: 0 };
},
clear() {},
});
try {
model.updateHistory("epoch", [message(1), message(2)], null);
model.open();
model.setQuery("target");
await expect.poll(() => model.getSnapshot().phase).toBe("ready");
model.next();
await expect.poll(() => reveals).toBe(2);
model.updateHistory("epoch", [message(1)], 3);
revealSecond();
await expect.poll(() => model.getSnapshot().failure).toBe("historyChanged");
expect(model.getSnapshot()).toMatchObject({ phase: "error", selectedItemId: null });
} finally {
model.close();
}
});
it("can load a search location before the initial chat history has arrived", async () => {
const model = new ChatFindModel({
async search() {
return response([{ seq: 1, role: "assistant" }]);
},
async load() {
model.updateHistory("epoch", [message(1)]);
model.updateHistory("epoch", [message(1)], null);
},
async reveal() {
return { count: 1, occurrence: 0 };
@@ -232,7 +368,7 @@ it("can load a search location before the initial chat history has arrived", asy
clear() {},
});
try {
model.updateHistory(null, []);
model.updateHistory(null, [], null);
model.open();
model.setQuery("target");
await expect.poll(() => model.getSnapshot().phase).toBe("ready");
@@ -260,7 +396,7 @@ it("reports the position across the whole chat and corrects it with rendered cou
clear() {},
});
try {
model.updateHistory("epoch", [message(1), message(2)]);
model.updateHistory("epoch", [message(1), message(2)], null);
model.open();
model.setQuery("target");
await expect.poll(() => model.getSnapshot().phase).toBe("ready");
@@ -297,7 +433,7 @@ it("counts one match per message from a host that reports no counts", async () =
clear() {},
});
try {
model.updateHistory("epoch", [message(1), message(2)]);
model.updateHistory("epoch", [message(1), message(2)], null);
model.open();
model.setQuery("target");
await expect.poll(() => model.getSnapshot().phase).toBe("ready");
@@ -76,6 +76,10 @@ export class ChatFindModel {
private items: StreamItem[] = [];
private epoch: string | null = null;
private resultEpoch: string | null = null;
/** The latest rewind applied to the history. A rewind keeps the epoch and retires messages. */
private rewindSeq: number | null = null;
/** The latest rewind when the current result set's search started; undefined until a search starts. */
private searchedRewindSeq: number | null | undefined = undefined;
private locations: Location[] = [];
private resolved = new Map<Location, Target>();
private current = -1;
@@ -113,6 +117,7 @@ export class ChatFindModel {
this.cancel();
this.locations = [];
this.resultEpoch = null;
this.searchedRewindSeq = undefined;
this.resolved.clear();
this.current = -1;
this.inMessage = { occurrence: 0, count: 0 };
@@ -144,6 +149,7 @@ export class ChatFindModel {
});
}
private async search(signal: AbortSignal) {
this.searchedRewindSeq = this.rewindSeq;
try {
let cursor: number | undefined;
do {
@@ -168,15 +174,14 @@ export class ChatFindModel {
this.fail(error, signal);
}
}
updateHistory(epoch: string | null, items: StreamItem[]) {
if (
this.resultEpoch !== null &&
epoch !== null &&
epoch !== this.resultEpoch &&
this.state.open
) {
/** `rewindSeq` is the sequence number of the latest rewind applied to this history. */
updateHistory(epoch: string | null, items: StreamItem[], rewindSeq: number | null) {
const replaced = this.resultEpoch !== null && epoch !== null && epoch !== this.resultEpoch;
const rewound = this.searchedRewindSeq !== undefined && rewindSeq !== this.searchedRewindSeq;
if ((replaced || rewound) && this.state.open) {
this.fail(new ChatFindFailureError("historyChanged"), this.abort.signal);
}
this.rewindSeq = rewindSeq;
this.epoch = epoch;
this.items = items;
this.historyListeners.forEach((listener) => listener());
@@ -9,6 +9,8 @@ export interface ChatFindProps {
agentId: string;
serverId: string;
epoch: string | null;
/** The sequence number of the latest rewind applied to this history, if any. */
rewindSeq: number | null;
items: StreamItem[];
viewportRef: RefObject<StreamViewportHandle | null>;
revealLoadedMessage(messageId: string): boolean;
@@ -139,7 +139,7 @@ describe("useChatOutline", () => {
kind: "user_message",
text: "live prompt",
timestamp: new Date(seq),
timelineCursor: { epoch: "epoch-1", seq },
timelineCursor: { epoch: "epoch-1", seq, anchorSeq: seq },
});
rerender({ tail: [loadedPrompt(2)] });
await waitFor(() => expect(runtime.listAgentTimelinePrompts).toHaveBeenCalledTimes(2));
@@ -198,7 +198,7 @@ describe("useChatOutline", () => {
kind: "assistant_message",
text: "chunk",
timestamp: new Date(),
timelineCursor: { epoch: "epoch-1", seq: 2 },
timelineCursor: { epoch: "epoch-1", seq: 2, anchorSeq: 2 },
},
],
timelineEpoch: "epoch-1",
@@ -219,7 +219,7 @@ describe("useChatOutline", () => {
kind: "user_message",
text: "Replaced conversation",
timestamp: new Date(2),
timelineCursor: { epoch: "epoch-2", seq: 1 },
timelineCursor: { epoch: "epoch-2", seq: 1, anchorSeq: 1 },
},
],
timelineEpoch: "epoch-2",
@@ -275,7 +275,7 @@ describe("useChatOutline", () => {
kind: "user_message" as const,
text: "prompt",
timestamp: new Date(1),
timelineCursor: { epoch: "epoch-1", seq: 1 },
timelineCursor: { epoch: "epoch-1", seq: 1, anchorSeq: 1 },
},
];
const { result, rerender } = renderHook(
@@ -326,7 +326,7 @@ describe("useChatOutline", () => {
kind: "user_message" as const,
text: "prompt",
timestamp: new Date(1),
timelineCursor: { epoch: "epoch-1", seq: 1 },
timelineCursor: { epoch: "epoch-1", seq: 1, anchorSeq: 1 },
};
const { result, rerender } = renderHook(
({ tail, mountedHistoryMessageIds }) =>
@@ -381,7 +381,7 @@ describe("useChatOutline", () => {
kind: "user_message" as const,
text: "prompt",
timestamp: new Date(2),
timelineCursor: { epoch: "epoch-1", seq: 2 },
timelineCursor: { epoch: "epoch-1", seq: 2, anchorSeq: 2 },
};
const revealLoadedMessage = vi.fn();
revealLoadedMessage.mockReturnValue(false);
@@ -320,7 +320,7 @@ describe("stream presentation through installed plugins", () => {
const source = harness.source();
const live = source.head[0];
if (live?.kind !== "assistant_message") throw new Error("Expected a live assistant message");
const canonical = { ...live, timelineCursor: { epoch: "epoch-1", seq: 42 } };
const canonical = { ...live, timelineCursor: { epoch: "epoch-1", seq: 42, anchorSeq: 42 } };
const present = createStreamPresentation();
present({ ...presentationOptions, tail: [], head: [live], transform: undefined });
const repointed = rows(
@@ -330,7 +330,7 @@ describe("stream presentation through installed plugins", () => {
{
id: streamed[0]!.id,
text: "Streamed answer",
timelineCursor: { epoch: "epoch-1", seq: 42 },
timelineCursor: { epoch: "epoch-1", seq: 42, anchorSeq: 42 },
},
]);
});
@@ -517,7 +517,7 @@ describe("timeline presentation", () => {
messageId: "provider-message",
clientMessageId: "client-message",
turnId: "turn-1",
timelineCursor: { epoch: "epoch", seq: 12 },
timelineCursor: { epoch: "epoch", seq: 12, anchorSeq: 12 },
});
const presentMessage = () =>
present({
@@ -33,7 +33,7 @@ describe("resolveAssistantTurnForkBoundary", () => {
it("forks a failed assistant turn from its Paseo timeline cursor without a provider message id", () => {
const failedTurn = {
...assistantMessage("assistant-error", 2),
timelineCursor: { epoch: "timeline-1", seq: 42 },
timelineCursor: { epoch: "timeline-1", seq: 42, anchorSeq: 42 },
};
expect(
@@ -50,7 +50,7 @@ describe("resolveAssistantTurnForkBoundary", () => {
it("includes the provider message id with a supported timeline cursor", () => {
const selected = {
...assistantMessage("assistant-1", 2, "msg-assistant-1"),
timelineCursor: { epoch: "timeline-1", seq: 42 },
timelineCursor: { epoch: "timeline-1", seq: 42, anchorSeq: 42 },
};
expect(
@@ -68,7 +68,7 @@ describe("resolveAssistantTurnForkBoundary", () => {
it("falls back to the provider message id when timeline cursors are unsupported", () => {
const selected = {
...assistantMessage("assistant-1", 2, "msg-assistant-1"),
timelineCursor: { epoch: "timeline-1", seq: 42 },
timelineCursor: { epoch: "timeline-1", seq: 42, anchorSeq: 42 },
};
expect(
@@ -1,7 +1,13 @@
import type { StreamItem, TimelinePosition } from "@/types/stream";
import type { StreamItem } from "@/types/stream";
/** The daemon's timeline cursor; the row's anchor stays in the client. */
interface TimelineBoundaryCursor {
epoch: string;
seq: number;
}
export type AssistantTurnForkBoundary =
| { boundaryCursor: TimelinePosition; boundaryMessageId?: string }
| { boundaryCursor: TimelineBoundaryCursor; boundaryMessageId?: string }
| { boundaryCursor?: undefined; boundaryMessageId: string };
export function resolveAssistantTurnForkBoundary(input: {
@@ -15,7 +21,7 @@ export function resolveAssistantTurnForkBoundary(input: {
}
if (input.supportsTimelineCursor && item.timelineCursor) {
return {
boundaryCursor: item.timelineCursor,
boundaryCursor: { epoch: item.timelineCursor.epoch, seq: item.timelineCursor.seq },
...(item.messageId ? { boundaryMessageId: item.messageId } : {}),
};
}
@@ -140,7 +140,7 @@ describe("collapseTurns", () => {
it("stands at its host's timeline position, taking a new row when that position lands", () => {
const live = collapse([user("u1"), shell("c1"), reply("r1")]);
expect(live.summaryRowsByHostId.get("u1")).not.toHaveProperty("timelineCursor");
const cursor = { epoch: "epoch-1", seq: 7 };
const cursor = { epoch: "epoch-1", seq: 7, anchorSeq: 7 };
const placed = [{ ...user("u1"), timelineCursor: cursor }, shell("c1"), reply("r1")];
const canonical = collapse(placed, { previousSummaryRowsByHostId: live.summaryRowsByHostId });
expect(canonical.summaryRowsByHostId.get("u1")).not.toBe(live.summaryRowsByHostId.get("u1"));
+5
View File
@@ -406,6 +406,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
const timelineEpoch = useSessionStore(
(state) => state.sessions[resolvedServerId]?.agentTimelineCursor.get(agentId)?.epoch ?? null,
);
const timelineRewindSeq = useSessionStore((state) => {
const rewind = state.sessions[resolvedServerId]?.agentTimelineLatestRewind.get(agentId);
return rewind && rewind.epoch === timelineEpoch ? rewind.seq : null;
});
const isTimelineDetached = useSessionStore(
(state) => state.sessions[resolvedServerId]?.agentTimelineHasNewer.get(agentId) === true,
);
@@ -1160,6 +1164,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
agentId={agentId}
serverId={resolvedServerId}
epoch={timelineEpoch}
rewindSeq={timelineRewindSeq}
items={findItems}
viewportRef={viewportRef}
revealLoadedMessage={revealLoadedMessage}
@@ -1,4 +1,4 @@
import { memo, useCallback, useMemo } from "react";
import { memo, useCallback, useMemo, useState } from "react";
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import { ExternalLink, Gift } from "lucide-react-native";
import { useTranslation } from "react-i18next";
@@ -156,9 +156,19 @@ interface ReleaseViewProps {
isCurrent: boolean;
}
/**
* A release that has highlights shows only them until the reader asks for the
* sections, which are the full record. A release without highlights has nothing
* to stand in for its sections, so it shows them as it always did.
*/
const ReleaseView = memo(function ReleaseView({ release, isCurrent }: ReleaseViewProps) {
const { t } = useTranslation();
const date = formatChangelogDate(release.date);
const [showingAllChanges, setShowingAllChanges] = useState(false);
const toggleAllChanges = useCallback(() => setShowingAllChanges((value) => !value), []);
const allChangesState = useMemo(() => ({ expanded: showingAllChanges }), [showingAllChanges]);
const hasHighlights = release.highlights.length > 0;
const showsSections = !hasHighlights || showingAllChanges;
return (
<View style={styles.release} testID={`changelog-release-${release.version}`}>
@@ -168,26 +178,40 @@ const ReleaseView = memo(function ReleaseView({ release, isCurrent }: ReleaseVie
<View style={styles.headingSpacer} />
{date ? <Text style={styles.date}>{date}</Text> : null}
</View>
{keyChangelogSections(release.sections).map(({ key, section }) => (
<View key={key} style={styles.section}>
{section.title ? <Text style={styles.sectionTitle}>{section.title}</Text> : null}
{section.body ? <MarkdownRenderer text={section.body} compact /> : null}
</View>
))}
{hasHighlights ? <MarkdownRenderer text={release.highlights} compact /> : null}
{hasHighlights && release.sections.length > 0 ? (
<Button
variant="ghost"
style={styles.allChangesToggle}
accessibilityState={allChangesState}
onPress={toggleAllChanges}
>
{t(showingAllChanges ? "changelog.hideAllChanges" : "changelog.showAllChanges")}
</Button>
) : null}
{showsSections ? <ReleaseSections sections={release.sections} /> : null}
</View>
);
});
function ReleaseSections({ sections }: { sections: readonly ChangelogSection[] }) {
return keyChangelogSections(sections).map(({ key, section }) => (
<View key={key} style={styles.section}>
{section.title ? <Text style={styles.sectionTitle}>{section.title}</Text> : null}
{section.body ? <MarkdownRenderer text={section.body} compact /> : null}
</View>
));
}
/** A release can repeat a section title; the occurrence keeps the key stable. */
function keyChangelogSections(
sections: readonly ChangelogSection[],
): { key: string; section: ChangelogSection }[] {
const seen = new Map<string, number>();
return sections.map((section) => {
const title = section.title ?? "";
const occurrence = seen.get(title) ?? 0;
seen.set(title, occurrence + 1);
return { key: `${title}:${occurrence}`, section };
const occurrence = seen.get(section.title) ?? 0;
seen.set(section.title, occurrence + 1);
return { key: `${section.title}:${occurrence}`, section };
});
}
@@ -236,6 +260,12 @@ const styles = StyleSheet.create((theme) => ({
textTransform: "uppercase",
letterSpacing: 0.5,
},
// Flush with the highlights above it, so the label's ink sits on their rail.
allChangesToggle: {
alignSelf: "flex-start",
paddingHorizontal: 0,
borderWidth: 0,
},
showMore: {
alignSelf: "center",
},
@@ -8,15 +8,15 @@ import {
type ChangelogSection,
} from "./parse-changelog";
function bodies(markdown: string, releaseIndex = 0): string[] {
return parseChangelog(markdown)[releaseIndex].sections.map(sectionBody);
function bodies(markdown: string): string[] {
return parseChangelog(markdown)[0].sections.map(sectionBody);
}
function sectionBody(section: ChangelogSection): string {
return section.body;
}
function sectionTitle(section: ChangelogSection): string | null {
function sectionTitle(section: ChangelogSection): string {
return section.title;
}
@@ -25,17 +25,36 @@ function releaseDate(release: ChangelogRelease): string {
}
function authoredLines(release: ChangelogRelease): string[] {
return release.sections.flatMap(sectionLines);
return [...release.highlights.split("\n"), ...release.sections.flatMap(sectionLines)];
}
function sectionLines(section: ChangelogSection): string[] {
return [...(section.title ? [`### ${section.title}`] : []), ...section.body.split("\n")];
return [`### ${section.title}`, ...section.body.split("\n")];
}
function isNotBlank(line: string): boolean {
return line.trim().length > 0;
}
function readRepositoryFile(path: string): string {
return readFileSync(fileURLToPath(new URL(`../../../../../${path}`, import.meta.url)), "utf8");
}
/** The cases the app, the website and the release scripts must all split the same way. */
interface ChangelogStructureCases {
document: string[];
releases: {
version: string;
date: string;
highlights: string[];
sectionTitles: string[];
}[];
}
const structureCases: ChangelogStructureCases = JSON.parse(
readRepositoryFile("scripts/test-support/changelog-structure.json"),
);
describe("parseChangelog", () => {
it("reads the shape the changelog is authored in today", () => {
const releases = parseChangelog(
@@ -44,7 +63,8 @@ describe("parseChangelog", () => {
"",
"## 0.8.0-beta.1 - 2026-09-08",
"",
"This release extends what plugins can do.",
"- **Plugin providers:** Plugins can add providers.",
"- **Faster diffs:** Diffs load sooner.",
"",
"### Plugins",
"",
@@ -67,8 +87,9 @@ describe("parseChangelog", () => {
{
version: "0.8.0-beta.1",
date: "2026-09-08",
highlights:
"- **Plugin providers:** Plugins can add providers.\n- **Faster diffs:** Diffs load sooner.",
sections: [
{ title: null, body: "This release extends what plugins can do." },
{
title: "Plugins",
body: "- Added provider contributions ([#4314](https://example.com/4314))",
@@ -79,6 +100,7 @@ describe("parseChangelog", () => {
{
version: "0.7.2",
date: "2026-09-02",
highlights: "",
sections: [{ title: "Fixed", body: "- Fixed a thing" }],
},
]);
@@ -89,7 +111,7 @@ describe("parseChangelog", () => {
"# Changelog\n\nAll notable changes.\n\n## 1.0.0 - 2026-01-01\n\n- One\n",
);
expect(releases).toHaveLength(1);
expect(releases[0].sections).toEqual([{ title: null, body: "- One" }]);
expect(releases[0]).toMatchObject({ highlights: "- One", sections: [] });
});
it("returns nothing for a document with no releases", () => {
@@ -136,13 +158,13 @@ describe("parseChangelog", () => {
it("does not treat a deeper heading as a release", () => {
const releases = parseChangelog("## 1.0.0 - 2026-01-01\n\n#### Deep\n\n- One\n");
expect(releases).toHaveLength(1);
expect(releases[0].sections).toEqual([{ title: null, body: "#### Deep\n\n- One" }]);
expect(releases[0].highlights).toBe("#### Deep\n\n- One");
});
it("does not treat an unspaced hash run as a heading", () => {
const releases = parseChangelog("## 1.0.0 - 2026-01-01\n\n##notaheading\n");
expect(releases).toHaveLength(1);
expect(releases[0].sections[0].body).toBe("##notaheading");
expect(releases[0].highlights).toBe("##notaheading");
});
});
@@ -162,14 +184,34 @@ describe("parseChangelog", () => {
]);
});
it("drops an empty lead section", () => {
it("keeps a bare section heading as an untitled section", () => {
const releases = parseChangelog("## 1.0.0 - 2026-01-01\n\n###\n\n- One\n");
expect(releases[0].sections).toEqual([{ title: "", body: "- One" }]);
});
});
describe("highlights", () => {
it("has none when the release opens with a section", () => {
const releases = parseChangelog("## 1.0.0 - 2026-01-01\n\n### Fixed\n\n- One\n");
expect(releases[0].sections).toEqual([{ title: "Fixed", body: "- One" }]);
expect(releases[0]).toMatchObject({
highlights: "",
sections: [{ title: "Fixed", body: "- One" }],
});
});
it("reads the summary paragraphs older entries opened with as highlights", () => {
const releases = parseChangelog(
"## 1.0.0 - 2026-01-01\n\nDaemon restarts keep terminals.\n\nMore here.\n\n### Added\n\n- One\n",
);
expect(releases[0]).toMatchObject({
highlights: "Daemon restarts keep terminals.\n\nMore here.",
sections: [{ title: "Added", body: "- One" }],
});
});
it("keeps a release that is only prose", () => {
const releases = parseChangelog("## 1.0.0 - 2026-01-01\n\nJust a note.\n");
expect(releases[0].sections).toEqual([{ title: null, body: "Just a note." }]);
expect(releases[0]).toMatchObject({ highlights: "Just a note.", sections: [] });
});
});
@@ -256,7 +298,12 @@ describe("parseChangelog", () => {
it("reads CRLF documents", () => {
const releases = parseChangelog("## 1.0.0 - 2026-01-01\r\n\r\n### Fixed\r\n\r\n- One\r\n");
expect(releases).toEqual([
{ version: "1.0.0", date: "2026-01-01", sections: [{ title: "Fixed", body: "- One" }] },
{
version: "1.0.0",
date: "2026-01-01",
highlights: "",
sections: [{ title: "Fixed", body: "- One" }],
},
]);
});
});
@@ -272,19 +319,45 @@ describe("formatChangelogDate", () => {
});
});
describe("the structure every changelog surface shares", () => {
for (const lineEnding of ["\n", "\r\n"]) {
it(`splits the shared cases with ${JSON.stringify(lineEnding)} line endings`, () => {
const releases = parseChangelog(structureCases.document.join(lineEnding));
expect(
releases.map((release) => ({
version: release.version,
date: release.date,
highlights: release.highlights.split("\n").filter(isNotBlank),
sectionTitles: release.sections.map(sectionTitle),
})),
).toEqual(
structureCases.releases.map((release) => ({
version: release.version,
date: release.date,
highlights: release.highlights.filter(isNotBlank),
sectionTitles: release.sectionTitles,
})),
);
});
}
});
describe("the repository's own CHANGELOG.md", () => {
const markdown = readFileSync(
fileURLToPath(new URL("../../../../../CHANGELOG.md", import.meta.url)),
"utf8",
);
const markdown = readRepositoryFile("CHANGELOG.md");
const releases = parseChangelog(markdown);
it("finds a release for every release heading line", () => {
const headingLines = markdown.split("\n").filter((line) => line.startsWith("## "));
expect(releases).toHaveLength(headingLines.length);
});
it("parses every release heading", () => {
expect(releases.length).toBeGreaterThan(50);
for (const release of releases) {
expect(release.version).toMatch(/^\d+\.\d+\.\d+/);
expect(release.date).toMatch(/^\d{4}-\d{2}-\d{2}$/);
expect(release.sections.length).toBeGreaterThan(0);
expect(release.highlights.length + release.sections.length).toBeGreaterThan(0);
}
});
@@ -3,7 +3,10 @@
*
* The document is authored in the repository, fetched at runtime, and rewritten
* on every release, so this parser commits to exactly two facts: a `##` heading
* starts a release and a `###` heading starts a section inside it. Everything
* starts a release and a `###` heading starts a section inside it. Whatever sits
* between the release heading and its first section is the release's
* highlights: the list a release now leads with, or the summary paragraphs and
* notices older entries opened with. Everything
* else — prose, callouts, block quotes, fenced code, images, video and embed
* HTML, tables, nested lists — is carried through verbatim and handed to the
* Markdown renderer. A new block kind in the changelog therefore needs no
@@ -17,8 +20,8 @@
*/
export interface ChangelogSection {
/** The `###` heading text, or null for content that precedes the first one. */
title: string | null;
/** The `###` heading text. Empty for a bare `###`. */
title: string;
/** Verbatim markdown under the heading. */
body: string;
}
@@ -28,7 +31,9 @@ export interface ChangelogRelease {
version: string;
/** The heading tail after the version. Empty when the heading carried none. */
date: string;
/** Sections in document order; the untitled lead section comes first. */
/** Verbatim markdown before the first section. Empty when the release opens with one. */
highlights: string;
/** The `###` sections in document order. */
sections: ChangelogSection[];
}
@@ -46,13 +51,14 @@ const LEADING_V = /^v(?=\d)/i;
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
interface SectionDraft {
title: string | null;
title: string;
lines: string[];
}
interface ReleaseDraft {
version: string;
date: string;
highlights: string[];
sections: SectionDraft[];
}
@@ -75,7 +81,8 @@ export function parseChangelog(markdown: string): ChangelogRelease[] {
if (releaseHeading) {
releases.push({
...splitReleaseHeading(releaseHeading[1] ?? ""),
sections: [{ title: null, lines: [] }],
highlights: [],
sections: [],
});
continue;
}
@@ -83,7 +90,7 @@ export function parseChangelog(markdown: string): ChangelogRelease[] {
const sectionHeading = line.match(SECTION_HEADING);
if (sectionHeading && releases.length > 0) {
releases[releases.length - 1].sections.push({
title: (sectionHeading[1] ?? "").replace(ATX_CLOSING, "").trim() || null,
title: (sectionHeading[1] ?? "").replace(ATX_CLOSING, "").trim(),
lines: [],
});
continue;
@@ -91,19 +98,22 @@ export function parseChangelog(markdown: string): ChangelogRelease[] {
}
const release = releases.at(-1);
if (release) release.sections[release.sections.length - 1].lines.push(line);
if (release) (release.sections.at(-1)?.lines ?? release.highlights).push(line);
}
return releases.map(materializeRelease);
}
function materializeRelease(draft: ReleaseDraft): ChangelogRelease {
const sections = draft.sections
.map((section) => ({ title: section.title, body: trimBlankLines(section.lines) }))
// A heading with an empty body still names something; an empty lead section
// is just the gap between the release heading and the first section.
.filter((section) => section.title !== null || section.body.length > 0);
return { version: draft.version, date: draft.date, sections };
return {
version: draft.version,
date: draft.date,
highlights: trimBlankLines(draft.highlights),
sections: draft.sections.map((section) => ({
title: section.title,
body: trimBlankLines(section.lines),
})),
};
}
function trimBlankLines(lines: string[]): string {
@@ -28,6 +28,7 @@ import { isWeb } from "@/constants/platform";
import { useKeyboardVisibility } from "@/hooks/use-keyboard-visibility";
import { SafeAreaInsetsContext, useSafeAreaInsets } from "react-native-safe-area-context";
import { AdaptiveTextInput } from "@/components/adaptive-text-input";
import { useDismissibleOverlay } from "@/components/ui/overlay-dismissal";
export { AdaptiveTextInput, type AdaptiveTextInputProps } from "@/components/adaptive-text-input";
// Horizontal indent token shared by the sheet header (title, back arrow,
@@ -654,6 +655,9 @@ export function AdaptiveModalSheet({
const isMobile = useIsCompactFormFactor();
const isKeyboardVisible = useKeyboardVisibility(visible);
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
const isSheet = isMobile || !isWeb;
// The sheet registers itself; the desktop dialog does not use it.
useDismissibleOverlay(visible && !isSheet, onClose);
const footerView = useMemo(
() => (footer ? <SheetFooter style={footerContainerStyle}>{footer}</SheetFooter> : null),
[footer, footerContainerStyle],
@@ -668,7 +672,7 @@ export function AdaptiveModalSheet({
const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({
visible,
isEnabled: isMobile || !isWeb,
isEnabled: isSheet,
onClose,
});
const [shouldRenderWeb, setShouldRenderWeb] = useState(visible);
@@ -6,6 +6,7 @@ import { FOOTER_HEIGHT } from "@/constants/layout";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { KeyboardTranslateView } from "@/keyboard/shift";
import { Button } from "@/components/ui/button";
import { useToast } from "@/contexts/toast-context";
import type { Theme } from "@/styles/theme";
import { toErrorMessage } from "@/utils/error-messages";
@@ -18,6 +19,7 @@ export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCallout
const { t } = useTranslation();
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const toast = useToast();
const [isUnarchiving, setIsUnarchiving] = useState(false);
const [unarchiveError, setUnarchiveError] = useState<string | null>(null);
@@ -26,12 +28,15 @@ export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCallout
setIsUnarchiving(true);
setUnarchiveError(null);
try {
await client.refreshAgent(agentId);
const refreshed = await client.refreshAgent(agentId);
if (refreshed.historyDiverged) {
toast.show(t("workspace.tabs.toasts.reloadedAgentHistoryDiverged"), { variant: "warning" });
}
} catch (error) {
setUnarchiveError(toErrorMessage(error));
setIsUnarchiving(false);
}
}, [client, isConnected, isUnarchiving, agentId]);
}, [client, isConnected, isUnarchiving, agentId, toast, t]);
return (
<KeyboardTranslateView style={styles.container}>
@@ -305,7 +305,7 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center",
backgroundColor: "transparent",
gap: theme.spacing[1],
...toolbarContentSlot(theme.spacing[2]),
...toolbarContentSlot(),
borderRadius: theme.borderRadius.full,
},
triggerHovered: {
@@ -338,7 +338,6 @@ const styles = StyleSheet.create((theme) => ({
customTriggerWrapper: {
paddingHorizontal: 0,
paddingVertical: 0,
marginHorizontal: 0,
height: "auto",
},
triggerFill: {
@@ -9,6 +9,7 @@ import { useRewindComposerRestore } from "./composer-restore";
import { useSessionStore } from "@/stores/session-store";
import { shouldRestoreComposerForRewindMode } from "./rewind-mode";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { hostSupportsFeature } from "@/runtime/host-features";
interface UseRewindAgentMutationInput {
serverId?: string;
@@ -35,7 +36,16 @@ export function useRewindAgentMutation(input: UseRewindAgentMutationInput): {
throw new Error(t("common.errors.daemonClientUnavailable"));
}
await input.client.rewindAgent(input.agentId, input.messageId, mode);
if (mode !== "files") {
// COMPAT(timelineRewindOperations): added in v0.14.1, remove after 2027-05-10. A daemon with
// the feature delivers the rewind on the live timeline; an older one replaced the timeline
// and this client has to read it again.
const appliesLive =
input.serverId !== undefined &&
hostSupportsFeature(
useSessionStore.getState().sessions[input.serverId]?.serverInfo,
"timelineRewindOperations",
);
if (mode !== "files" && !appliesLive) {
const cursor = input.serverId
? useSessionStore
.getState()
+48 -1
View File
@@ -1,5 +1,12 @@
import { describe, expect, it } from "vitest";
import { computePosition, fitSurfaceWidth, getTransformOrigin, type Rect } from "./anchor";
import {
computePosition,
fitSurfaceWidth,
getTransformOrigin,
holdSurfaceEdge,
surfaceFrameStyles,
type Rect,
} from "./anchor";
const DISPLAY: Rect = { x: 0, y: 0, width: 1000, height: 800 };
const TRIGGER: Rect = { x: 100, y: 100, width: 40, height: 20 };
@@ -78,6 +85,46 @@ describe("computePosition", () => {
});
});
/**
* Where a surface's edges land when its content has resized but it has not been placed again,
* the frame native paints before the next placement arrives. Resolves the frame styles the way
* layout does: a top-held surface grows down from its top, a bottom-held one up from the
* holder's bottom.
*/
function edgesBeforeReplacement(placedHeight: number, paintedHeight: number) {
const placed = position({ placement: "top", triggerRect: { ...TRIGGER, y: 400 } });
const { holder, surface } = surfaceFrameStyles(holdSurfaceEdge(placed, placedHeight));
const bottom =
typeof surface.bottom === "number"
? Number(holder.height) - surface.bottom
: Number(surface.top) + paintedHeight;
return { top: bottom - paintedHeight, bottom };
}
describe("surfaceFrameStyles", () => {
it("keeps a surface above its trigger off the trigger when its content grows", () => {
// Trigger top 400, offset 4: the surface ends at 396 however tall it becomes.
expect(edgesBeforeReplacement(CONTENT.height, CONTENT.height)).toEqual({
top: 296,
bottom: 396,
});
expect(edgesBeforeReplacement(CONTENT.height, 260)).toEqual({ top: 136, bottom: 396 });
});
it("keeps a surface above its trigger against the trigger when its content shrinks", () => {
expect(edgesBeforeReplacement(CONTENT.height, 40)).toEqual({ top: 356, bottom: 396 });
});
it("holds a surface below its trigger by its top edge", () => {
const { surface } = surfaceFrameStyles(holdSurfaceEdge(position(), CONTENT.height));
expect(surface).toMatchObject({ position: "absolute", top: 124, left: 100 });
});
it("waits off-screen until it is placed", () => {
expect(surfaceFrameStyles(null).surface).toMatchObject({ top: -9999, left: -9999 });
});
});
describe("getTransformOrigin", () => {
it.each([
["bottom", "start", [0, 0, 0]],
+52 -3
View File
@@ -1,8 +1,8 @@
import type { View } from "react-native";
import type { View, ViewStyle } from "react-native";
/**
* Where a floating surface goes relative to the thing that opened it. Menus and hover cards
* place themselves with it.
* Where a floating surface goes relative to the thing that opened it. Menus, hover cards, and
* tooltips place themselves with it.
*/
export type Placement = "top" | "bottom" | "left" | "right";
@@ -177,6 +177,55 @@ export function computePosition({
return { ...clampToDisplayArea({ ...anchored, contentSize, displayArea }), actualPlacement };
}
/** Where a placed surface sits: its left edge, and the edge that holds it vertically. */
export type SurfacePosition = { left: number; top: number } | { left: number; bottomEdge: number };
/**
* A surface above its trigger is held by its bottom edge, the edge that faces the trigger. When
* its content grows after it paints, it extends upward, away from the trigger. Held by its top,
* it would paint over the trigger until the next placement lands, a frame later on native.
*/
export function holdSurfaceEdge(
placed: { x: number; y: number; actualPlacement: Placement },
contentHeight: number,
): SurfacePosition {
if (placed.actualPlacement === "top") {
return { left: placed.x, bottomEdge: placed.y + contentHeight };
}
return { left: placed.x, top: placed.y };
}
/**
* Styles for a surface and the holder it sits in. Both fill the display area's width, so the
* surface lays out exactly as it would without the holder. Render the holder with the
* `pointerEvents="box-none"` prop so presses outside the surface reach what is behind it;
* react-native-web drops `box-none` given as an inline style. A bottom-held surface sits on the
* bottom of a holder that ends at its bottom edge, so layout keeps that edge in place however
* the content changes. Until it is placed, the surface waits off-screen to be measured.
*/
export function surfaceFrameStyles(position: SurfacePosition | null): {
holder: ViewStyle;
surface: ViewStyle;
} {
const fill: ViewStyle = { position: "absolute", top: 0, left: 0, right: 0 };
if (!position) {
return {
holder: { ...fill, bottom: 0 },
surface: { position: "absolute", top: -9999, left: -9999 },
};
}
if ("top" in position) {
return {
holder: { ...fill, bottom: 0 },
surface: { position: "absolute", top: position.top, left: position.left },
};
}
return {
holder: { ...fill, height: position.bottomEdge },
surface: { position: "absolute", bottom: 0, left: position.left },
};
}
export function getTransformOrigin(
placement: Placement,
alignment: Alignment,
@@ -63,6 +63,7 @@ import {
} from "@/components/adaptive-modal-sheet";
import { FloatingSurface } from "@/components/ui/floating";
import { useDismissKeyboardOnOpen } from "@/components/ui/keyboard-dismiss";
import { useDismissibleOverlay } from "@/components/ui/overlay-dismissal";
import { getOverlayRoot, OverlayLayerProvider, useOverlayLayer } from "@/lib/overlay-root";
import { density, densityIconBox } from "@/styles/density";
import { DensityIcon } from "@/components/ui/density-icon";
@@ -1369,6 +1370,7 @@ export function Combobox({
useEffect(() => {
if (!active && isOpen) handleClose();
}, [active, isOpen, handleClose]);
useDismissibleOverlay(isOpen, handleClose);
useResetSearchOnOpen(isOpen, setSearchQueryWithCallback, bumpSearchResetKey);
@@ -119,15 +119,10 @@ describe("control sizing gate", () => {
});
describe("toolbar glyphs and spacing", () => {
it("spaces touch toolbar controls by what they paint and keeps the pointer's 4px gap", () => {
expect(TOOLBAR_CONTROL_GAPS).toEqual({ touch: 20, pointer: 4 });
expect(TOOLBAR_CONTROL_GAP).toEqual({ xs: 20, md: 4 });
// A 20pt glyph's disc-sized frame reaches 8 into the gap on each side.
expect(toolbarSlot(20)).toEqual({
width: TOOLBAR_DISC_SIZE,
height: TOOLBAR_DISC_SIZE,
marginHorizontal: { xs: -8, md: 0 },
});
it("lays touch toolbar controls out at their highlight with no gap and keeps the pointer's 4px gap", () => {
expect(TOOLBAR_CONTROL_GAPS).toEqual({ touch: 0, pointer: 4 });
expect(TOOLBAR_CONTROL_GAP).toEqual({ xs: 0, md: 4 });
expect(toolbarSlot()).toEqual({ width: TOOLBAR_DISC_SIZE, height: TOOLBAR_DISC_SIZE });
});
it("paints touch toolbar glyphs at the header icons' 20pt and 1.5px stroke and leaves pointer unchanged", () => {
@@ -101,47 +101,23 @@ export const TOOLBAR_DISC_SIZES = { touch: 36, pointer: 28 } satisfies SizingVal
export const TOOLBAR_DISC_SIZE = bySizing(TOOLBAR_DISC_SIZES);
/**
* The space between adjacent toolbar controls. Touch-sized it is the visible space between what
* the controls paint, because each control lays out at its painted width (`toolbarSlot`).
* The space between adjacent toolbar controls. Touch-sized there is none: each control lays out at
* its fully rounded highlight, so neighbouring highlights touch and never overlap.
*/
export const TOOLBAR_CONTROL_GAPS = { touch: 20, pointer: 4 } satisfies SizingValue;
export const TOOLBAR_CONTROL_GAPS = { touch: 0, pointer: 4 } satisfies SizingValue;
export const TOOLBAR_CONTROL_GAP = bySizing(TOOLBAR_CONTROL_GAPS);
/**
* A toolbar control's frame: a disc-sized circle, which is also its fully rounded highlight.
* Touch-sized it lays out at the width of the glyph it paints, `shapeWidth`, and the circle reaches
* into the gaps on either side, so the row spaces what you see. Pointer-sized it is the square
* control.
*/
export function toolbarSlot(shapeWidth: number): {
width: LayoutValue;
height: LayoutValue;
marginHorizontal: LayoutValue;
} {
return {
width: TOOLBAR_DISC_SIZE,
height: TOOLBAR_DISC_SIZE,
marginHorizontal: bySizing({
touch: (shapeWidth - TOOLBAR_DISC_SIZES.touch) / 2,
pointer: 0,
}),
};
/** A labelled toolbar control's padding on each side of its content. */
export const TOOLBAR_LABEL_PADDING = 8;
/** An icon-only toolbar control's frame: a disc-sized circle, which is also its highlight. */
export function toolbarSlot(): { width: LayoutValue; height: LayoutValue } {
return { width: TOOLBAR_DISC_SIZE, height: TOOLBAR_DISC_SIZE };
}
/**
* A toolbar control as wide as its content, such as a labelled picker: a disc-tall pill. Touch-sized
* its padding reaches into the gaps, so its content is what the row spaces.
*/
export function toolbarContentSlot(padding: number): {
height: LayoutValue;
paddingHorizontal: number;
marginHorizontal: LayoutValue;
} {
return {
height: TOOLBAR_DISC_SIZE,
paddingHorizontal: padding,
marginHorizontal: bySizing({ touch: -padding, pointer: 0 }),
};
/** A toolbar control as wide as its content, such as a labelled picker: a disc-tall pill. */
export function toolbarContentSlot(): { height: LayoutValue; paddingHorizontal: number } {
return { height: TOOLBAR_DISC_SIZE, paddingHorizontal: TOOLBAR_LABEL_PADDING };
}
/**
+21 -10
View File
@@ -322,6 +322,17 @@ export function HoverCardContent(props: HoverCardContentProps): ReactElement | n
);
}
// A card above its trigger holds its bottom edge, so content that loads while it is open grows it
// upward without a correction. Anchored by its top, it would paint over the trigger until placed.
type CardPosition = { left: number; top: number } | { left: number; bottom: number };
function isSamePosition(current: CardPosition | null, next: CardPosition): boolean {
if (!current || current.left !== next.left) return false;
return "top" in current
? "top" in next && current.top === next.top
: "bottom" in next && current.bottom === next.bottom;
}
function readDomRect(view: View | null): Rect | null {
const element = view as unknown as HTMLElement | null;
if (!element) return null;
@@ -349,7 +360,7 @@ function HoverCardSurface({
contentRef: RefObject<View | null>;
}): ReactElement {
const overlayRef = useRef<View>(null);
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
const [position, setPosition] = useState<CardPosition | null>(null);
const place = useCallback(() => {
const triggerRect = readDomRect(triggerRef.current);
@@ -357,7 +368,7 @@ function HoverCardSurface({
const content = contentRef.current as unknown as HTMLElement | null;
if (!triggerRect || !overlayRect || !content) return;
const { width, height } = Dimensions.get("window");
const { x, y } = computePosition({
const { x, y, actualPlacement } = computePosition({
triggerRect,
contentSize: { width: content.offsetWidth, height: content.offsetHeight },
displayArea: { x: 0, y: 0, width, height },
@@ -366,19 +377,19 @@ function HoverCardSurface({
alignment,
offset,
});
const next = { x: x - overlayRect.x, y: y - overlayRect.y };
setPosition((current) => (current?.x === next.x && current.y === next.y ? current : next));
const left = x - overlayRect.x;
const next: CardPosition =
actualPlacement === "top"
? { left, bottom: overlayRect.height - (y + content.offsetHeight - overlayRect.y) }
: { left, top: y - overlayRect.y };
setPosition((current) => (isSamePosition(current, next) ? current : next));
}, [alignment, contentRef, offset, placement, triggerRef]);
useLayoutEffect(place, [place]);
const frameStyle = useMemo(
() => ({
position: "absolute" as const,
top: position?.y ?? -9999,
left: position?.x ?? -9999,
}),
[position?.x, position?.y],
() => ({ position: "absolute" as const, ...(position ?? { top: -9999, left: -9999 }) }),
[position],
);
const surfaceStyle = useMemo(() => [styles.surface, style], [style]);
@@ -5,6 +5,7 @@ import {
} from "@gorhom/bottom-sheet";
import React from "react";
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef } from "react";
import { useDismissibleOverlay } from "@/components/ui/overlay-dismissal";
import type { ElementRef, ReactNode } from "react";
import { systemBackPress } from "./back-press";
import {
@@ -163,6 +164,7 @@ export function useIsolatedBottomSheetVisibility({
useEffect(() => {
tracker.syncDesired({ visible, isEnabled });
}, [isEnabled, tracker, visible]);
useDismissibleOverlay(visible && isEnabled !== false, onClose);
return {
sheetRef: setSheetRef,
@@ -11,6 +11,7 @@ import {
type MenuPath,
} from "./menu-navigation";
import type { Rect } from "../anchor";
import { useDismissibleOverlay } from "../overlay-dismissal";
/**
* How a menu draws itself once it is open.
@@ -117,6 +118,7 @@ export function useMenuState({
const [isOpen, setIsOpenState] = useControllableOpenState({ open, defaultOpen, onOpenChange });
useDismissKeyboardOnOpen(isOpen);
useDismissibleOverlay(isOpen, () => setOpen(false));
// A menu always reopens on its root page, against a freshly measured anchor. Keeping either
// would reopen the surface three levels deep, or at the coordinates of the last right click.
@@ -31,11 +31,14 @@ import {
computePosition,
fitSurfaceWidth,
getTransformOrigin,
holdSurfaceEdge,
measureElement,
surfaceFrameStyles,
type Alignment,
type Placement,
type Rect,
type Size,
type SurfacePosition,
} from "../anchor";
const SCROLL_CONTENT_STYLE = { flexGrow: 1 } as const;
@@ -109,7 +112,7 @@ export function useAnchoredPosition({
}) {
const [triggerRect, setTriggerRect] = useState<Rect | null>(null);
const [contentSize, setContentSize] = useState<Size | null>(null);
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
const [position, setPosition] = useState<SurfacePosition | null>(null);
const [actualPlacement, setActualPlacement] = useState<Placement>(side);
const visibleContentSize = useMemo(() => {
@@ -174,7 +177,7 @@ export function useAnchoredPosition({
offset,
});
setPosition({ x: result.x, y: result.y });
setPosition(holdSurfaceEdge(result, visibleContentSize.height));
setActualPlacement(result.actualPlacement);
}, [triggerRect, visibleContentSize, side, align, offset]);
@@ -280,6 +283,7 @@ export function AnchoredSurface({
return () => cancelAnimationFrame(frame);
}, [open, placed, surfaceNativeID]);
const frame = useMemo(() => surfaceFrameStyles(position), [position]);
const frameStyle = useMemo<StyleProp<ViewStyle>>(() => {
const { width: screenWidth } = Dimensions.get("window");
const resolvedWidthStyle: ViewStyle = fullWidth
@@ -287,10 +291,9 @@ export function AnchoredSurface({
: fitSurfaceWidth({ width, minWidth, maxWidth }, screenWidth);
return [
resolvedWidthStyle,
frame.surface,
{
position: "absolute" as const,
top: position?.y ?? -9999,
left: fullWidth ? horizontalPadding : (position?.x ?? -9999),
...(fullWidth ? { left: horizontalPadding } : null),
transformOrigin: getTransformOrigin(actualPlacement, align),
},
];
@@ -300,8 +303,7 @@ export function AnchoredSurface({
width,
minWidth,
maxWidth,
position?.x,
position?.y,
frame.surface,
actualPlacement,
align,
]);
@@ -343,40 +345,42 @@ export function AnchoredSurface({
testID={testID ? `${testID}-backdrop` : undefined}
/>
) : null}
<FloatingSurface
key={placed ? "placed" : "measuring"}
collapsable={false}
tabIndex={-1}
nativeID={surfaceNativeID}
testID={testID}
dataSet={surfaceDataSet}
style={styles.content}
frameStyle={frameStyle}
entering={placed ? nativeContentEntering : undefined}
exiting={
!placed || isWeb || !onExited
? undefined
: contentExiting.withCallback((finished) => {
"worklet";
if (finished) {
runOnJS(onExited)();
}
})
}
>
{scrollable ? (
<FloatingScrollView
bounces={false}
showsVerticalScrollIndicator
style={scrollViewportStyle}
contentContainerStyle={SCROLL_CONTENT_STYLE}
>
{measured}
</FloatingScrollView>
) : (
measured
)}
</FloatingSurface>
<View pointerEvents="box-none" style={frame.holder}>
<FloatingSurface
key={placed ? "placed" : "measuring"}
collapsable={false}
tabIndex={-1}
nativeID={surfaceNativeID}
testID={testID}
dataSet={surfaceDataSet}
style={styles.content}
frameStyle={frameStyle}
entering={placed ? nativeContentEntering : undefined}
exiting={
!placed || isWeb || !onExited
? undefined
: contentExiting.withCallback((finished) => {
"worklet";
if (finished) {
runOnJS(onExited)();
}
})
}
>
{scrollable ? (
<FloatingScrollView
bounces={false}
showsVerticalScrollIndicator
style={scrollViewportStyle}
contentContainerStyle={SCROLL_CONTENT_STYLE}
>
{measured}
</FloatingScrollView>
) : (
measured
)}
</FloatingSurface>
</View>
</>
);
}
+12 -2
View File
@@ -1,13 +1,23 @@
import { Modal as NativeModal, type ModalProps } from "react-native";
import { Modal as NativeModal, type ModalProps as NativeModalProps } from "react-native";
import { useDismissibleOverlay } from "@/components/ui/overlay-dismissal";
// React Native defaults iPhone modals to portrait even when the app supports rotation.
const orientations: NonNullable<ModalProps["supportedOrientations"]> = [
const orientations: NonNullable<NativeModalProps["supportedOrientations"]> = [
"portrait",
"portrait-upside-down",
"landscape-left",
"landscape-right",
];
type ModalProps = Omit<NativeModalProps, "onRequestClose"> & {
/** Back, Escape, and a composer taking focus all close the modal through this. */
onRequestClose?: () => void;
};
function noClose() {}
export function Modal(props: ModalProps) {
const { visible = true, onRequestClose } = props;
useDismissibleOverlay(visible && onRequestClose !== undefined, onRequestClose ?? noClose);
return <NativeModal supportedOrientations={orientations} {...props} />;
}
@@ -0,0 +1,90 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
type PropsWithChildren,
type ReactElement,
} from "react";
interface OverlayDismissal {
register(close: () => void): () => void;
dismissAll(): Promise<void>;
}
interface OpenOverlay {
close: () => void;
/** Set once the overlay is asked to close; every caller waits on the same one. */
closing: { gone: Promise<void>; settle: () => void } | null;
}
function createOverlayDismissal(): OverlayDismissal {
const open = new Set<OpenOverlay>();
function dismiss(overlay: OpenOverlay): Promise<void> {
if (!overlay.closing) {
let settle = () => {};
const gone = new Promise<void>((resolve) => {
settle = resolve;
});
overlay.closing = { gone, settle };
overlay.close();
}
return overlay.closing.gone;
}
return {
register(close) {
const overlay: OpenOverlay = { close, closing: null };
open.add(overlay);
return () => {
open.delete(overlay);
overlay.closing?.settle();
};
},
dismissAll() {
// An overlay is gone in the commit that unregisters it. That commit can also move focus, such
// as a web modal handing it back to the button that opened it.
return Promise.all([...open].map(dismiss)).then(() => undefined);
},
};
}
const OverlayDismissalContext = createContext<OverlayDismissal | null>(null);
/**
* A region that can close the sheets, menus and popovers opened from inside it. The composer is
* one: putting the cursor in it closes whatever it opened over itself. A scope inside another joins
* it, so a host widens the region by opening a scope around the composer and its neighbours.
*/
export function OverlayDismissalScope({ children }: PropsWithChildren): ReactElement {
const enclosing = useContext(OverlayDismissalContext);
const [own] = useState(createOverlayDismissal);
return (
<OverlayDismissalContext.Provider value={enclosing ?? own}>
{children}
</OverlayDismissalContext.Provider>
);
}
/** While `open`, the enclosing scope, if any, can close this overlay through `close`. */
export function useDismissibleOverlay(open: boolean, close: () => void): void {
const scope = useContext(OverlayDismissalContext);
const closeRef = useRef(close);
closeRef.current = close;
useEffect(() => {
if (!scope || !open) return;
return scope.register(() => closeRef.current());
}, [scope, open]);
}
/**
* Closes every overlay open in the enclosing scope, and resolves once they are gone. Outside a
* scope there is nothing to close. A registered `close` must close the overlay.
*/
export function useDismissOverlays(): () => Promise<void> {
const scope = useContext(OverlayDismissalContext);
return useCallback(() => scope?.dismissAll() ?? Promise.resolve(), [scope]);
}
+40 -39
View File
@@ -30,6 +30,7 @@ import { FloatingSurface } from "@/components/ui/floating";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { isNative, isWeb } from "@/constants/platform";
import { getOverlayRoot, OVERLAY_Z } from "@/lib/overlay-root";
import { holdSurfaceEdge, surfaceFrameStyles, type SurfacePosition } from "./anchor";
type Side = "top" | "bottom" | "left" | "right";
type Align = "start" | "center" | "end";
@@ -475,7 +476,7 @@ export function TooltipContent({
const ctx = useTooltipContext("TooltipContent");
const [triggerRect, setTriggerRect] = useState<Rect | null>(null);
const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null);
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
const [position, setPosition] = useState<SurfacePosition | null>(null);
useEffect(() => {
if (!ctx.open || !ctx.enabled || !ctx.triggerRef.current) {
@@ -509,7 +510,12 @@ export function TooltipContent({
align,
offset,
});
setPosition({ x: result.x, y: result.y });
setPosition(
holdSurfaceEdge(
{ x: result.x, y: result.y, actualPlacement: result.actualSide },
contentSize.height,
),
);
}, [triggerRect, contentSize, side, align, offset]);
const handleLayout = useCallback(
@@ -520,17 +526,8 @@ export function TooltipContent({
[],
);
const frameStyle = useMemo(
() => [
{
position: "absolute" as const,
top: position?.y ?? -9999,
left: position?.x ?? -9999,
maxWidth,
},
],
[maxWidth, position?.x, position?.y],
);
const frame = useMemo(() => surfaceFrameStyles(position), [position]);
const frameStyle = useMemo(() => [frame.surface, { maxWidth }], [frame.surface, maxWidth]);
const contentStyle = useMemo(() => [styles.content, style], [style]);
const handleDismiss = useCallback(() => ctx.setOpen(false), [ctx]);
@@ -543,19 +540,21 @@ export function TooltipContent({
if (isWeb) {
return createPortal(
<View pointerEvents="none" style={styles.portalOverlay}>
<FloatingSurface
pointerEvents="none"
entering={FadeIn.duration(80)}
exiting={FadeOut.duration(80)}
collapsable={false}
role="tooltip"
testID={testID}
onLayout={handleLayout}
style={contentStyle}
frameStyle={frameStyle}
>
{children}
</FloatingSurface>
<View pointerEvents="box-none" style={frame.holder}>
<FloatingSurface
pointerEvents="none"
entering={FadeIn.duration(80)}
exiting={FadeOut.duration(80)}
collapsable={false}
role="tooltip"
testID={testID}
onLayout={handleLayout}
style={contentStyle}
frameStyle={frameStyle}
>
{children}
</FloatingSurface>
</View>
</View>,
getOverlayRoot(),
);
@@ -570,19 +569,21 @@ export function TooltipContent({
onRequestClose={handleDismiss}
>
<Pressable testID="tooltip-dismiss" style={styles.overlay} onPress={handleDismiss}>
<FloatingSurface
pointerEvents="none"
entering={FadeIn.duration(80)}
exiting={FadeOut.duration(80)}
collapsable={false}
role="tooltip"
testID={testID}
onLayout={handleLayout}
style={contentStyle}
frameStyle={frameStyle}
>
{children}
</FloatingSurface>
<View pointerEvents="box-none" style={frame.holder}>
<FloatingSurface
pointerEvents="none"
entering={FadeIn.duration(80)}
exiting={FadeOut.duration(80)}
collapsable={false}
role="tooltip"
testID={testID}
onLayout={handleLayout}
style={contentStyle}
frameStyle={frameStyle}
>
{children}
</FloatingSurface>
</View>
</Pressable>
</Modal>
);
@@ -1,5 +1,5 @@
import { QueryClient } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
import { resolveAgentModelSelection } from "@/composer/agent-controls/utils";
@@ -142,8 +142,25 @@ function runtimeModel(model: string): Agent["runtimeInfo"] {
return { provider: "claude", sessionId: null, model };
}
function composer() {
return createAgentComposer(serverId, agentId, { queryClient });
function composer(deps: { reveal?: () => void } = {}) {
return createAgentComposer(serverId, agentId, { queryClient, reveal: () => {}, ...deps });
}
/** A mounted input that records the focus it takes. */
function mountedInput(on: { revealed: boolean }) {
const focused: unknown[] = [];
return {
focused,
input: {
isLive: () => on.revealed,
setText() {},
insertText() {},
isRevealed: () => on.revealed,
focus: async (options?: unknown) => {
focused.push(options);
},
},
};
}
beforeEach(() => {
@@ -157,6 +174,143 @@ afterEach(() => {
useSessionStore.getState().clearSession(serverId);
});
describe("agent composer focus", () => {
it("only focuses an input that is already on screen", async () => {
openSession(agent());
let reveals = 0;
const model = composer({ reveal: () => (reveals += 1) });
const { input, focused } = mountedInput({ revealed: true });
model.attachInput(input);
await model.focus({ selection: { start: 1, end: 3 } });
expect(focused).toEqual([{ selection: { start: 1, end: 3 } }]);
expect(reveals).toBe(0);
});
it("reveals an agent with no mounted composer and focuses the input that arrives", async () => {
openSession(agent());
let reveals = 0;
const model = composer({ reveal: () => (reveals += 1) });
const focusing = model.focus();
expect(reveals).toBe(1);
const { input, focused } = mountedInput({ revealed: true });
model.attachInput(input);
await focusing;
expect(focused).toEqual([undefined]);
});
it("focuses after the commit that revealed the input has applied a pending prompt", async () => {
openSession(agent());
const model = composer({ reveal() {} });
const focusing = model.focus({ selection: { start: 0, end: 5 } });
let text = "";
const textWhenFocused: string[] = [];
model.attachInput({
...mountedInput({ revealed: true }).input,
focus: async () => {
textWhenFocused.push(text);
},
});
text = "HELLO";
await focusing;
expect(textWhenFocused).toEqual(["HELLO"]);
});
it("waits for a retained input to come on screen before focusing it", async () => {
openSession(agent());
let reveals = 0;
const model = composer({ reveal: () => (reveals += 1) });
const on = { revealed: false };
const { input, focused } = mountedInput(on);
const detach = model.attachInput(input);
const focusing = model.focus();
expect(reveals).toBe(1);
expect(focused).toEqual([]);
// The mounted composer re-attaches when it comes on screen.
on.revealed = true;
detach();
model.attachInput(input);
await focusing;
expect(focused).toEqual([undefined]);
});
it("applies only the latest of two focus requests", async () => {
openSession(agent());
const model = composer();
const first = model.focus({ selection: { start: 0, end: 0 } });
const second = model.focus({ selection: { start: 2, end: 2 } });
const { input, focused } = mountedInput({ revealed: true });
model.attachInput(input);
await Promise.all([first, second]);
expect(focused).toEqual([{ selection: { start: 2, end: 2 } }]);
});
it("lets a call made while an arriving input is being focused decide the selection", async () => {
openSession(agent());
const model = composer();
const first = model.focus({ selection: { start: 0, end: 1 } });
const { input, focused } = mountedInput({ revealed: true });
model.attachInput(input);
const second = model.focus({ selection: { start: 2, end: 4 } });
await Promise.all([first, second]);
expect(focused.at(-1)).toEqual({ selection: { start: 2, end: 4 } });
});
it("settles a replaced focus request with the request that replaced it", async () => {
vi.useFakeTimers();
try {
openSession(agent());
const model = composer();
const settled: string[] = [];
const record = (name: string, request: Promise<void>) =>
request.then(
() => settled.push(`${name} focused`),
() => settled.push(`${name} failed`),
);
const first = record("first", model.focus());
const second = record("second", model.focus());
await vi.advanceTimersByTimeAsync(60_000);
await Promise.all([first, second]);
expect(settled).toEqual(["second failed", "first failed"]);
} finally {
vi.useRealTimers();
}
});
it("rejects when the composer never comes on screen, and a later mount does not take focus", async () => {
vi.useFakeTimers();
try {
openSession(agent());
const model = composer();
const focusing = model.focus();
const failed = expect(focusing).rejects.toThrow("did not come on screen");
await vi.advanceTimersByTimeAsync(60_000);
await failed;
const { input, focused } = mountedInput({ revealed: true });
model.attachInput(input);
expect(focused).toEqual([]);
} finally {
vi.useRealTimers();
}
});
it("rejects for an archived agent without navigating to it", async () => {
openSession(agent({ archivedAt: new Date() }));
let reveals = 0;
const model = composer({ reveal: () => (reveals += 1) });
await expect(model.focus()).rejects.toThrow("archived");
expect(reveals).toBe(0);
});
});
describe("agent composer", () => {
it("reads without notifying anyone, so a read during a render updates no other component", () => {
const running = agent();
@@ -169,8 +323,8 @@ describe("agent composer", () => {
[other.id, other],
]),
);
const watched = createAgentComposer(serverId, running.id, { queryClient });
const reader = createAgentComposer(serverId, other.id, { queryClient });
const watched = createAgentComposer(serverId, running.id, { queryClient, reveal: () => {} });
const reader = createAgentComposer(serverId, other.id, { queryClient, reveal: () => {} });
let notifications = 0;
const stop = watched.subscribe(() => {
notifications += 1;
@@ -201,6 +355,8 @@ describe("agent composer", () => {
isLive: () => true,
setText: (text) => mounted.push(`set:${text}`),
insertText: (text) => mounted.push(`insert:${text}`),
isRevealed: () => false,
focus: async () => {},
});
model.insertText("at the cursor");
model.setPrompt("replaced");
@@ -219,6 +375,8 @@ describe("agent composer", () => {
isLive: () => false,
setText: (text) => mounted.push(`set:${text}`),
insertText: (text) => mounted.push(`insert:${text}`),
isRevealed: () => false,
focus: async () => {},
});
model.insertText("while away");
expect(mounted).toEqual([]);
@@ -396,7 +554,7 @@ describe("agent composer", () => {
it("is shared while its agent is in the session, across closing and reopening its tab", () => {
openSession(agent());
const shared = agentComposer(serverId, agentId);
const detach = shared.attachInput({ isLive: () => true, setText() {}, insertText() {} });
const detach = shared.attachInput(mountedInput({ revealed: true }).input);
detach();
expect(agentComposer(serverId, agentId)).toBe(shared);
const stop = shared.subscribe(() => {});
@@ -407,7 +565,7 @@ describe("agent composer", () => {
it("is released when the last holder lets go of a composer whose agent was removed meanwhile", () => {
openSession(agent());
const mounted = agentComposer(serverId, agentId);
const detach = mounted.attachInput({ isLive: () => true, setText() {}, insertText() {} });
const detach = mounted.attachInput(mountedInput({ revealed: true }).input);
useSessionStore.getState().setAgents(serverId, new Map());
// Held, so the session change did not release it, and nothing changes in the session after.
expect(agentComposer(serverId, agentId)).toBe(mounted);
@@ -8,13 +8,14 @@ import {
resolveAgentModelSelection,
resolveProviderSnapshotEntry,
} from "@/composer/agent-controls/utils";
import type { ComposerModel, ComposerState } from "@/composer/model";
import type { ComposerFocusOptions, ComposerModel, ComposerState } from "@/composer/model";
import { filterSelectableModels } from "@/provider-selection/model-catalog";
import { buildDraftStoreKey } from "@/stores/draft-keys";
import { useDraftStore } from "@/stores/draft-store";
import { toDraftInputIfReady, type DraftRecord } from "@/stores/draft-store/state";
import { useSessionStore, type Agent } from "@/stores/session-store";
import type { UserComposerAttachment } from "@/attachments/types";
import { navigateToAgent } from "@/utils/navigate-to-agent";
/** The mounted composer's input. While attached, prompt commands go through it. */
export interface MountedComposerInput {
@@ -23,6 +24,9 @@ export interface MountedComposerInput {
setText(text: string): void;
/** Inserts at the cursor, replacing the selection. */
insertText(text: string): void;
/** On screen and able to take the keyboard: `focus` needs nothing revealed first. */
isRevealed(): boolean;
focus(options?: ComposerFocusOptions): Promise<void>;
}
/**
@@ -41,6 +45,20 @@ interface AgentComposerDeps {
cache?: ProviderSnapshotCache;
/** The last watcher or mounted composer let go, so the composer may be releasable. */
onUnheld?: () => void;
/** Brings the agent's composer on screen, wherever the user is. */
reveal: () => void;
}
// Long enough for a cold workspace to load its chat over a relay.
const REVEAL_TIMEOUT_MS = 15_000;
/** A focus waiting for the revealed input to come on screen. */
interface PendingFocus {
/** Which `focus()` call this is; a later call decides where the cursor goes. */
call: number;
options: ComposerFocusOptions | undefined;
settle: { resolve: () => void; reject: (error: Error) => void };
timeout: ReturnType<typeof setTimeout>;
}
type Catalog = { status: "loading" } | { status: "ready"; entries: ProviderSnapshotEntry[] | null };
@@ -66,6 +84,9 @@ export function createAgentComposer(
// One pair of store subscriptions serves every listener, from the first to the last.
let stopWatchingStores: (() => void) | null = null;
let mounted: MountedComposerInput | null = null;
let pendingFocus: PendingFocus | null = null;
let focusCalls = 0;
let latestFocus: Promise<void> = Promise.resolve();
let last: {
agent: Agent | null;
draft: DraftRecord | undefined;
@@ -209,6 +230,25 @@ export function createAgentComposer(
return last.state;
}
function takePendingFocus(): PendingFocus | null {
const pending = pendingFocus;
pendingFocus = null;
if (pending) clearTimeout(pending.timeout);
return pending;
}
function focusIfRevealed(input: MountedComposerInput) {
if (!pendingFocus || !input.isRevealed()) return;
const { call, options, settle } = takePendingFocus()!;
// Attaching runs in a commit's effects, and a prompt set alongside this request can be applied
// by a later effect in that commit. A selection has to see that text.
queueMicrotask(() => {
// A call made since then, such as from a mounting item, decides; this one ends with it.
const outcome = call === focusCalls ? input.focus(options) : latestFocus;
outcome.then(settle.resolve, settle.reject);
});
}
function unheld() {
if (listeners.size === 0 && mounted === null) deps.onUnheld?.();
}
@@ -237,6 +277,7 @@ export function createAgentComposer(
},
attachInput(input) {
mounted = input;
focusIfRevealed(input);
return () => {
if (mounted === input) mounted = null;
unheld();
@@ -253,6 +294,34 @@ export function createAgentComposer(
useDraftStore.getState().replaceDraftText({ draftKey, text: current + text });
}
},
focus(options) {
const agent = readAgent();
if (!agent || agent.archivedAt != null) {
return Promise.reject(new Error("The agent is gone or archived."));
}
const call = ++focusCalls;
if (mounted?.isRevealed()) {
latestFocus = mounted.focus(options);
return latestFocus;
}
const replaced = takePendingFocus();
const request = new Promise<void>((resolve, reject) => {
pendingFocus = {
call,
options,
settle: { resolve, reject },
timeout: setTimeout(() => {
takePendingFocus();
reject(new Error("The composer did not come on screen."));
}, REVEAL_TIMEOUT_MS),
};
deps.reveal();
});
// The latest request decides where the cursor goes, and the one it replaced ends with it.
if (replaced) request.then(replaced.settle.resolve, replaced.settle.reject);
latestFocus = request;
return request;
},
changeAttachments(updater) {
useDraftStore.getState().updateDraftInput({
draftKey,
@@ -304,6 +373,7 @@ export function agentComposer(serverId: string, agentId: string): AgentComposer
composer = createAgentComposer(serverId, agentId, {
queryClient: singletonQueryClient,
onUnheld: releaseReleasableComposers,
reveal: () => navigateToAgent({ serverId, agentId, asTab: true }),
});
composers.set(key, composer);
}
@@ -13,6 +13,8 @@ beforeEach(() => vi.stubGlobal("React", React));
afterEach(cleanup);
const base = { id: "x", title: "Context", icon: Scan };
// The sheet lays its rows out in a column, which is what stretches a row across it.
const SHEET_COLUMN = { display: "flex", flexDirection: "column", width: 382 } as const;
type RecordingToast = ToastApi & { error: ReturnType<typeof vi.fn> };
@@ -92,6 +94,29 @@ describe("ComposerControls", () => {
expect(screen.getByRole("button", { name: "Context" }).textContent).toContain("On");
});
it("gives a sheet row's value the row's width, so a short value is not truncated", () => {
const toast = createToast();
render(
<div style={SHEET_COLUMN}>
<Controls
items={[
{
...base,
type: "select",
value: "full",
choices: [{ value: "full", label: "Full access" }],
onChange: () => undefined,
},
]}
surface="sheet"
toast={toast}
/>
</div>,
);
const value = screen.getByText("Full access");
expect(value.scrollWidth).toBeLessThanOrEqual(value.clientWidth);
});
it("reports a failed command the way a built-in control does, and frees the control", async () => {
let reject: (error: Error) => void = () => undefined;
const onPress = vi.fn(
@@ -2,7 +2,7 @@ import { forwardRef, useCallback, type ComponentProps } from "react";
import { Text, View, type PressableStateCallbackType } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
import { TOUCH_ICON_SIZE, toolbarContentSlot, toolbarSlot } from "@/components/ui/control-geometry";
import { toolbarContentSlot, toolbarSlot } from "@/components/ui/control-geometry";
import { useComposerControlLayout } from "@/composer/agent-controls/layout-context";
import { ComposerToolbarGlyph } from "@/composer/agent-controls/glyph";
import type { AgentControlIcon } from "@/agent-controls/icons";
@@ -73,6 +73,7 @@ export const AgentControlTrigger = forwardRef<View, AgentControlTriggerProps>(
accessibilityLabel={accessibilityLabel}
testID={testID}
chevron={showCaret ? undefined : null}
block={isSheet}
>
{isSheet ? (
<View style={styles.sheetGlyph}>
@@ -105,12 +106,12 @@ const styles = StyleSheet.create((theme) => ({
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
...toolbarContentSlot(theme.spacing[2]),
...toolbarContentSlot(),
borderRadius: theme.borderRadius.full,
backgroundColor: "transparent",
},
toolbarIconOnly: {
...toolbarSlot(TOUCH_ICON_SIZE.md),
...toolbarSlot(),
flexShrink: 0,
paddingHorizontal: 0,
justifyContent: "center",
@@ -50,10 +50,8 @@ import { useIsCompactFormFactor } from "@/constants/layout";
import {
controlSizingForLayout,
TOOLBAR_CONTROL_GAP,
bySizing,
toolbarGlyphColor,
type ToolbarGlyphs,
TOUCH_ICON_SIZE,
toolbarSlot,
} from "@/components/ui/control-geometry";
import { readMeasuredWidth } from "@/hooks/use-container-width";
@@ -87,6 +85,10 @@ import { composerModelFromHalves } from "@/composer/halves-model";
import { ComposerModelProvider } from "@/composer/model";
import { ComposerAgentProvider, useComposerInput } from "@/composer/session";
import { PluginComposerItems, usePluginComposerItemCount } from "@/plugins/composer-items";
import {
PluginToolbarSlot,
usePluginToolbarWidth,
} from "@/composer/agent-controls/plugin-toolbar-slot";
import {
useAgentProfileEditor,
useAgentProfilePicker,
@@ -466,6 +468,7 @@ function ControlledAgentControls({
onSelectThinkingOption && thinkingOptions && thinkingOptions.length > 0,
);
const pluginItemCount = usePluginComposerItemCount(modelSelectorServerId);
const { width: pluginItemsWidth, onLayout: handlePluginItemsLayout } = usePluginToolbarWidth();
const formattedThinkingOptions = useMemo(
() => toThinkingControlOptions(thinkingOptions),
@@ -488,6 +491,7 @@ function ControlledAgentControls({
() =>
(optionControls?.controls ?? []).map((control) => {
if (control.type === "toggle") return { type: "toggle" as const };
if (control.desktopTrigger === "icon") return { type: "select" as const, label: null };
const selected = readSelectOption(control, optionControls?.options ?? {});
const choice = control.choices.find((candidate) => candidate.value === selected);
return {
@@ -503,10 +507,10 @@ function ControlledAgentControls({
hasThinking: canSelectThinking,
hasMode: modeControl !== null && modeControl !== undefined,
features: featureControls,
pluginItems: pluginItemCount,
pluginItemsWidth,
fontScale,
}),
[canSelectModel, canSelectThinking, featureControls, fontScale, modeControl, pluginItemCount],
[canSelectModel, canSelectThinking, featureControls, fontScale, modeControl, pluginItemsWidth],
);
const presentation = useMemo(() => resolveComposerControlPresentation(density), [density]);
const layoutContextValue = useMemo(
@@ -635,7 +639,7 @@ function ControlledAgentControls({
return (
<ComposerControlLayoutProvider value={layoutContextValue}>
<View style={styles.container} onLayout={handleLayout}>
<View style={styles.container} onLayout={handleLayout} testID="agent-controls-toolbar">
{!presentation.foldControls ? (
<DesktopAgentControlsContent
provider={provider}
@@ -661,7 +665,7 @@ function ControlledAgentControls({
handleSheetOpenChange={setOpenSelector}
modeControl={modeControl}
presentation={presentation}
pluginItemCount={pluginItemCount}
hasPluginItems={pluginItemsWidth > 0}
glyphs={layoutContextValue.glyphs}
activeSheet={activeSheet}
handleOpenSheet={handleOpenSheet}
@@ -698,6 +702,14 @@ function ControlledAgentControls({
canSwitchProvider={Boolean(onSelectProviderAndModel)}
/>
)}
{!isCompact ? (
<PluginToolbarSlot
serverId={modelSelectorServerId}
width={pluginItemsWidth}
fits={!presentation.aggregateFeatures}
onLayout={handlePluginItemsLayout}
/>
) : null}
</View>
</ComposerControlLayoutProvider>
);
@@ -732,7 +744,7 @@ interface DesktopAgentControlsContentProps extends AgentControlsContentProps {
handleDesktopModelSelect: (providerId: string, modelId: string) => void;
handleToolbarOpenChange: (id: string | null) => void;
presentation: ComposerControlPresentation;
pluginItemCount: number;
hasPluginItems: boolean;
activeSheet: ActiveSheet;
handleOpenSheet: (sheet: Exclude<ActiveSheet, null>) => void;
handleCloseSheet: () => void;
@@ -766,7 +778,7 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) {
handleSheetOpenChange,
modeControl,
presentation,
pluginItemCount,
hasPluginItems,
glyphs,
activeSheet,
handleOpenSheet,
@@ -782,7 +794,7 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) {
[t],
);
const handleOpenFeatures = useCallback(() => handleOpenSheet("features"), [handleOpenSheet]);
const hasFeatures = featureItems.length > 0 || pluginItemCount > 0;
const hasFeatures = featureItems.length > 0 || hasPluginItems;
return (
<>
{canSelectModel ? (
@@ -864,17 +876,14 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) {
</AdaptiveModalSheet>
</>
) : (
<>
<ComposerControls
items={featureItems}
surface="toolbar"
disabled={disabled}
openId={openSelector}
onOpenIdChange={handleToolbarOpenChange}
onActionComplete={onDropdownClose}
/>
<PluginComposerItems serverId={modelSelectorServerId} surface="toolbar" />
</>
<ComposerControls
items={featureItems}
surface="toolbar"
disabled={disabled}
openId={openSelector}
onOpenIdChange={handleToolbarOpenChange}
onActionComplete={onDropdownClose}
/>
)}
</>
);
@@ -1172,6 +1181,8 @@ export const AgentControls = memo(function AgentControls({
isLive: composerInput.isLive,
setText: composerInput.setText,
insertText: composerInput.insertText,
isRevealed: composerInput.isRevealed,
focus: composerInput.focus,
})
: undefined,
[composer, composerInput],
@@ -1452,18 +1463,15 @@ const styles = StyleSheet.create((theme) => ({
flexDirection: "row",
alignItems: "center",
gap: TOOLBAR_CONTROL_GAP,
// Clips pickers that run out of room. Touch-sized the pickers' pills and circles reach 8 past
// what the row spaces, so the clip reaches as far.
// Clips pickers that run out of room.
overflow: "hidden",
paddingHorizontal: bySizing({ touch: theme.spacing[2], pointer: 0 }),
marginHorizontal: bySizing({ touch: -theme.spacing[2], pointer: 0 }),
},
modelControl: {
minWidth: 0,
flexShrink: 1,
},
modeIconBadge: {
...toolbarSlot(TOUCH_ICON_SIZE.md),
...toolbarSlot(),
alignItems: "center",
justifyContent: "center",
paddingHorizontal: 0,
@@ -47,7 +47,7 @@ describe("composer control layout", () => {
hasThinking: true,
hasMode: true,
features: [{ type: "toggle" as const }],
pluginItems: 0,
pluginItemsWidth: 0,
fontScale: 1,
};
@@ -116,7 +116,7 @@ describe("composer control layout", () => {
hasThinking: true,
hasMode: true,
features: [{ type: "toggle" }],
pluginItems: 0,
pluginItemsWidth: 0,
fontScale: 1,
},
}),
@@ -129,7 +129,7 @@ describe("composer control layout", () => {
hasThinking: true,
hasMode: true,
features: [{ type: "toggle" }, { type: "select", label: "Tools" }],
pluginItems: 0,
pluginItemsWidth: 0,
fontScale: 1,
},
}),
@@ -142,7 +142,7 @@ describe("composer control layout", () => {
hasThinking: true,
hasMode: true,
features: [{ type: "toggle" }],
pluginItems: 0,
pluginItemsWidth: 0,
fontScale: 1.25,
},
}),
@@ -158,7 +158,7 @@ describe("composer control layout", () => {
hasModel: true,
hasThinking: true,
hasMode: true,
pluginItems: 0,
pluginItemsWidth: 0,
fontScale: 1,
},
};
@@ -180,34 +180,61 @@ describe("composer control layout", () => {
).toBe("condensed");
});
it("condenses to make room for plugin items and keeps them in the overflow", () => {
describe("plugin items", () => {
const base = {
availableWidth: 430,
currentDensity: "full" as const,
geometry: POINTER,
controls: {
hasModel: true,
hasThinking: true,
hasMode: true,
features: [],
fontScale: 1,
pluginItems: 0,
},
};
const controls = {
hasModel: true,
hasThinking: true,
hasMode: true,
features: [],
fontScale: 1,
pluginItemsWidth: 0,
};
expect(
resolveComposerControlDensity({ ...base, controls: { ...base.controls, pluginItems: 0 } }),
).toBe("full");
expect(
resolveComposerControlDensity({ ...base, controls: { ...base.controls, pluginItems: 2 } }),
).toBe("condensed");
expect(
resolveComposerControlDensity({
...base,
availableWidth: 250,
controls: { ...base.controls, pluginItems: 2 },
}),
).toBe("tight");
it("budgets the width the items measured, so an icon toggle fits where a label would not", () => {
expect(
resolveComposerControlDensity({
...base,
controls: { ...controls, pluginItemsWidth: 28 },
}),
).toBe("full");
expect(
resolveComposerControlDensity({
...base,
controls: { ...controls, pluginItemsWidth: 125 },
}),
).toBe("condensed");
});
it("reserves no overflow button for items that render nothing", () => {
// 264 for the condensed row; an overflow button would take it to 296.
const tight = { ...base, availableWidth: 290, currentDensity: "tight" as const };
expect(resolveComposerControlDensity({ ...tight, controls })).toBe("condensed");
expect(
resolveComposerControlDensity({
...tight,
controls: { ...controls, pluginItemsWidth: 28 },
}),
).toBe("tight");
});
it("holds either density at the full floor while the items' width stays the same", () => {
// 412 is the full floor with a 28-wide item, and 12 the hysteresis.
const atFloor = { ...base, availableWidth: 412 - 12 };
const withItem = { ...controls, pluginItemsWidth: 28 };
expect(resolveComposerControlDensity({ ...atFloor, controls: withItem })).toBe("full");
expect(
resolveComposerControlDensity({
...atFloor,
currentDensity: "condensed",
controls: withItem,
}),
).toBe("condensed");
});
});
it("gives every toolbar control one shell and one glyph per role and control sizing", () => {
@@ -221,15 +248,38 @@ describe("composer control layout", () => {
caretSize: 14,
});
expect(TOUCH).toMatchObject({
controlSize: 20,
controlSize: 36,
glyphs: {
icon: { size: 20, strokeWidth: 1.5, tone: "foreground" },
label: { size: 20, strokeWidth: 1.5, tone: "foreground" },
},
controlGap: 20,
controlGap: 0,
labelPadding: 8,
});
});
it("budgets an icon-only select as the icon it draws", () => {
// Codex's service tier: a select whose toolbar trigger is an icon. 476 with its icon, 543 with
// the label "Priority", against a 502-wide toolbar.
const controls = {
hasModel: true,
hasThinking: true,
hasMode: true,
features: [{ type: "select" as const, label: null }, { type: "toggle" as const }],
fontScale: 1,
pluginItemsWidth: 28,
};
expect(
resolveComposerControlDensity({
availableWidth: 502,
currentDensity: "condensed",
geometry: POINTER,
controls,
}),
).toBe("full");
});
it("budgets touch-sized controls, so a touch toolbar condenses at a width a pointer one fits", () => {
const input = {
availableWidth: 400,
@@ -240,7 +290,7 @@ describe("composer control layout", () => {
hasMode: true,
features: [{ type: "toggle" as const }],
fontScale: 1,
pluginItems: 0,
pluginItemsWidth: 0,
},
};
@@ -249,15 +299,15 @@ describe("composer control layout", () => {
});
describe("touch toolbar on an iPad in portrait, sidebar docked", () => {
// Codex: model, thinking, mode and two toggle features. Touch controls lay out at what they
// paint, 20pt apart, so icon-only thinking, mode and features need 200.
// Codex: model, thinking, mode and two toggle features. Touch controls lay out at their
// 36pt highlight with no gap, so icon-only thinking, mode and features need 108.
const controls = {
hasModel: true,
hasThinking: true,
hasMode: true,
features: [{ type: "toggle" as const }, { type: "toggle" as const }],
fontScale: 1,
pluginItems: 0,
pluginItemsWidth: 0,
};
it("keeps the model family label with icon-only controls on an empty draft", () => {
@@ -316,7 +366,7 @@ describe("composer control layout", () => {
hasMode: true,
features: [],
fontScale: 1,
pluginItems: 0,
pluginItemsWidth: 0,
},
};
@@ -1,6 +1,7 @@
import {
TOOLBAR_CONTROL_GAPS,
TOOLBAR_CONTROL_SIZES,
TOOLBAR_DISC_SIZES,
TOOLBAR_LABEL_PADDING,
toolbarGlyphsFor,
type ControlSizing,
type ToolbarGlyphs,
@@ -26,12 +27,15 @@ export interface ComposerControlPresence {
hasThinking: boolean;
hasMode: boolean;
features: readonly ComposerFeatureControlPresence[];
/** Plugin composer items. Each is budgeted like a select with a short label. */
pluginItems: number;
/** The plugin items' measured width in the toolbar row; 0 when none of them renders anything. */
pluginItemsWidth: number;
fontScale: number;
}
export type ComposerFeatureControlPresence = { type: "toggle" } | { type: "select"; label: string };
export type ComposerFeatureControlPresence =
| { type: "toggle" }
/** `label` is what the toolbar trigger draws; null for an icon-only trigger. */
| { type: "select"; label: string | null };
export interface ComposerControlPresentation {
showCarets: boolean;
@@ -50,31 +54,24 @@ export interface ComposerToolbarGeometry {
glyphs: ToolbarGlyphs;
controlGap: number;
iconLabelGap: number;
/** A labelled control's padding on each side, as far as it takes room in the row. */
/** A labelled control's padding on each side. */
labelPadding: number;
caretSize: number;
}
/**
* The toolbar's measurements under a control sizing; see `resolveControlSizing`. Touch-sized a
* control lays out at the width of what it paints (`toolbarSlot`, `toolbarContentSlot`), so an
* icon-only control takes its glyph's width and a picker's padding takes no room.
*/
/** The toolbar's measurements under a control sizing; see `resolveControlSizing`. */
export function composerToolbarGeometry(sizing: ControlSizing): ComposerToolbarGeometry {
const glyphs = toolbarGlyphsFor(sizing);
const isTouch = sizing === "touch";
return {
controlSize: isTouch ? glyphs.icon.size : TOOLBAR_CONTROL_SIZES.pointer,
glyphs,
controlSize: TOOLBAR_DISC_SIZES[sizing],
glyphs: toolbarGlyphsFor(sizing),
controlGap: TOOLBAR_CONTROL_GAPS[sizing],
iconLabelGap: 4,
labelPadding: isTouch ? 0 : 8,
labelPadding: TOOLBAR_LABEL_PADDING,
caretSize: 14,
};
}
const DENSITY_HYSTERESIS = 12;
const PLUGIN_ITEM_LABEL = "Plugin item";
/** The model label's narrowest useful width: enough for the model family, such as "GPT-6…". */
const MODEL_FAMILY_LABEL_WIDTH = 56;
@@ -103,7 +100,7 @@ function resolveFeatureControlWidth(
fontScale: number,
geometry: ComposerToolbarGeometry,
): number {
if (feature.type === "toggle") return geometry.controlSize;
if (feature.type === "toggle" || feature.label === null) return geometry.controlSize;
return (
geometry.controlSize +
geometry.iconLabelGap +
@@ -122,7 +119,7 @@ function resolveCondensedFloor(
if (controls.hasModel) widths.push(chrome + 60 * fontScale);
if (controls.hasThinking) widths.push(geometry.controlSize);
if (controls.hasMode) widths.push(chrome + 96 * fontScale);
if (controls.features.length > 0 || controls.pluginItems > 0) {
if (controls.features.length > 0 || controls.pluginItemsWidth > 0) {
widths.push(geometry.controlSize);
}
return sumControlWidths(widths, geometry);
@@ -142,11 +139,7 @@ function resolveFullFloor(
for (const feature of controls.features) {
widths.push(resolveFeatureControlWidth(feature, fontScale, geometry));
}
for (let index = 0; index < controls.pluginItems; index++) {
widths.push(
resolveFeatureControlWidth({ type: "select", label: PLUGIN_ITEM_LABEL }, fontScale, geometry),
);
}
if (controls.pluginItemsWidth > 0) widths.push(controls.pluginItemsWidth);
return sumControlWidths(widths, geometry);
}
@@ -162,7 +155,7 @@ function resolveTightFloor(
}
if (controls.hasThinking) widths.push(geometry.controlSize);
if (controls.hasMode) widths.push(geometry.controlSize);
if (controls.features.length > 0 || controls.pluginItems > 0) {
if (controls.features.length > 0 || controls.pluginItemsWidth > 0) {
widths.push(geometry.controlSize);
}
return sumControlWidths(widths, geometry);
@@ -27,7 +27,6 @@ import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative, isWeb } from "@/constants/platform";
import { readMeasuredWidth } from "@/hooks/use-container-width";
import {
TOUCH_ICON_SIZE,
toolbarContentSlot,
toolbarSlot,
type ToolbarGlyphs,
@@ -535,12 +534,12 @@ const styles = StyleSheet.create((theme) => ({
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
...toolbarContentSlot(theme.spacing[2]),
...toolbarContentSlot(),
borderRadius: theme.borderRadius.full,
backgroundColor: "transparent",
},
triggerIconOnly: {
...toolbarSlot(TOUCH_ICON_SIZE.md),
...toolbarSlot(),
flexShrink: 0,
paddingHorizontal: 0,
justifyContent: "center",
@@ -0,0 +1,82 @@
import { useCallback, useState } from "react";
import { type LayoutChangeEvent, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { RetainedPanelActivity } from "@/components/retained-panel";
import { TOOLBAR_CONTROL_GAP } from "@/components/ui/control-geometry";
import { isWeb } from "@/constants/platform";
import { PluginComposerItems } from "@/plugins/composer-items";
/** A retained tab under `display: none` reports 0 by 0; see `styles.slot` for why 0 wide is real. */
function readSlotWidth(event: LayoutChangeEvent): number | null {
const { width, height } = event.nativeEvent.layout;
return height > 0 ? width : null;
}
/** What the plugin items take up in the toolbar row, in points, as `PluginToolbarSlot` reports it. */
export function usePluginToolbarWidth(): {
width: number;
onLayout: (event: LayoutChangeEvent) => void;
} {
const [width, setWidth] = useState(0);
const onLayout = useCallback((event: LayoutChangeEvent) => {
const next = readSlotWidth(event);
if (next !== null) setWidth(next);
}, []);
return { width, onLayout };
}
/**
* The plugin items in their toolbar form, measured whether or not the toolbar shows them. An item
* is an arbitrary component, so its width is known only from rendering it, and it is always the
* toolbar form that is measured: the width never depends on the density it decides, so moving the
* items to the overflow sheet cannot change the answer. Out of the row, the same node stays
* mounted and measured, unseen and out of reach, and inactive so an open picker closes with it.
*/
export function PluginToolbarSlot({
serverId,
width,
fits,
onLayout,
}: {
serverId: string | null;
width: number;
/** Whether the toolbar has room for the items at the width they measured. */
fits: boolean;
onLayout: (event: LayoutChangeEvent) => void;
}) {
// An empty slot stays out of the row, or the row's gap would reserve room for nothing.
const inline = fits && width > 0;
return (
<View
style={[styles.slot, !inline && styles.measuring]}
onLayout={onLayout}
testID="plugin-toolbar-items"
accessibilityElementsHidden={!inline}
importantForAccessibility={inline ? "auto" : "no-hide-descendants"}
aria-hidden={!inline}
>
<RetainedPanelActivity active={inline}>
<PluginComposerItems serverId={serverId} surface="toolbar" />
</RetainedPanelActivity>
</View>
);
}
const styles = StyleSheet.create({
slot: {
flexDirection: "row",
alignItems: "center",
flexShrink: 0,
gap: TOOLBAR_CONTROL_GAP,
// Keeps a slot whose items render nothing distinguishable from one that is not rendered.
minHeight: 1,
},
measuring: {
position: "absolute",
left: 0,
top: 0,
opacity: 0,
pointerEvents: "none",
...(isWeb ? ({ visibility: "hidden" } as const) : null),
},
});
@@ -49,6 +49,12 @@ export function composerModelFromHalves(input: {
assertUnlocked(composerInput.isLocked);
composerInput.insertText(text);
},
// Only an item inside this composer can hold it, so the user was here when they asked. Paseo
// has no way to name a draft from outside it and does not pull the user back to one.
async focus(options) {
if (!composerInput.isRevealed()) throw new Error("The composer is not on screen.");
await composerInput.focus(options);
},
changeAttachments(updater) {
assertUnlocked(composerInput.isLocked);
composerInput.changeAttachments(updater);
+88 -9
View File
@@ -42,8 +42,7 @@ import {
type ToolbarGlyph,
toolbarSlot,
} from "@/components/ui/control-geometry";
import { COMPOSER_TOOLBAR_SHAPES } from "@/composer/input/toolbar-rails";
import { usePanelStore } from "@/stores/panel-store";
import { usePanelStore, type MobilePanelView } from "@/stores/panel-store";
import { usePointerCapabilities } from "@/hooks/pointer-capabilities";
import { useShallow } from "zustand/shallow";
import {
@@ -138,6 +137,9 @@ import { submitAgentInput } from "@/composer/submit";
import { ComposerRecall } from "@/composer/recall";
import { createMessageSubmissionWriter } from "@/composer/submission/writer";
import { ComposerKeyboardScopeProvider, useComposerKeyboardScope } from "@/composer/keyboard-scope";
import { usePaneHost } from "@/panels/pane-context";
import { OverlayDismissalScope, useDismissOverlays } from "@/components/ui/overlay-dismissal";
import type { PaneHost } from "@/panels/panel-manifest";
import { useAppSettings } from "@/hooks/use-settings";
import { RenderProfile } from "@/utils/render-profiler";
import { AfterPaintPublication } from "@/composer/after-paint-publication";
@@ -153,6 +155,8 @@ import type {
import type { SelectedFile } from "@/attachments/selected-file";
import { resolveComposerAttachmentSubmitFormat } from "@/composer/attachments/submit";
import { ComposerInputProvider, type ComposerInput } from "@/composer/session";
import type { ComposerFocusOptions } from "@/composer/model";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { insertAtSelection } from "@/composer/insert-text";
import { composerWorkspaceAttachment } from "@/composer/attachments/workspace";
import { useWorkspaceAttachmentsForScopes } from "@/attachments/workspace-attachments-store";
@@ -1213,6 +1217,24 @@ function ComposerVoiceModeButton({
);
}
// A plugin modal can refuse to close; the request then tries the cursor anyway and settles.
const OVERLAY_CLOSE_TIMEOUT_MS = 1_000;
function closedWithin(closed: Promise<void>, timeoutMs: number): Promise<void> {
let timeout: ReturnType<typeof setTimeout> | undefined;
const expired = new Promise<void>((resolve) => {
timeout = setTimeout(resolve, timeoutMs);
});
return Promise.race([closed, expired]).finally(() => clearTimeout(timeout));
}
/** Only the agent list can cover a screen outside a workspace: the Explorer lives in the workspace. */
function isMobilePanelCoveringHost(target: MobilePanelView, host: PaneHost | null): boolean {
if (target === "agent-list") return true;
if (host === null) return false;
return target !== (host === "explorer" ? "file-explorer" : "agent");
}
export function Composer({ isPaneFocused, ...props }: ComposerProps) {
// A compact overlay panel (agent list, explorer) covers the pane without taking its
// focus. The composer gives up input from the moment the user asks for the panel,
@@ -1220,16 +1242,21 @@ export function Composer({ isPaneFocused, ...props }: ComposerProps) {
// Pane focus stays put, so the keyboard the panel dismisses is a blur in the
// user's own pane: closing the panel does not bring focus back.
const isCompact = useIsCompactFormFactor();
const overlayPanelRequested = usePanelStore((state) => state.mobilePanel.target !== "agent");
const paneHost = usePaneHost();
const overlayPanelRequested = usePanelStore((state) =>
isMobilePanelCoveringHost(state.mobilePanel.target, paneHost),
);
const coveredByOverlayPanel = isCompact && overlayPanelRequested;
return (
<ComposerKeyboardScopeProvider
isPaneFocused={isPaneFocused}
isActiveComposer={isPaneFocused && !coveredByOverlayPanel}
>
<RenderProfile id="ComposerContent">
<ComposerContent {...props} />
</RenderProfile>
<OverlayDismissalScope>
<RenderProfile id="ComposerContent">
<ComposerContent {...props} />
</RenderProfile>
</OverlayDismissalScope>
</ComposerKeyboardScopeProvider>
);
}
@@ -1432,6 +1459,52 @@ function ComposerContentImpl({
[onChangeText, textSource],
);
const isInputLive = useIsInputLive(messageInputRef);
const isPanelActive = useRetainedPanelActive();
const { isActiveComposer } = useComposerKeyboardScope();
const inputRevealed = isPanelActive && isActiveComposer;
const isActiveComposerRef = useRef(isActiveComposer);
isActiveComposerRef.current = isActiveComposer;
const isInputRevealed = useCallback(
() => isInputLive() && isActiveComposerRef.current,
[isInputLive],
);
const dismissOverlays = useDismissOverlays();
const latestFocusRef = useRef<Promise<void> | null>(null);
const takeInputFocus = useCallback(
(options?: ComposerFocusOptions) => {
const isFocused = () => messageInputRef.current?.isFocused() ?? false;
const place = () => {
messageInputRef.current?.focus();
if (options?.selection) messageInputRef.current?.setSelection(options.selection);
};
// The sheets, menus and modals this composer opened would otherwise cover the cursor, or take
// focus back as they close.
const request: Promise<void> = closedWithin(dismissOverlays(), OVERLAY_CLOSE_TIMEOUT_MS).then(
() => {
// A newer request decides where the cursor goes, and this one ends with it.
const latest = latestFocusRef.current;
if (latest && latest !== request) return latest;
if (isNative) {
place();
// A native input refuses focus without saying so, such as while dictation holds it.
if (!isFocused()) throw new Error("The composer input could not take focus.");
return;
}
return new Promise<void>((resolve, reject) => {
focusWithRetries({
focus: place,
isFocused,
onSuccess: resolve,
onTimeout: () => reject(new Error("The composer input could not take focus.")),
});
});
},
);
latestFocusRef.current = request;
return request;
},
[dismissOverlays],
);
const isComposerLockedRef = useRef(isComposerLocked);
isComposerLockedRef.current = isComposerLocked;
const composerInput = useMemo<ComposerInput>(
@@ -1439,6 +1512,9 @@ function ComposerContentImpl({
locked: isComposerLocked,
isLocked: () => isComposerLockedRef.current,
isLive: isInputLive,
revealed: inputRevealed,
isRevealed: isInputRevealed,
focus: takeInputFocus,
textSource,
setText: replaceUserInput,
insertText: insertUserInput,
@@ -1447,11 +1523,14 @@ function ComposerContentImpl({
}),
[
attachments,
inputRevealed,
insertUserInput,
isComposerLocked,
isInputLive,
isInputRevealed,
replaceUserInput,
setSelectedAttachments,
takeInputFocus,
textSource,
],
);
@@ -2640,7 +2719,7 @@ const styles = StyleSheet.create((theme: Theme) => ({
gap: theme.spacing[3],
},
cancelButton: {
...toolbarSlot(COMPOSER_TOOLBAR_SHAPES.disc),
...toolbarSlot(),
alignItems: "center",
justifyContent: "center",
},
@@ -2658,7 +2737,7 @@ const styles = StyleSheet.create((theme: Theme) => ({
gap: TOOLBAR_CONTROL_GAP,
},
contextWindowMeterSlot: {
...toolbarSlot(COMPOSER_TOOLBAR_SHAPES.ring),
...toolbarSlot(),
flexShrink: 0,
alignItems: "center",
justifyContent: "center",
@@ -2672,7 +2751,7 @@ const styles = StyleSheet.create((theme: Theme) => ({
justifyContent: "center",
},
realtimeVoiceButton: {
...toolbarSlot(COMPOSER_TOOLBAR_SHAPES.disc),
...toolbarSlot(),
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
+22 -9
View File
@@ -40,12 +40,12 @@ import {
type ToolbarGlyph,
toolbarSlot,
} from "@/components/ui/control-geometry";
import { bySizing } from "@/components/ui/control-sizing";
import {
COMPOSER_PADDING_BOTTOM,
COMPOSER_PADDING_HORIZONTAL,
COMPOSER_CORNER_RADIUS,
COMPOSER_PADDING_TOP,
COMPOSER_TOOLBAR_SHAPES,
composerToolbarOverhang,
resolveComposerLeadingEdge,
} from "@/composer/input/toolbar-rails";
@@ -225,9 +225,12 @@ export interface MessageInputProps {
export interface MessageInputRef {
focus: () => void;
blur: () => void;
isFocused: () => boolean;
getText: () => string;
getInputSnapshot: () => ComposerInputSnapshot;
replaceText: (text: string, selection?: { start: number; end: number }) => void;
/** Clamped to the text the input holds. */
setSelection: (selection: { start: number; end: number }) => void;
runKeyboardAction: (action: MessageInputKeyboardActionKind) => boolean;
/**
* Web-only: return the underlying DOM element for focus assertions/retries.
@@ -1486,6 +1489,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
focus: () => {
textInputRef.current?.focus();
},
isFocused: () => textInputRef.current?.isFocused() ?? false,
blur: () => {
textInputRef.current?.blur();
},
@@ -1493,6 +1497,12 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
getInputSnapshot: () =>
getComposerInputSnapshot(textInputRef.current, valueRef.current, selectionRef.current),
replaceText,
setSelection: (selection) => {
const text = textInputRef.current?.getText() ?? valueRef.current;
const clamped = resolveRetainedSelection(selection, text.length);
selectionRef.current = clamped;
textInputRef.current?.setSelection(clamped);
},
runKeyboardAction: (action) =>
runMessageInputKeyboardAction(action, {
focusInput: () => textInputRef.current?.focus(),
@@ -1983,7 +1993,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
const voiceButtonStyle = useCallback(
({ hovered, pressed }: { hovered?: boolean; pressed?: boolean }) => [
styles.voiceButton,
isDictating && styles.voiceButtonRecording,
Boolean(hovered || pressed) && !isDictating && styles.iconButtonHovered,
Boolean(pressed) && isDictating && styles.discPressed,
!isDictationStartEnabled && styles.buttonDisabled,
@@ -2238,7 +2247,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
voiceMuteToggleKeys={voiceMuteToggleKeys}
dictationToggleKeys={dictationToggleKeys}
/>
{sendButton}
<View style={styles.trailingDisc}>{sendButton}</View>
</View>
</View>
),
@@ -2470,8 +2479,16 @@ const styles = StyleSheet.create((theme: Theme) => ({
alignItems: "center",
gap: TOOLBAR_CONTROL_GAP,
},
// Touch-sized the row's highlights touch, but the disc is always painted, so a pressed neighbour
// keeps clear of it instead of merging with it.
trailingDisc: {
marginLeft: bySizing({ touch: theme.spacing[1.5], pointer: 0 }),
},
// Touch-sized the plus is all thin arms, so beside the picker's glyph the same gap reads wider.
// Its highlight can overlap the picker's: only one control is ever pressed.
attachButton: {
...toolbarSlot(COMPOSER_TOOLBAR_SHAPES.attach),
...toolbarSlot(),
marginRight: bySizing({ touch: -2, pointer: 0 }),
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
@@ -2483,14 +2500,11 @@ const styles = StyleSheet.create((theme: Theme) => ({
justifyContent: "center",
},
voiceButton: {
...toolbarSlot(COMPOSER_TOOLBAR_SHAPES.dictation),
...toolbarSlot(),
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
},
voiceButtonRecording: {
marginHorizontal: toolbarSlot(COMPOSER_TOOLBAR_SHAPES.disc).marginHorizontal,
},
voiceButtonRecordingDisc: {
width: TOOLBAR_DISC_SIZE,
height: TOOLBAR_DISC_SIZE,
@@ -2501,7 +2515,6 @@ const styles = StyleSheet.create((theme: Theme) => ({
},
sendButton: {
minWidth: TOOLBAR_DISC_SIZE,
marginHorizontal: toolbarSlot(COMPOSER_TOOLBAR_SHAPES.disc).marginHorizontal,
height: TOOLBAR_DISC_SIZE,
alignItems: "center",
justifyContent: "center",
@@ -2,16 +2,17 @@ import { describe, expect, it } from "vitest";
import { composerToolbarOverhang, resolveComposerLeadingEdge } from "./toolbar-rails";
describe("composer toolbar rails", () => {
it("keeps touch glyphs and labels on the text rails and nests a trailing disc into the corner", () => {
for (const edge of ["attach", "dictation", "voiceMode", "label", "none"] as const) {
expect(composerToolbarOverhang(edge, "touch")).toBe(0);
}
it("hangs each end highlight far enough to put its ink on the text rail and nests a trailing disc into the corner", () => {
// The 36pt highlight around the plus's ink (14/24 of a 20pt glyph, plus its 1.5 stroke).
expect(composerToolbarOverhang("attach", "touch")).toBeCloseTo((36 - (14 * 20) / 24 - 1.5) / 2);
expect(composerToolbarOverhang("label", "touch")).toBe(8);
expect(composerToolbarOverhang("none", "touch")).toBe(0);
// A 36pt disc centred in the 44pt row sits 8 from the bottom edge, so it sits 8 from the side.
expect(composerToolbarOverhang("disc", "touch")).toBe(8);
});
it("keeps the pointer row's flat overhang", () => {
for (const edge of ["attach", "dictation", "voiceMode", "disc", "label", "none"] as const) {
for (const edge of ["attach", "disc", "label", "none"] as const) {
expect(composerToolbarOverhang(edge, "pointer")).toBe(6);
}
});
@@ -1,14 +1,14 @@
import {
TOOLBAR_CONTROL_SIZES,
TOOLBAR_DISC_SIZES,
TOOLBAR_RINGS,
TOOLBAR_LABEL_PADDING,
toolbarGlyphsFor,
type ControlSizing,
} from "@/components/ui/control-geometry";
import { bySizing } from "@/components/ui/control-sizing";
/** The control at one end of the composer toolbar, which decides how far the row hangs out. */
export type ComposerToolbarEdge = "attach" | "label" | "dictation" | "voiceMode" | "disc" | "none";
export type ComposerToolbarEdge = "attach" | "label" | "disc" | "none";
/**
* The composer's padding. Its sides are the rails: the text column and the toolbar's outer
@@ -32,23 +32,12 @@ export const COMPOSER_PADDING_BOTTOM = bySizing({
pointer: COMPOSER_PADDING.pointer.bottom,
});
/** How much of Lucide's 24-unit grid each toolbar glyph's ink spans, centred in its box. */
const GLYPH_INK_SPANS = { plus: 14, mic: 14, audioLines: 20 } as const;
function touchGlyphInkWidth(span: number): number {
/** The plus glyph's ink: 14 of Lucide's 24 units, plus its stroke. */
function attachGlyphInkWidth(): number {
const glyph = toolbarGlyphsFor("touch").icon;
return (span * glyph.size) / 24 + glyph.strokeWidth;
return (14 * glyph.size) / 24 + glyph.strokeWidth;
}
/** The width each toolbar control paints when touch-sized; the row spaces these. */
export const COMPOSER_TOOLBAR_SHAPES = {
attach: touchGlyphInkWidth(GLYPH_INK_SPANS.plus),
dictation: touchGlyphInkWidth(GLYPH_INK_SPANS.mic),
voiceMode: touchGlyphInkWidth(GLYPH_INK_SPANS.audioLines),
ring: TOOLBAR_RINGS.touch.size,
disc: TOOLBAR_DISC_SIZES.touch,
} as const;
/** Pointer-sized the row hangs a flat 6px into the padding, which puts each 16pt glyph box on the rail. */
const POINTER_OVERHANG = 6;
@@ -68,10 +57,20 @@ export const COMPOSER_CORNER_RADIUS = bySizing({
pointer: 16,
});
/**
* Touch-sized each end control's highlight hangs past the rail far enough to put what it paints on
* the rail. The trailing disc paints its whole highlight, so it nests into the corner instead.
*/
const TOUCH_OVERHANGS: Record<ComposerToolbarEdge, number> = {
attach: (TOOLBAR_DISC_SIZES.touch - attachGlyphInkWidth()) / 2,
label: TOOLBAR_LABEL_PADDING,
disc: DISC_CORNER_NEST,
none: 0,
};
/** How far the composer toolbar hangs past the text column at one end. */
export function composerToolbarOverhang(edge: ComposerToolbarEdge, sizing: ControlSizing): number {
if (sizing === "pointer") return POINTER_OVERHANG;
return edge === "disc" ? DISC_CORNER_NEST : 0;
return sizing === "pointer" ? POINTER_OVERHANG : TOUCH_OVERHANGS[edge];
}
/** The control that starts the toolbar on the left. */
+8
View File
@@ -17,6 +17,12 @@ export interface ComposerState {
metadata: Record<string, JsonValue>;
}
/** Where the cursor goes when a composer takes focus. */
export interface ComposerFocusOptions {
/** Offsets into the prompt, clamped to its length. Omit to keep the cursor where it is. */
selection?: { start: number; end: number };
}
/**
* A composer as plugins use it: its state, a way to hear about changes, and the commands that
* change it. The commands enforce the rules the built-in controls follow (a draft being created
@@ -29,6 +35,8 @@ export interface ComposerModel {
setPrompt(text: string): void;
/** Inserts at the cursor, replacing the selection. */
insertText(text: string): void;
/** Puts the cursor in the input. Resolves once it is there; rejects when it cannot get there. */
focus(options?: ComposerFocusOptions): Promise<void>;
changeAttachments(updater: (current: UserComposerAttachment[]) => UserComposerAttachment[]): void;
setProvider(provider: string): Promise<void>;
setModel(modelId: string): Promise<void>;
+9
View File
@@ -1,6 +1,7 @@
import { createContext, useContext, type ReactNode } from "react";
import type { JsonValue, ProviderOptions } from "@getpaseo/protocol/agent-types";
import type { UserComposerAttachment } from "@/attachments/types";
import type { ComposerFocusOptions } from "@/composer/model";
import type { ComposerTextSource } from "@/composer/text-source";
/**
@@ -17,6 +18,14 @@ export interface ComposerInput {
* cannot take the cursor-aware commands below.
*/
isLive: () => boolean;
/**
* The input is showing and nothing stands between it and the keyboard: its pane is focused and
* no compact overlay panel covers it. A new value of `revealed` is a new `ComposerInput`, which
* is how a request waiting for the input to come on screen hears about it.
*/
revealed: boolean;
isRevealed: () => boolean;
focus: (options?: ComposerFocusOptions) => Promise<void>;
textSource: ComposerTextSource;
setText: (text: string) => void;
/** Inserts at the cursor, replacing the selection. */
@@ -385,6 +385,16 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
console.warn("[Session] timeline replacement refresh failed", { serverId, error });
});
};
const onRewind = (message: SessionOutboundMessage) => {
if (message.type !== "agent.timeline.rewind") return;
const { agentId, epoch, seq, retireFromSeq } = message.payload;
sync.applyStreamEvent(agentId, {
event: { type: "rewind", retireFromSeq },
seq,
epoch,
timestamp: new Date(),
});
};
const sync = getHostRuntimeStore().createViewedTimelineOwner(serverId, {
canCheckFreshness: () =>
client.getLastServerInfoMessage()?.features?.timelineFreshnessCheck === true,
@@ -395,6 +405,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
update: (message) => {
if (message.type === "agent_stream") onStream(message);
else if (message.type === "agent.timeline.replacement") onReplacement(message);
else if (message.type === "agent.timeline.rewind") onRewind(message);
},
});
return observation;
+200
View File
@@ -2,6 +2,8 @@ import type { DiffState, OwnedSubscription, SubscriptionObserver } from "@getpas
import { QueryClient, QueryObserver, skipToken } from "@tanstack/react-query";
import { describe, expect, it } from "vitest";
import type { MutableDaemonConfig, SessionOutboundMessage } from "@getpaseo/protocol/messages";
import { deriveCheckoutDiffResult, type CheckoutDiffSnapshot } from "@/git/diff-snapshot";
import { shareCheckoutDiff } from "@/git/diff-sharing";
import { checkoutDiffQueryKey } from "@/git/query-keys";
import { buildTerminalsQueryKey } from "@/screens/workspace/terminals/state";
import { daemonConfigQueryKey } from "@/data/daemon-config";
@@ -34,6 +36,7 @@ type RouterMessage =
type RouterMessageType = RouterMessage["type"];
type RouterHandler = (message: RouterMessage) => void;
type RouterClient = Parameters<typeof mountServerDataPushRouter>[0]["client"];
type DiffFile = SubscribeCheckoutDiffResponseMessage["payload"]["files"][number];
const daemonConfig: MutableDaemonConfig = {
relay: { enabled: false },
@@ -233,6 +236,49 @@ function createFakeClient(config: { rejectCheckoutDiffSubscribe?: boolean } = {}
};
}
/** Matches the real diff observation: nothing is delivered until the stream publishes. */
type SubscribedDiffState = DiffState & { subscriptionId: string };
function createDiffStreamClient() {
const subscriptions: Array<{ publish(state: DiffState): void }> = [];
const noSubscription = {
subscriptionId: "none",
ready: Promise.resolve(undefined as never),
subscribe: () => () => {},
release: async () => {},
};
const client: RouterClient = {
async getProvidersSnapshot() {
throw new Error("Unexpected snapshot pull");
},
observeEvents: () => noSubscription,
observeTerminals: () => noSubscription,
observeDiff() {
const observers = new Set<SubscriptionObserver<SubscribedDiffState>>();
let released = false;
const subscriptionId = `diff-${subscriptions.length + 1}`;
subscriptions.push({
publish: (state) => {
if (released) return;
for (const observer of observers) observer.snapshot({ ...state, subscriptionId });
},
});
return {
subscriptionId,
ready: new Promise<never>(() => {}),
subscribe: (observer: SubscriptionObserver<SubscribedDiffState>) => {
observers.add(observer);
return () => observers.delete(observer);
},
release: async () => {
released = true;
},
};
},
};
return { client, subscriptions, latest: () => subscriptions[subscriptions.length - 1]! };
}
function providerUpdate(generatedAt: string): ProvidersSnapshotUpdateMessage {
return {
type: "providers_snapshot_update",
@@ -388,6 +434,160 @@ describe("server data push router", () => {
}
});
describe("a diff reopened from a retained panel", () => {
const serverId = "server-1";
const cwd = "/repo";
const queryKey = checkoutDiffQueryKey(serverId, cwd, "uncommitted", undefined, false);
function diffFile(content: string): DiffFile {
return {
path: "README.md",
isNew: false,
isDeleted: false,
additions: 1,
deletions: 0,
hunks: [
{
oldStart: 1,
oldCount: 1,
newStart: 1,
newCount: 1,
lines: [{ type: "add", content, tokens: [{ text: content, style: null }] }],
},
],
};
}
function loadingState(revision: number): DiffState {
return {
phase: "loading",
revision,
entries: [
{
path: "README.md",
oldPath: null,
change: "modified",
content: "text",
additions: 1,
deletions: 0,
},
],
};
}
function readyState(revision: number, files: DiffFile[]): DiffState {
return {
phase: "ready",
revision,
resolved: { baseSha: "", headSha: "", mergeBaseSha: "" },
files,
diffTooLarge: false,
};
}
function mountChangesPanel(fake: ReturnType<typeof createDiffStreamClient>) {
const queryClient = new QueryClient();
const route = (enabled: boolean) => ({
queryKey,
queryFn: skipToken as typeof skipToken,
gcTime: Infinity,
structuralSharing: shareCheckoutDiff,
meta: checkoutDiffPushRoute({
enabled,
serverId,
cwd,
subscriptionId: `checkoutDiff:${JSON.stringify(queryKey)}`,
compare: { mode: "uncommitted", ignoreWhitespace: false },
}),
});
const observer = new QueryObserver(queryClient, route(true));
const unsubscribeObserver = observer.subscribe(() => {});
const unmountRouter = mountServerDataPushRouter({
queryClient,
client: fake.client,
serverId,
});
return {
hide: () => observer.setOptions(route(false)),
show: () => observer.setOptions(route(true)),
// What the Changes pane renders from.
diff: () =>
deriveCheckoutDiffResult(
queryClient.getQueryData<CheckoutDiffSnapshot>(queryKey) ?? null,
),
unmount: () => {
unsubscribeObserver();
unmountRouter();
queryClient.clear();
},
};
}
it("keeps the painted diff through the reload when the daemon returns the same content", () => {
const fake = createDiffStreamClient();
const panel = mountChangesPanel(fake);
try {
fake.latest().publish(readyState(1, [diffFile("same")]));
const painted = panel.diff().files;
expect(fake.subscriptions).toHaveLength(1);
panel.hide();
panel.show();
expect(fake.subscriptions).toHaveLength(2);
fake.latest().publish(loadingState(1));
expect(panel.diff()).toMatchObject({ isLoading: false, entries: null });
expect(panel.diff().files).toBe(painted);
fake.latest().publish(readyState(1, [diffFile("same")]));
expect(panel.diff().isLoading).toBe(false);
expect(panel.diff().files).toBe(painted);
} finally {
panel.unmount();
}
});
it("replaces the painted diff in one step when the daemon returns changed content", () => {
const fake = createDiffStreamClient();
const panel = mountChangesPanel(fake);
try {
fake.latest().publish(readyState(1, [diffFile("before")]));
const painted = panel.diff().files;
panel.hide();
panel.show();
fake.latest().publish(loadingState(1));
expect(panel.diff().isLoading).toBe(false);
expect(panel.diff().files).toBe(painted);
const changed = [diffFile("after")];
fake.latest().publish(readyState(1, changed));
expect(panel.diff().isLoading).toBe(false);
expect(panel.diff().files).toEqual(changed);
} finally {
panel.unmount();
}
});
it("reports loading with the announced entries when no content has been painted", () => {
const fake = createDiffStreamClient();
const panel = mountChangesPanel(fake);
try {
expect(panel.diff().isLoading).toBe(true);
fake.latest().publish(loadingState(1));
expect(panel.diff()).toMatchObject({ isLoading: true, files: [] });
expect(panel.diff().entries).toHaveLength(1);
fake.latest().publish(readyState(1, [diffFile("first")]));
expect(panel.diff()).toMatchObject({ isLoading: false, entries: null });
expect(panel.diff().files).toHaveLength(1);
} finally {
panel.unmount();
}
});
});
it("routes provider snapshot and daemon config payloads until detached", async () => {
const queryClient = new QueryClient();
const fake = createFakeClient();
+8
View File
@@ -25,6 +25,10 @@ export function shareCheckoutDiff(previous: unknown, incoming: unknown): DiffPay
const next = incoming as DiffPayload;
const old = previous as DiffPayload | undefined;
if (!old || old === next) return next;
// A new subscription announces its entries before the bodies arrive. That is
// progress on the first load only: once a diff has settled it stays on screen
// until the next revision commits, so an unchanged diff never repaints.
if (isLoadingSnapshot(next) && !isLoadingSnapshot(old)) return old;
const files = retainDiffFiles(old.files, next.files);
const error = replaceEqualDeep(old.error, next.error);
return files === old.files &&
@@ -37,6 +41,10 @@ export function shareCheckoutDiff(previous: unknown, incoming: unknown): DiffPay
: { ...next, files, error };
}
function isLoadingSnapshot(payload: DiffPayload): boolean {
return payload.entries !== undefined;
}
export function shareCommitFileDiff(
previous: unknown,
incoming: unknown,
@@ -166,7 +166,7 @@ describe("ensureAgentIsInitialized", () => {
kind: "assistant_message",
id: "canonical-item",
text: "Canonical before restart",
timelineCursor: { epoch: "epoch-1", seq: 12 },
timelineCursor: { epoch: "epoch-1", seq: 12, anchorSeq: 12 },
timestamp: new Date("2026-07-27T10:00:00.000Z"),
},
],
+4
View File
@@ -722,6 +722,8 @@ export const ar: TranslationResources = {
resumeCommandUnavailable: "أمر الاستئناف غير متوفر",
reloadingAgent: "وكيل إعادة التحميل...",
reloadedAgent: "وكيل إعادة تحميل",
reloadedAgentHistoryDiverged:
"تمت إعادة تحميل الوكيل، لكن سجل المزوّد لا يطابق هذه المحادثة، لذا لم تتم مزامنته",
failedToReloadAgent: "فشل في إعادة تحميل الوكيل",
failedToCloseAgent: "فشل في إغلاق الوكيل",
},
@@ -1177,6 +1179,8 @@ export const ar: TranslationResources = {
title: "ما الجديد",
installed: "مثبّت",
showMore: "عرض المزيد",
showAllChanges: "عرض كل التغييرات",
hideAllChanges: "إخفاء كل التغييرات",
openWebsite: "سجل التغييرات الكامل",
error: {
title: "تعذّر تحميل سجل التغييرات",
+4
View File
@@ -719,6 +719,8 @@ export const en = {
resumeCommandUnavailable: "Resume command not available",
reloadingAgent: "Reloading agent...",
reloadedAgent: "Reloaded agent",
reloadedAgentHistoryDiverged:
"Reloaded agent, but the provider's history doesn't match this chat, so it wasn't synced",
failedToReloadAgent: "Failed to reload agent",
failedToCloseAgent: "Failed to close agent",
},
@@ -1185,6 +1187,8 @@ export const en = {
title: "What's new",
installed: "Installed",
showMore: "Show more",
showAllChanges: "Show all changes",
hideAllChanges: "Hide all changes",
openWebsite: "Full changelog",
error: {
title: "Unable to load the changelog",
+4
View File
@@ -729,6 +729,8 @@ export const es: TranslationResources = {
resumeCommandUnavailable: "Comando de reanudación no disponible",
reloadingAgent: "Agente de recarga...",
reloadedAgent: "Agente recargado",
reloadedAgentHistoryDiverged:
"Agente recargado, pero el historial del proveedor no coincide con este chat, así que no se sincronizó",
failedToReloadAgent: "No se pudo recargar el agente",
failedToCloseAgent: "No se pudo cerrar el agente",
},
@@ -1213,6 +1215,8 @@ export const es: TranslationResources = {
title: "Novedades",
installed: "Instalada",
showMore: "Mostrar más",
showAllChanges: "Mostrar todos los cambios",
hideAllChanges: "Ocultar todos los cambios",
openWebsite: "Registro de cambios completo",
error: {
title: "No se pudo cargar el registro de cambios",
+4
View File
@@ -729,6 +729,8 @@ export const fr: TranslationResources = {
resumeCommandUnavailable: "Commande de reprise indisponible",
reloadingAgent: "Rechargement de l’agent…",
reloadedAgent: "Agent rechargé",
reloadedAgentHistoryDiverged:
"Agent rechargé, mais l’historique du fournisseur ne correspond pas à cette conversation, il n’a donc pas été synchronisé",
failedToReloadAgent: "Impossible de recharger l’agent",
failedToCloseAgent: "Impossible de fermer l’agent",
},
@@ -1206,6 +1208,8 @@ export const fr: TranslationResources = {
title: "Nouveautés",
installed: "Installée",
showMore: "Afficher plus",
showAllChanges: "Afficher toutes les modifications",
hideAllChanges: "Masquer toutes les modifications",
openWebsite: "Journal des modifications complet",
error: {
title: "Impossible de charger le journal des modifications",
+4
View File
@@ -729,6 +729,8 @@ export const ja: TranslationResources = {
resumeCommandUnavailable: "再開コマンドが利用できません",
reloadingAgent: "エージェントを再読み込み中...",
reloadedAgent: "エージェントを再読み込みしました",
reloadedAgentHistoryDiverged:
"エージェントを再読み込みしましたが、プロバイダーの履歴がこのチャットと一致しないため同期しませんでした",
failedToReloadAgent: "エージェントの再読み込みに失敗しました",
failedToCloseAgent: "エージェントを閉じられませんでした",
},
@@ -1190,6 +1192,8 @@ export const ja: TranslationResources = {
title: "新着情報",
installed: "インストール済み",
showMore: "もっと見る",
showAllChanges: "すべての変更を表示",
hideAllChanges: "すべての変更を非表示",
openWebsite: "変更履歴をすべて表示",
error: {
title: "変更履歴を読み込めません",
+4
View File
@@ -724,6 +724,8 @@ export const ko: TranslationResources = {
resumeCommandUnavailable: "재개 명령을 사용할 수 없습니다",
reloadingAgent: "에이전트 다시 로드 중...",
reloadedAgent: "에이전트를 다시 로드했습니다",
reloadedAgentHistoryDiverged:
"에이전트를 다시 로드했지만 제공자 기록이 이 채팅과 일치하지 않아 동기화하지 않았습니다",
failedToReloadAgent: "에이전트를 다시 로드하지 못했습니다",
failedToCloseAgent: "에이전트를 닫지 못했습니다",
},
@@ -1184,6 +1186,8 @@ export const ko: TranslationResources = {
title: "새로운 소식",
installed: "설치됨",
showMore: "더 보기",
showAllChanges: "모든 변경 사항 보기",
hideAllChanges: "모든 변경 사항 숨기기",
openWebsite: "전체 변경 내역",
error: {
title: "변경 내역을 불러오지 못했습니다",
+4
View File
@@ -727,6 +727,8 @@ export const ptBR: TranslationResources = {
resumeCommandUnavailable: "Comando de retomada indisponível",
reloadingAgent: "Recarregando agente...",
reloadedAgent: "Agente recarregado",
reloadedAgentHistoryDiverged:
"Agente recarregado, mas o histórico do provedor não corresponde a este chat, então não foi sincronizado",
failedToReloadAgent: "Falha ao recarregar agente",
failedToCloseAgent: "Falha ao fechar agente",
},
@@ -1203,6 +1205,8 @@ export const ptBR: TranslationResources = {
title: "Novidades",
installed: "Instalada",
showMore: "Mostrar mais",
showAllChanges: "Mostrar todas as mudanças",
hideAllChanges: "Ocultar todas as mudanças",
openWebsite: "Changelog completo",
error: {
title: "Não foi possível carregar o changelog",
+4
View File
@@ -729,6 +729,8 @@ export const ru: TranslationResources = {
resumeCommandUnavailable: "Команда возобновления недоступна",
reloadingAgent: "Перезагрузка агента...",
reloadedAgent: "Агент перезагружен",
reloadedAgentHistoryDiverged:
"Агент перезагружен, но история провайдера не совпадает с этим чатом, поэтому она не синхронизирована",
failedToReloadAgent: "Не удалось перезагрузить агента",
failedToCloseAgent: "Не удалось закрыть агента",
},
@@ -1194,6 +1196,8 @@ export const ru: TranslationResources = {
title: "Что нового",
installed: "Установлена",
showMore: "Показать ещё",
showAllChanges: "Показать все изменения",
hideAllChanges: "Скрыть все изменения",
openWebsite: "Полный список изменений",
error: {
title: "Не удалось загрузить список изменений",
+4
View File
@@ -722,6 +722,8 @@ export const zhCN: TranslationResources = {
resumeCommandUnavailable: "恢复命令不可用",
reloadingAgent: "正在重新加载 Agent...",
reloadedAgent: "已重新加载 Agent",
reloadedAgentHistoryDiverged:
"已重新加载 Agent,但提供方的历史记录与此对话不一致,因此未同步",
failedToReloadAgent: "重新加载 Agent 失败",
failedToCloseAgent: "关闭 Agent 失败",
},
@@ -1169,6 +1171,8 @@ export const zhCN: TranslationResources = {
title: "新功能",
installed: "已安装",
showMore: "显示更多",
showAllChanges: "显示全部变更",
hideAllChanges: "收起全部变更",
openWebsite: "完整更新日志",
error: {
title: "无法加载更新日志",
+8 -3
View File
@@ -81,6 +81,7 @@ import {
import { WorkspaceDraftComposer } from "@/composer/draft/workspace-tab";
import { type DraftConversation, useDraftTimeline } from "@/composer/draft/timeline";
import { AgentTracks } from "@/panels/agent-tracks";
import { OverlayDismissalScope } from "@/components/ui/overlay-dismissal";
import { ParentAgentCallout, useParentAgentId } from "@/subagents/parent";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import { buildDraftStoreKey, generateDraftId } from "@/stores/draft-keys";
@@ -1317,10 +1318,14 @@ function ChatSurface({
disabled: boolean;
fullscreen: boolean;
}) {
// The tracks above the composer open their pages from outside it, so focusing the composer
// closes everything the chat opened.
return (
<FileDropZone style={styles.container} disabled={disabled}>
<ComposerDock fullscreen={fullscreen}>{children}</ComposerDock>
</FileDropZone>
<OverlayDismissalScope>
<FileDropZone style={styles.container} disabled={disabled}>
<ComposerDock fullscreen={fullscreen}>{children}</ComposerDock>
</FileDropZone>
</OverlayDismissalScope>
);
}
+5
View File
@@ -74,6 +74,11 @@ export function usePaneContext(): PaneContextValue {
return value;
}
/** The host of the pane around this component, or null on a screen with no panes. */
export function usePaneHost(): PaneHost | null {
return useContext(PaneContext)?.host ?? null;
}
export function usePaneFocus(): PaneFocusContextValue {
const value = useContext(PaneFocusContext);
invariant(value, "PaneFocusContext is required");

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