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 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
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
275 changed files with 10634 additions and 7912 deletions
+6 -15
View File
@@ -1,17 +1,17 @@
# Changelog
## 0.16.0-beta.2 - 2026-11-11
## 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))
- **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))
- **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))
- **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))
- **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))
- **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))
### Added
@@ -30,8 +30,6 @@
- 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))
- Changed Rename workspace in the sidebar on desktop and wide web layouts to edit the title in place instead of opening a dialog ([#9280](https://github.com/getpaseo/paseo/pull/9280))
- Changed What's new and the website changelog to show a release's highlights first, with the full list behind Show all changes ([#9281](https://github.com/getpaseo/paseo/pull/9281))
### Fixed
@@ -39,7 +37,6 @@
- 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 a long user message showing without its fade and Show more for one frame when a chat opened ([#9302](https://github.com/getpaseo/paseo/pull/9302))
- 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))
@@ -51,7 +48,6 @@
- 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 unarchiving a workspace not restoring the agents that were archived with it ([#9210](https://github.com/getpaseo/paseo/pull/9210))
- 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))
@@ -77,13 +73,11 @@
- 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 Codex reconnect attempts and warnings not appearing in the timeline ([#9370](https://github.com/getpaseo/paseo/pull/9370) by [@whatamelon](https://github.com/whatamelon))
- 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 resumed pi-subagents runs missing from the Subagents track ([#9261](https://github.com/getpaseo/paseo/pull/9261) by [@xxachangxx](https://github.com/xxachangxx))
- 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))
@@ -115,13 +109,10 @@
### 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))
- Added an agent's background task count (`backgroundWork.count`) and `background` flag to plugin agent snapshots, and a `background` flag to agent hooks ([#9362](https://github.com/getpaseo/paseo/pull/9362))
- 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 a plugin the daemon terminated for an invalid message staying listed with no exit reported ([#9285](https://github.com/getpaseo/paseo/pull/9285) by [@Brandon168](https://github.com/Brandon168))
- Fixed ACP provider plugins dropping the token usage an agent reports in its prompt response ([#9396](https://github.com/getpaseo/paseo/pull/9396) by [@Myasuka](https://github.com/Myasuka))
- 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))
+6 -15
View File
@@ -1,17 +1,17 @@
# Changelog
## 0.16.0-beta.2 - 2026-11-11
## 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))
- **插件侧边栏视图**:插件可以用自己的布局替换侧边栏内容,展示所有已连接主机上的工作区、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))
- **更快的 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))
- **细节打磨**:一轮对话结束时聊天保持不动,返回没有变化的聊天时直接显示原样,无需刷新。工具行从第一帧起就显示最终标题和图标,上下文窗口浮层打开时用量已加载完成,侧边栏悬停卡片一次只打开一个。 ([#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))
- **返回时更少重连**:返回应用时会保留仍可用的连接,包括正在传输大型响应的连接,只在必要时重连一次。主机保存了多个连接时,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))
- **返回时更少重连**:返回应用时会保留仍可用的连接,包括正在传输大型响应的连接,只在必要时重连一次。主机保存了多个连接时,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))
### 新增
@@ -30,8 +30,6 @@
- 斜杠命令菜单现在显示加载和错误状态并提供“重试”,且不再默认选中 `/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))
- 在桌面端和宽屏网页布局中,侧边栏的“重命名 workspace”改为在原处编辑标题,不再打开对话框 ([#9280](https://github.com/getpaseo/paseo/pull/9280))
- “更新内容”和网站更新日志改为先显示每个版本的亮点,完整列表收在“显示全部变更”之后 ([#9281](https://github.com/getpaseo/paseo/pull/9281))
### 修复
@@ -39,7 +37,6 @@
- 修复包含较短折叠轮次的聊天打开时留有空白、需滚动后才填满的问题 ([#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))
- 修复聊天打开时,较长的用户消息有一帧不显示渐隐效果和“展开”的问题 ([#9302](https://github.com/getpaseo/paseo/pull/9302))
- 修复紧跟在段落之后、中间没有空行的 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))
@@ -51,7 +48,6 @@
- 修复从侧边栏和“新建工作区”点击“添加主机”会跳转到“设置”的问题 ([#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))
- 修复取消归档工作区时未恢复随之归档的 Agent 的问题 ([#9210](https://github.com/getpaseo/paseo/pull/9210))
- 修复流式回复期间聊天一滚动上下文窗口浮层就自行关闭的问题 ([#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))
@@ -77,13 +73,11 @@
- 修复 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))
- 修复 Codex 的重连尝试和警告未显示在时间线中的问题 ([#9370](https://github.com/getpaseo/paseo/pull/9370),贡献者:[@whatamelon](https://github.com/whatamelon))
- 修复 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))
- 修复恢复的 pi-subagents 运行未出现在 Subagent 栏中的问题 ([#9261](https://github.com/getpaseo/paseo/pull/9261),贡献者:[@xxachangxx](https://github.com/xxachangxx))
- 修复向进程已退出的 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))
@@ -115,13 +109,10 @@
### 插件
- 新增插件订阅,可将 daemon 的实时事件流式推送到插件面板;插件 RPC 和事件支持二进制(`Uint8Array`)负载 ([#8527](https://github.com/getpaseo/paseo/pull/8527))
- 插件的 Agent 快照新增后台任务数量(`backgroundWork.count`)和 `background` 标记,Agent 钩子新增 `background` 标记 ([#9362](https://github.com/getpaseo/paseo/pull/9362))
- “设置 → 插件”改为通过齿轮按钮打开插件设置,每个设置页面对应一个标签页 ([#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))
- 修复 daemon 因无效消息终止插件后,该插件仍留在列表中且未报告退出的问题 ([#9285](https://github.com/getpaseo/paseo/pull/9285),贡献者:[@Brandon168](https://github.com/Brandon168))
- 修复 ACP Provider 插件丢弃 Agent 在提示词响应中报告的 token 用量的问题 ([#9396](https://github.com/getpaseo/paseo/pull/9396),贡献者:[@Myasuka](https://github.com/Myasuka))
- 修复已发布的 `@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))
+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 |
+10 -10
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.
@@ -257,7 +258,7 @@ Workspace archive is a separate lifecycle. Archiving or removing a worktree can
agent record without setting the agent's `archivedAt`, while its `workspaceId` still points at the
archived workspace. History navigation must not infer workspace lifecycle from `agent.archivedAt`
or mutate either lifecycle. The workspace route asks the daemon for authoritative recovery state;
the route exposes explicit Unarchive and Restore actions.
only the route's explicit Unarchive or Restore action changes the archived workspace.
Workspace archive is undoable from the archived toast, or with Cmd/Ctrl+Z, while the toast is up.
The daemon runs one archive lane per workspace, shared by every connection
@@ -281,12 +282,9 @@ does not flip it back. Tabs and layout are purged only once the undo window clos
archive committed.
History navigation opens the selected agent without changing either archive state. Workspace
**Unarchive**, **Restore**, Undo, and reopening the directory recover the agents closed by that
workspace archive, including
after a daemon restart or after the toast expires. Agents archived individually beforehand stay
archived. A failed worktree recreation leaves the workspace and its agents archived for retry.
Each recovered agent runs the provider's native unarchive hook before interactive resume and
history hydration.
**Restore** recovers only the workspace; the selected archived agent stays open with its callout.
The agent's **Unarchive** runs the provider's native unarchive hook before interactive resume and
history hydration. Other archived agents stay archived.
Opening an agent is a navigation choice, independent of whether its details are cached. The
layout retains that choice across reload while the panel fetches the agent from the daemon.
@@ -335,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
-13
View File
@@ -39,19 +39,6 @@ The trigger can only stay put when the list has scroll room for the change. When
content fits the viewport, or a collapse at the end would need an offset past the end, the list
ends at its end and the trigger moves by the difference.
## State that survives a remount
The list mounts a row again whenever it opens the chat, brings a web row back into the history
window, or reassigns a native row's LegendList container. State held in the row resets each time.
Keep a disclosure's open state in the per-agent store the timeline provides, keyed by item id:
`ToolCallExpansionContext` for tool calls, `UserMessageExpansionContext` for long user messages.
Decide a row's first painted shape in the commit that mounts it. `MessagePreview` measures its body
in a layout effect, which commits before paint on web and Fabric. `onLayout` lands after the first
frame: a long message measured there paints capped with no fade or show more, then grows by the
control's height a frame later. `onLayout` still tracks later size changes. A zero height comes from
a hidden panel and is ignored.
## Web
`holdRow` pins the row's trigger edge in the reading anchor and runs `apply`; the next reading
-9
View File
@@ -112,15 +112,6 @@ need an artifact. `android:clear` removes the generated Android project.
Run `npm run test:android --workspace=@getpaseo/app` explicitly with a local Android toolchain to prebuild Android and run native module unit tests. Ordinary CI does not prebuild Android or run these tests.
With an emulator or device connected, run the hardware keyboard's window-dispatch regression tests
from `packages/app/android`:
```bash
./gradlew :paseo-hardware-keyboard:connectedDebugAndroidTest --no-daemon --max-workers=2 -Dorg.gradle.parallel=false
```
These check terminal arrow ownership, held-key repeats and modifiers, and ordinary text-field editing.
### Diagnostic logs without ADB
In a dogfood build, open Android's Files app → Downloads → **Paseo diagnostics** and share all
+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 |
+8
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`
-5
View File
@@ -85,11 +85,6 @@ Escape interrupts an agent only in the message input or an unclaimed surface.
A focused terminal excludes that binding. The source leaves terminal Escape alone;
terminal Escape/Ctrl forwarding is a separate feature.
Terminal arrows never pass through the shortcut pipeline. The Android hardware
module claims them while the terminal's hidden input (`nativeID`) owns the focus.
`TextInput.onKeyPress` only sees IME keys, and an arrow that falls through moves
the hidden caret and desyncs the next text change, so the pipeline cannot own them.
## Keys shared with text editing
A binding on a key the platform already uses for editing, such as Cmd/Ctrl+Z for
+1 -1
View File
@@ -160,7 +160,7 @@ Use OpenCode v2 execution events to trigger turn completion, with active-state a
V2.0.4 also removed the activation endpoint that gated a cold location, and a cold location registers its config-derived commands, skills, and providers asynchronously. Wait until `plugin.list` returns a populated inventory before reading the catalog or commands; an empty inventory means the location is still warming. Fail when the readiness deadline expires, including when an inventory request stalls.
Paseo installs its OpenCode tool bridge through `OPENCODE_CONFIG_CONTENT`. V1 accepts a plugin file; v2 silently skips configured files and requires a package directory with a server entry point. Both versions use the daemon's private loopback bridge for caller-scoped tools. The global manifest must use agent-scoped schemas before a session is bound, because every bridge call executes in an agent session. V2 discovers definitions from each bound session's catalog: plugin-contributed tools can be absent from the global manifest, and an allowlist cannot register a missing definition. Registration is location-wide, so session filtering and caller-scoped execution must remain in place. Bridge context lives only in daemon memory and is removed when the Paseo session closes. The content-addressed plugin artifacts contain no session data or secrets. V2 also needs this plugin when native Paseo tools are disabled: its prompt API has no structured-output format, so the plugin supplies a schema-validated final-answer tool.
Paseo installs its OpenCode tool bridge through `OPENCODE_CONFIG_CONTENT`. V1 accepts a plugin file; v2 silently skips configured files and requires a package directory with a server entry point. Both versions use the daemon's private loopback bridge for caller-scoped tools. The global manifest must use agent-scoped schemas before a session is bound, because every bridge call executes in an agent session. Bridge context lives only in daemon memory and is removed when the Paseo session closes. The content-addressed plugin artifacts contain no session data or secrets. V2 also needs this plugin when native Paseo tools are disabled: its prompt API has no structured-output format, so the plugin supplies a schema-validated final-answer tool.
An agent with custom environment variables or user-configured MCP servers gets a dedicated OpenCode server. V2 supports session environment, but its MCP configuration remains location-scoped, so separate servers still prevent one agent from changing another agent's MCP setup. Configure custom MCP with `mcp.add`; do not follow it with `mcp.connect`, which only toggles config-backed servers.
@@ -1,67 +0,0 @@
# Pi resumed subagents — #9200
## Scope and provenance
An AI agent ran this QA through Playwright/CDP attached to the **real macOS Electron renderer**, using an isolated desktop-managed daemon. The user authorized the run; this is not a claim that the user personally operated the UI. No production daemon, installed package, or daily host configuration was changed.
Desktop evidence was captured on the modified **0.15.3 development checkout** at base `e260f93cc7b6098f6b175bd0e5c7eb24d3f03cf4`, with Electron 44.2.0, Pi 1.5.0, and installed pi-subagents 0.86.0. The final branch was subsequently fast-forwarded to `d46ee6558c043f9fb377d20f05ff662ce27a31f6` (0.15.4). The adapter and test file stayed byte-for-byte identical. The lockfile changes only workspace version references; third-party dependency entries did not change. Server declarations were rebuilt and the checks below passed on the final base. **These screenshots are not a 0.15.4 release-package test.**
## Before and automated checks
Issue #9200 reports ordinary async spawn appearing in the track while successive successful resumes disappear. The original adapter rejects any `action` and requires top-level `agent`/`task`, which a resume does not supply. A separate file-only transcript deduplication also prevents a new attempt row from following Pi's reused session file.
[Captured command output](validation.txt) contains:
- RED on the original adapter: a successful resume returns no descriptor instead of a running row.
- RED on the intermediate implementation with old file-only deduplication: the next resumed row gets no transcript.
- Final **21/21** tests passing, including consecutive resumes with a real shared temporary JSONL, polling, notice/replay routing, late old-notice isolation, `bg_wait`, failed/management calls, missing launch identity, and the task alias. Existing spawn fixtures still pass.
- Full `npm run format`, `npm run typecheck`, `npm run lint`, and `git diff --check` passing after the base update.
Only the specific adapter test file was run, not the full suite. CI has not been run. There is no old-desktop before screenshot; the reported failure and the two actual RED runs are the before evidence.
## Real desktop steps and observations
1. Built server/app dependencies and launched the existing desktop dev runner with a separate home, Electron userData, Pi configuration/session directory, and async root. The final endpoints were loopback-only: daemon 6768, Metro 8082, and CDP 9223. The renderer's desktop bridge confirmed its own managed daemon/home/server identity. Only the existing pi-subagents extension and a marker-only test agent were enabled; child tasks used `sleep`/`printf`, not workspace edits.
2. Created a Pi parent through the normal Paseo **CLI/API**, asking it to call `subagent({agent:"qa-marker", task, async:true})`. This produced A. The model first enabled the subagent tool, then performed the ordinary spawn. This step was not submitted through the composer.
3. Used the real Electron **composer** to ask Pi for `subagent({action:"resume", id:A, message})`, then resumed B the same way to produce C. No top-level `agent`, `task`, or `async` was supplied to these resume calls. All three finished with their requested markers.
4. Opened the provider-owned track while C ran: it showed `1 working`, the follow-up description, and `qa-marker`. Opening that row showed the reused child conversation and the current Shell call. Each resume had a new run/async directory, while all attempts reused the same session JSONL.
5. While C was still running, clicked **Archive finished subagents**. Completed A/B rows disappeared, while C remained visible as `1 working`.
6. Submitted a nonexistent resume ID through the composer. Pi returned an error and the parent reported “not found”; no new row or async run appeared.
7. The first B screenshot was taken after B finished because screenshot capture waited for fonts. It is not offered as running-state proof. A supplementary real resume from A, B_OBSERVE, was captured as `1 working` and opened successfully. It also finished. The final track contained exactly the four successful runs, not a fifth phantom from the failed resume.
The [selected real tool arguments/results, final statuses, and four completion notices](desktop-observations.json) preserve the test UUIDs. B's `revivedFrom` is A; C's is B. This file contains selected fields from persisted Pi entries/status files, not synthesized launch details. The [dialogue DOM excerpt](dialogue-excerpt.txt) records opening a resumed child. Test-root paths are replaced with `<QA_ROOT>` and the encoded workspace directory with `<QA_WORKSPACE>`; no auth or models configuration is included.
### Screenshots
Unmodified captures, except for the explicitly described dialogue crop. They contain only isolated test prompts, test UUIDs, and app UI; no account, credentials, or user-home paths.
**C running in the native provider-owned track:**
![C running](c-running.png)
**After archiving finished rows, C remains running:**
![Archive keeps running C](archive-keeps-running.png)
**Opened C conversation, current Shell call and parent link:** this is an unaltered crop of the bottom 880×245 pixels of the real child-pane capture, omitting the long temporary session path above it. No state was redrawn.
![C child conversation crop](c-dialogue-crop.png)
**Final four completed rows, with the failed resume reported above:**
![Completed rows and failed resume](completed-and-failed-resume.png)
## Coverage and limits
| Surface | Tested |
| ---------------------------------- | ------------------------------------------------------------------------- |
| macOS Electron development desktop | Yes — real renderer, composer, track, child pane, archive-finished action |
| Pi RPC / Paseo WebSocket / daemon | Yes — real provider calls and persisted tool results/notices |
| Standalone browser web | No |
| iOS / Android | No |
| Windows / Linux desktop | No |
| Packaged release / CI | No |
Each resumed attempt intentionally gets its own row and reads the whole reused child transcript, including earlier history; it does not slice history by attempt. Old-notice isolation is also covered by the automated regression and the earlier real-notice host replay, rather than forcing a delayed notification in the desktop run.
Short-lived duplicate Shell text was observed in the streaming child pane; its cause was not established and it is not claimed fixed here. The earlier direct-SDK smoke's turn-end persistence error did not recur through this normal RPC run. All four test runners exited, the parent settled idle, and the temporary workspace stayed empty. No claims are made about old workflow issues or daemon-restart global adoption.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

@@ -1,157 +0,0 @@
{
"capture": "Selected fields from actual Pi session entries and final status files; omitted unrelated metadata. Test UUIDs unchanged; all test-root absolute paths replaced with <QA_ROOT>.",
"calls": [
{
"timestamp": "2026-11-10T04:08:31.691Z",
"toolCallId": "call_dab88a12b4db419586ac9d0eab565ca8|fc_0e57afa06f701795016af298be50c487d0844c674c8a61fa94",
"arguments": {
"agent": "qa-marker",
"task": "Run sleep 18 then printf PASEO_DESKTOP_A; do not read or write any files",
"async": true
}
},
{
"timestamp": "2026-11-10T04:08:32.010Z",
"toolCallId": "call_dab88a12b4db419586ac9d0eab565ca8|fc_0e57afa06f701795016af298be50c487d0844c674c8a61fa94",
"isError": false,
"details": {
"mode": "single",
"runId": "832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122",
"asyncId": "832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122",
"asyncDir": "<QA_ROOT>/async/async-subagent-runs/832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122",
"results": []
}
},
{
"timestamp": "2026-11-10T04:10:22.901Z",
"toolCallId": "call_b636f1714b83481ba5b4ab0aee32b239|fc_0e57afa06f701795016af2992d215087d099231229062fa446",
"arguments": {
"action": "resume",
"id": "832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122",
"message": "Run sleep 18 then printf PASEO_DESKTOP_B; do not read or write files"
}
},
{
"timestamp": "2026-11-10T04:10:23.219Z",
"toolCallId": "call_b636f1714b83481ba5b4ab0aee32b239|fc_0e57afa06f701795016af2992d215087d099231229062fa446",
"isError": false,
"details": {
"mode": "single",
"runId": "8ca757d4-7514-4e87-8eeb-af52e2228ccb",
"asyncId": "8ca757d4-7514-4e87-8eeb-af52e2228ccb",
"asyncDir": "<QA_ROOT>/async/async-subagent-runs/8ca757d4-7514-4e87-8eeb-af52e2228ccb",
"results": []
}
},
{
"timestamp": "2026-11-10T04:13:36.923Z",
"toolCallId": "call_8e4ef481e0964e25bbdad4defe620e12|fc_0e57afa06f701795016af299ef47c887d0ac84e70bd50ffc5a",
"arguments": {
"action": "resume",
"id": "8ca757d4-7514-4e87-8eeb-af52e2228ccb",
"message": "Run bash sleep 45 then printf PASEO_DESKTOP_C; do not read or write files"
}
},
{
"timestamp": "2026-11-10T04:13:37.597Z",
"toolCallId": "call_8e4ef481e0964e25bbdad4defe620e12|fc_0e57afa06f701795016af299ef47c887d0ac84e70bd50ffc5a",
"isError": false,
"details": {
"mode": "single",
"runId": "f41865ed-0e62-44b5-a825-27ac4f0aa596",
"asyncId": "f41865ed-0e62-44b5-a825-27ac4f0aa596",
"asyncDir": "<QA_ROOT>/async/async-subagent-runs/f41865ed-0e62-44b5-a825-27ac4f0aa596",
"results": []
}
},
{
"timestamp": "2026-11-10T04:16:53.099Z",
"toolCallId": "call_60e8fa68de0b436fad2aaf8cc391dae2|fc_0e57afa06f701795016af29ab2b1b487d09020677eaaf9086e",
"arguments": {
"action": "resume",
"id": "00000000-0000-0000-0000-000000000000",
"message": "Output PASEO_INVALID"
}
},
{
"timestamp": "2026-11-10T04:16:53.109Z",
"toolCallId": "call_60e8fa68de0b436fad2aaf8cc391dae2|fc_0e57afa06f701795016af29ab2b1b487d09020677eaaf9086e",
"isError": true,
"details": {},
"content": [
{
"type": "text",
"text": "Async run not found for '00000000-0000-0000-0000-000000000000'."
}
]
},
{
"timestamp": "2026-11-10T04:17:07.330Z",
"toolCallId": "call_e6eb985f16b24308be82922d6a1d7a57|fc_0e57afa06f701795016af29ac17d5c87d0b79c8763512f43da",
"arguments": {
"action": "resume",
"id": "832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122",
"message": "Run bash sleep 45 then printf PASEO_DESKTOP_B_OBSERVE; do not read or write files"
}
},
{
"timestamp": "2026-11-10T04:17:07.668Z",
"toolCallId": "call_e6eb985f16b24308be82922d6a1d7a57|fc_0e57afa06f701795016af29ac17d5c87d0b79c8763512f43da",
"isError": false,
"details": {
"mode": "single",
"runId": "01ac3b2e-2ed6-472d-8438-4dfb91ea62f7",
"asyncId": "01ac3b2e-2ed6-472d-8438-4dfb91ea62f7",
"asyncDir": "<QA_ROOT>/async/async-subagent-runs/01ac3b2e-2ed6-472d-8438-4dfb91ea62f7",
"results": []
}
}
],
"finalStatuses": [
{
"runId": "01ac3b2e-2ed6-472d-8438-4dfb91ea62f7",
"state": "complete",
"revivedFrom": "832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122",
"sessionFile": "<QA_ROOT>/pi/sessions/<QA_WORKSPACE>/2026-11-10T04-08-20-423Z_01a1c3a4-80c7-75ad-8526-1844853a0f02/832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122/run-0/session.jsonl"
},
{
"runId": "832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122",
"state": "complete",
"sessionFile": "<QA_ROOT>/pi/sessions/<QA_WORKSPACE>/2026-11-10T04-08-20-423Z_01a1c3a4-80c7-75ad-8526-1844853a0f02/832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122/run-0/session.jsonl"
},
{
"runId": "8ca757d4-7514-4e87-8eeb-af52e2228ccb",
"state": "complete",
"revivedFrom": "832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122",
"sessionFile": "<QA_ROOT>/pi/sessions/<QA_WORKSPACE>/2026-11-10T04-08-20-423Z_01a1c3a4-80c7-75ad-8526-1844853a0f02/832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122/run-0/session.jsonl"
},
{
"runId": "f41865ed-0e62-44b5-a825-27ac4f0aa596",
"state": "complete",
"revivedFrom": "8ca757d4-7514-4e87-8eeb-af52e2228ccb",
"sessionFile": "<QA_ROOT>/pi/sessions/<QA_WORKSPACE>/2026-11-10T04-08-20-423Z_01a1c3a4-80c7-75ad-8526-1844853a0f02/832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122/run-0/session.jsonl"
}
],
"completionNotices": [
{
"timestamp": "2026-11-10T04:09:07.245Z",
"customType": "subagent-notify",
"content": "Background task completed: **qa-marker**\n\nqa-marker:\nPASEO_DESKTOP_A\n\nRetention-managed async directory: <QA_ROOT>/async/async-subagent-runs/832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122\n\nSession file: <QA_ROOT>/pi/sessions/<QA_WORKSPACE>/2026-11-10T04-08-20-423Z_01a1c3a4-80c7-75ad-8526-1844853a0f02/832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122/run-0/session.jsonl\n\nParent action: Read the saved results above and resume the already-authorized parent task, or report completion. If approval is required, explicitly ask the user. Do not silently yield, rerun completed work, or infer new authorization."
},
{
"timestamp": "2026-11-10T04:10:53.423Z",
"customType": "subagent-notify",
"content": "Background task completed: **qa-marker**\n\nqa-marker:\nPASEO_DESKTOP_B\n\nRetention-managed async directory: <QA_ROOT>/async/async-subagent-runs/8ca757d4-7514-4e87-8eeb-af52e2228ccb\n\nSession file: <QA_ROOT>/pi/sessions/<QA_WORKSPACE>/2026-11-10T04-08-20-423Z_01a1c3a4-80c7-75ad-8526-1844853a0f02/832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122/run-0/session.jsonl\n\nParent action: Read the saved results above and resume the already-authorized parent task, or report completion. If approval is required, explicitly ask the user. Do not silently yield, rerun completed work, or infer new authorization."
},
{
"timestamp": "2026-11-10T04:14:37.834Z",
"customType": "subagent-notify",
"content": "Background task completed: **qa-marker**\n\nqa-marker:\nPASEO_DESKTOP_C\n\nRetention-managed async directory: <QA_ROOT>/async/async-subagent-runs/f41865ed-0e62-44b5-a825-27ac4f0aa596\n\nSession file: <QA_ROOT>/pi/sessions/<QA_WORKSPACE>/2026-11-10T04-08-20-423Z_01a1c3a4-80c7-75ad-8526-1844853a0f02/832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122/run-0/session.jsonl\n\nParent action: Read the saved results above and resume the already-authorized parent task, or report completion. If approval is required, explicitly ask the user. Do not silently yield, rerun completed work, or infer new authorization."
},
{
"timestamp": "2026-11-10T04:17:37.887Z",
"customType": "subagent-notify",
"content": "Background task completed: **qa-marker**\n\nqa-marker:\nPASEO_DESKTOP_B_OBSERVE\n\nRetention-managed async directory: <QA_ROOT>/async/async-subagent-runs/01ac3b2e-2ed6-472d-8438-4dfb91ea62f7\n\nSession file: <QA_ROOT>/pi/sessions/<QA_WORKSPACE>/2026-11-10T04-08-20-423Z_01a1c3a4-80c7-75ad-8526-1844853a0f02/832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122/run-0/session.jsonl\n\nParent action: Read the saved results above and resume the already-authorized parent task, or report completion. If approval is required, explicitly ask the user. Do not silently yield, rerun completed work, or infer new authorization."
}
]
}
@@ -1,17 +0,0 @@
Excerpt of actual Electron renderer DOM captured after opening the resumed child.
Test-root paths replaced with <QA_ROOT>; unrelated earlier history omitted.
Task: You are reviving a previous subagent conversation.
Original run: 832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122
Original agent: qa-marker
Original session file: <QA_ROOT>/pi/sessions/<QA_WORKSPACE>/2026-11-10T04-08-20-423Z_01a1c3a4-80c7-75ad-8526-1844853a0f02/832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122/run-0/session.jsonl
Use the stored session context as background. Answer the orchestrator's follow-up below. Do not assume the original child session is still running.
Follow-up:
Run bash sleep 45 then printf PASEO_DESKTOP_B_OBSERVE; do not read or write files
Show more
12:17 PM
QA #9200 — Pi resume A→B→C
Open parent
@@ -1,196 +0,0 @@
Captured command output. Only private absolute paths are replaced with <REPO_ROOT>/<USER_HOME>.
RED runs are intentional regression failures, not final failures.
=== Before fix: resume arguments rejected (expected RED, exit 1) ===
$ cd packages/server && npx vitest run src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts --bail=1
RUN v4.1.6 <REPO_ROOT>/packages/server
stdout | src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts
[dotenv@17.3.1] injecting env (0) from .env.test -- tip: ⚙️ suppress all logs with { quiet: true }
[dotenv@17.3.1] injecting env (0) from ../.env -- tip: ⚙️ suppress all logs with { quiet: true }
❯ src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts (10 tests | 1 failed) 7ms
× registers a successful resume as a new async attempt without spawn arguments 6ms
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯
FAIL src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts > pi-subagents adapter > registers a successful resume as a new async attempt without spawn arguments
AssertionError: expected undefined to deeply equal [ { type: 'upsert', …(5) } ]
- Expected:
[
{
"description": "Continue",
"id": "resume-b",
"status": "running",
"title": "Subagent",
"toolCallId": "resume-b",
"type": "upsert",
},
]
+ Received:
undefined
❯ src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts:66:32
64| },
65| });
66| expect(resumed?.subagents).toEqual([
| ^
67| {
68| type: "upsert",
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯
Test Files 1 failed (1)
Tests 1 failed (10)
Start at 21:05:21
Duration 474ms (transform 245ms, setup 31ms, import 328ms, tests 7ms, environment 0ms)
=== Intermediate implementation: old file-only transcript deduplication (expected RED, exit 1) ===
$ cd packages/server && npx vitest run src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts --bail=1
RUN v4.1.6 <REPO_ROOT>/packages/server
stdout | src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts
[dotenv@17.3.1] injecting env (0) from .env.test -- tip: ⚡️ secrets for agents: https://dotenvx.com/as2
[dotenv@17.3.1] injecting env (0) from ../.env -- tip: ⚙️ override existing env vars with { override: true }
❯ src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts (16 tests | 1 failed) 1329ms
× follows consecutive resumed attempts and keeps stale notices on their own rows 1320ms
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯
FAIL src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts > pi-subagents adapter > follows consecutive resumed attempts and keeps stale notices on their own rows
AssertionError: expected false to be true // Object.is equality
- Expected
+ Received
- true
+ false
❯ src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts:155:78
153| ]);
154| replay.mapToolCall(call);
155| await expect.poll(() => hasChildTimeline(received, `resume-${r…
| ^
156| }
157| const oldNotice = {
Caused by: Error: Matcher did not succeed in time.
❯ src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts:155:9
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯
Test Files 1 failed (1)
Tests 1 failed | 6 passed (16)
Start at 21:09:04
Duration 1.85s (transform 247ms, setup 24ms, import 389ms, tests 1.33s, environment 0ms)
=== Final baseline d46ee655: GREEN, exit 0 ===
$ cd packages/server && npx vitest run src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts --bail=1
RUN v4.1.6 <REPO_ROOT>/packages/server
Test Files 1 passed (1)
Tests 21 passed (21)
Start at 13:07:04
Duration 1.97s (transform 256ms, setup 26ms, import 355ms, tests 1.45s, environment 0ms)
=== Final typecheck, exit 0 ===
$ npm run typecheck
> paseo@0.15.4 typecheck
> npm run typecheck --workspaces --if-present
> @getpaseo/builtin-plugins@0.15.4 typecheck
> tsgo -p tsconfig.json --noEmit
> @getpaseo/expo-two-way-audio@0.15.4 typecheck
> tsgo --noEmit
> @getpaseo/highlight@0.15.4 typecheck
> tsgo --noEmit
> @getpaseo/plugin@0.15.4 typecheck
> tsgo --noEmit && npm run typecheck:examples
> @getpaseo/plugin@0.15.4 typecheck:examples
> npm --prefix ../../plugin-examples/markdown-panel install --ignore-scripts --no-audit --no-fund && tsgo -p tsconfig.examples.json --noEmit
up to date in 433ms
> @getpaseo/protocol@0.15.4 pretypecheck
> npm run generate:validators
> @getpaseo/protocol@0.15.4 generate:validators
> node scripts/generate-validation-aot.mjs
generated src/generated/validation/ws-outbound.aot.ts: envelope + 250 session message validators
> @getpaseo/protocol@0.15.4 typecheck
> tsgo --noEmit
> @getpaseo/client@0.15.4 typecheck
> tsgo --noEmit
> @getpaseo/server@0.15.4 typecheck
> tsgo -p tsconfig.server.typecheck.json --noEmit
> @getpaseo/app@0.15.4 typecheck
> tsgo --noEmit
> @getpaseo/relay@0.15.4 typecheck
> tsgo --noEmit
> @getpaseo/website@0.15.4 typecheck
> tsgo --noEmit
> @getpaseo/desktop@0.15.4 typecheck
> tsgo --noEmit -p tsconfig.json
> @getpaseo/cli@0.15.4 typecheck
> tsgo --noEmit
=== Final lint, exit 0 ===
$ npm run lint
> paseo@0.15.4 lint
> oxlint
Found 0 warnings and 0 errors.
Finished in 2.3s on 5442 files with 177 rules using 12 threads.
=== Final full formatter, exit 0; no unrelated changes ===
$ npm run format
> paseo@0.15.4 format
> oxfmt .
Finished in 2684ms on 5928 files using 12 threads.
$ git diff --check
(exit 0; no output)
+5 -30
View File
@@ -669,9 +669,9 @@ Every section bullet must be scannable at a glance. The changelog is not release
## 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, ranked as
[Changelog ordering](#changelog-ordering) describes. The sections below are the full record; a
reader who stops after the highlights should have missed nothing they would ask about.
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
@@ -715,7 +715,7 @@ reader who stops after the highlights should have missed nothing they would ask
| 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, and each must outrank the one below it.
new?" without its description.
## Changelog attribution
@@ -748,32 +748,7 @@ Rules:
## Changelog ordering
Rank highlights by how much of a user's day the change touches: how many users get it, and how
often they meet it. Put every highlight in one of four groups, then order each group by the same
question.
1. **New capabilities.** Something a user could not see or do in the last release:
`Background tasks`, `Plugin sidebar views`.
2. **Everyday things that got better.** Speed, steadiness and polish in what users already do
every session: `Faster diff loading`, `Polish`.
3. **Changes for part of the audience.** They need a particular device, provider or setup:
`Keyboard shortcuts on mobile`, `Touch-sized tablet controls`.
4. **Small conveniences and highlighted fixes.** A user is glad to find them and would not update
for them: `Undo workspace archive`, `Fewer reconnects on return`. Fixes are routine, so one
that earned a highlight still follows everything new or improved.
- **Reach decides before kind.** A new capability that needs a hardware keyboard is in group 3.
- **Rank the whole list in one pass.** The order you collected the pull requests in, the alphabet
and the order of the sections below are not rankings.
- **Show the groups with the draft.** List each highlight under its group when you ask for
approval, so the maintainer corrects the test and not one position.
- **A maintainer's note that a change is big moves that change up.** It does not rank the changes
it names against each other, and it does not lower the ones it leaves out.
- **A correction to one highlight is a correction to the test.** Work out which group or question
you got wrong, rank every highlight again, and then edit the file. Moving only the named item
leaves the same mistake in the others.
Bullets within each section (Added, Changed, Fixed) follow this order:
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.
+62 -11
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:
@@ -159,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.
@@ -172,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
@@ -336,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.
@@ -352,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`
+1 -1
View File
@@ -1 +1 @@
sha256-e6BNeOJvy1uyDe/XW9hdr7NBgJ+t9tlNum5oLNefREI=
sha256-21qteVXVoL7S/wF0aGxHNEkTX8in6UF6+wWEZi8N3HA=
+28 -28
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.16.0-beta.2",
"version": "0.15.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.16.0-beta.2",
"version": "0.15.4",
"hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
@@ -37468,7 +37468,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.16.0-beta.2",
"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.16.0-beta.2",
"version": "0.15.4",
"license": "Apache-2.0",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.16.0-beta.2",
"@getpaseo/protocol": "0.16.0-beta.2",
"@getpaseo/server": "0.16.0-beta.2",
"@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.16.0-beta.2",
"version": "0.15.4",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/protocol": "0.16.0-beta.2",
"@getpaseo/relay": "0.16.0-beta.2",
"@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.16.0-beta.2",
"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.16.0-beta.2",
"version": "0.15.4",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -40195,7 +40195,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.16.0-beta.2",
"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.16.0-beta.2",
"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.16.0-beta.2",
"@getpaseo/protocol": "0.16.0-beta.2",
"@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.16.0-beta.2",
"@getpaseo/protocol": "0.16.0-beta.2",
"@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.16.0-beta.2",
"version": "0.15.4",
"license": "Apache-2.0",
"dependencies": {
"semver": "^7.8.5",
@@ -40295,7 +40295,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.16.0-beta.2",
"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.16.0-beta.2",
"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.16.0-beta.2",
"@getpaseo/highlight": "0.16.0-beta.2",
"@getpaseo/plugin": "0.16.0-beta.2",
"@getpaseo/protocol": "0.16.0-beta.2",
"@getpaseo/relay": "0.16.0-beta.2",
"@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.16.0-beta.2",
"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.16.0-beta.2",
"version": "0.15.4",
"dependencies": {
"@getpaseo/plugin": "*",
"zod": "^4.4.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.16.0-beta.2",
"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();
}
@@ -125,13 +125,11 @@ async function renameWorkspaceViaSidebar(
await expect(renameItem).toBeVisible({ timeout: 10_000 });
await renameItem.click();
// Desktop-width sidebars rename in place.
const renameInput = page.getByTestId(
`sidebar-workspace-rename-input-${serverId}:${input.workspaceId}`,
);
await expect(renameInput).toBeFocused({ timeout: 10_000 });
const modalPrefix = `sidebar-workspace-rename-modal-${serverId}:${input.workspaceId}`;
const renameInput = page.getByTestId(`${modalPrefix}-input`);
await expect(renameInput).toBeVisible({ timeout: 10_000 });
await renameInput.fill(input.title);
await renameInput.press("Enter");
await page.getByTestId(`${modalPrefix}-submit`).click();
await expect(renameInput).toHaveCount(0, { timeout: 15_000 });
}
@@ -10,7 +10,6 @@ import {
selectComposerDraftRange,
} from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { expectVisibleAgentSurfacesIdle } from "../support/helpers/agent-stream";
// The popover has no semantic role; its rows are React Native Pressables.
function fileSuggestions(page: Page) {
@@ -33,33 +32,31 @@ async function sendEmailIntact(page: Page, email: string): Promise<void> {
await expect(page.getByText(email, { exact: true })).toBeVisible();
}
test("Enter sends email formats intact and still completes file mentions", async ({ page }) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "composer-email-",
title: "Email mention regression",
repo: { files: [{ path: "example.com", content: "A file matching the email domain.\n" }] },
});
try {
await openAgentRoute(page, agent);
await expectComposerVisible(page);
for (const email of [
"user <12345+user@noreply.example.com>",
"alice@example.com",
"noreply+@example.com",
]) {
await test.step(`complete a file mention, then send ${email} intact`, async () => {
for (const email of [
"user <12345+user@noreply.example.com>",
"alice@example.com",
"noreply+@example.com",
]) {
test(`Enter sends ${email} intact and still completes file mentions`, async ({ page }) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "composer-email-",
title: "Email mention regression",
repo: { files: [{ path: "example.com", content: "A file matching the email domain.\n" }] },
});
try {
await openAgentRoute(page, agent);
await expectComposerVisible(page);
await test.step("complete a workspace file mention with Enter", async () => {
await selectReadmeMention(page);
await sendEmailIntact(page, email);
// Start the next draft only after the daemon and visible agent have settled.
const reply = await agent.client.waitForFinish(agent.agentId, 15_000);
expect(reply.status).toBe("idle");
await expectVisibleAgentSurfacesIdle(page);
});
await test.step("send an email without offering or inserting workspace files", async () => {
await sendEmailIntact(page, email);
});
} finally {
await agent.cleanup();
}
} finally {
await agent.cleanup();
}
});
});
}
test("fullscreen mention keys edit the draft while inline keys still complete files", async ({
page,
@@ -6,7 +6,6 @@ import {
fillComposerDraft,
typeIntoFocusedComposer,
} from "../support/helpers/composer";
import { expectVisibleAgentSurfacesIdle } from "../support/helpers/agent-stream";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
function userMessages(page: Page) {
@@ -30,9 +29,7 @@ async function pressInComposer(page: Page, key: string) {
}
test.describe("Composer recall", () => {
test("recalls, edits, and sends messages while keeping slash autocomplete usable", async ({
page,
}) => {
test("Up and Down walk sent messages from an empty composer", async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "composer-recall-",
title: "Composer recall",
@@ -40,44 +37,49 @@ test.describe("Composer recall", () => {
try {
await openAgentRoute(page, session);
await expectComposerVisible(page);
await test.step("walk sent history, edit a recalled draft, and send it", async () => {
await sendAndWaitForMessage(page, "fix the auth bug", 1);
await sendAndWaitForMessage(page, "add tests", 2);
await sendAndWaitForMessage(page, "fix the auth bug", 1);
await sendAndWaitForMessage(page, "add tests", 2);
await pressInComposer(page, "ArrowUp");
await expectComposerDraft(page, "add tests");
await pressInComposer(page, "ArrowUp");
await expectComposerDraft(page, "fix the auth bug");
await pressInComposer(page, "ArrowDown");
await expectComposerDraft(page, "add tests");
await pressInComposer(page, "ArrowDown");
await expectComposerDraft(page, "");
await pressInComposer(page, "ArrowUp");
await expectComposerDraft(page, "add tests");
await pressInComposer(page, "ArrowUp");
await expectComposerDraft(page, "fix the auth bug");
await pressInComposer(page, "ArrowDown");
await expectComposerDraft(page, "add tests");
await pressInComposer(page, "ArrowDown");
await expectComposerDraft(page, "");
await pressInComposer(page, "ArrowUp");
await typeIntoFocusedComposer(page, " for login");
await expectComposerDraft(page, "add tests for login");
await pressInComposer(page, "ArrowUp");
await expectComposerDraft(page, "add tests for login");
await pressInComposer(page, "ArrowUp");
await typeIntoFocusedComposer(page, " for login");
await expectComposerDraft(page, "add tests for login");
await pressInComposer(page, "ArrowUp");
await expectComposerDraft(page, "add tests for login");
await sendDraftWhenReady(page);
await expect(userMessages(page)).toHaveCount(3, { timeout: 30_000 });
await expect(userMessages(page).last()).toContainText("add tests for login");
});
await test.step("recall a slash message without opening its picker, then edit it", async () => {
await expectComposerDraft(page, "");
const reply = await session.client.waitForFinish(session.agentId, 15_000);
expect(reply.status).toBe("idle");
await expectVisibleAgentSurfacesIdle(page);
await sendAndWaitForMessage(page, "/tdd", 4);
await sendDraftWhenReady(page);
await expect(userMessages(page)).toHaveCount(3, { timeout: 30_000 });
await expect(userMessages(page).last()).toContainText("add tests for login");
} finally {
await session.cleanup();
}
});
const popover = page.getByTestId("composer-autocomplete-popover").filter({ visible: true });
await pressInComposer(page, "ArrowUp");
await expectComposerDraft(page, "/tdd");
await expect(popover).toHaveCount(0);
test("a recalled slash message does not open the command picker", async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "composer-recall-slash-",
title: "Composer recall slash",
});
try {
await openAgentRoute(page, session);
await expectComposerVisible(page);
await sendAndWaitForMessage(page, "/tdd", 1);
await typeIntoFocusedComposer(page, "x");
await expect(popover.first()).toBeVisible({ timeout: 30_000 });
});
const popover = page.getByTestId("composer-autocomplete-popover").filter({ visible: true });
await pressInComposer(page, "ArrowUp");
await expectComposerDraft(page, "/tdd");
await expect(popover).toHaveCount(0);
await typeIntoFocusedComposer(page, "x");
await expect(popover.first()).toBeVisible({ timeout: 30_000 });
} finally {
await session.cleanup();
}
@@ -26,7 +26,6 @@ import {
reloadAgent,
scriptAgentUsage,
seedAgentWithContextWindow,
tapContextWindowMeter,
usageCard,
} from "../support/helpers/context-window";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
@@ -510,24 +509,6 @@ test("context details stay open while the chat streams", async ({ page }) => {
}
});
test.describe("on a wide touch screen", () => {
test.use({ hasTouch: true });
test("tapping the meter opens context details that stay open", async ({ page, agent }) => {
const usage = await scriptAgentUsage(page);
await page.setViewportSize(DESKTOP);
await openAgent(page, agent);
const [claude] = claudeAndCodexReports();
// A tap can request the agent's usage more than once.
usage.answerNext([onPersonalLogin(claude!)]);
usage.answerNext([onPersonalLogin(claude!)]);
const details = await tapContextWindowMeter(page);
// Past the card's close grace. A touch sends no pointer movement after the tap.
await page.waitForTimeout(500);
await expect(details).toBeVisible();
});
});
test("pointer hovering context details preserves composer focus", async ({ page, agent }) => {
const usage = await scriptAgentUsage(page);
await page.setViewportSize(DESKTOP);
@@ -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();
}
@@ -146,9 +146,7 @@ async function installPinRpcGate(
}
test.describe("Pin workspace shortcut", () => {
test("pins and unpins across sidebar collapse and keeps project creation reachable", async ({
page,
}) => {
test("dispatches pin once and keeps project creation reachable", async ({ page }) => {
const workspace = await seedWorkspace({
repoPrefix: "pin-project-creation-",
title: "Pinned workspace",
@@ -171,26 +169,10 @@ test.describe("Pin workspace shortcut", () => {
await expect(workspaceRow(page, workspace.workspaceId)).toHaveCount(1);
});
await test.step("unpins the active workspace while the Pinned section is collapsed", async () => {
await page.keyboard.press(PIN_SHORTCUT);
await expect(pinnedSection(page)).toBeVisible({ timeout: 10_000 });
expect(gate.sentCount()).toBe(3);
await page.getByTestId("sidebar-pinned-section-header").click();
await expect(workspaceRow(page, workspace.workspaceId)).toHaveCount(0, {
timeout: 10_000,
});
await page.keyboard.press(PIN_SHORTCUT);
await expect(pinnedSection(page)).toHaveCount(0, { timeout: 10_000 });
expect(gate.sentCount()).toBe(4);
await expect(workspaceRow(page, workspace.workspaceId)).toHaveCount(1);
});
await test.step("keeps the pinned project available to workspace creation", async () => {
await page.keyboard.press(PIN_SHORTCUT);
await expect(pinnedSection(page)).toBeVisible({ timeout: 10_000 });
expect(gate.sentCount()).toBe(5);
expect(gate.sentCount()).toBe(3);
await openNewWorkspaceComposer(page, workspace);
await expectNewWorkspaceProjectSelected(page, workspace.projectDisplayName);
@@ -221,6 +203,27 @@ test.describe("Pin workspace shortcut", () => {
}
});
test("unpins the active workspace while the Pinned section is collapsed", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "pin-shortcut-unpin-" });
try {
await gotoAppShell(page);
await openWorkspace(page, workspace.workspaceId);
await page.keyboard.press(PIN_SHORTCUT);
await expect(pinnedSection(page)).toBeVisible({ timeout: 10_000 });
await page.getByTestId("sidebar-pinned-section-header").click();
await expect(workspaceRow(page, workspace.workspaceId)).toHaveCount(0, { timeout: 10_000 });
await page.keyboard.press(PIN_SHORTCUT);
await expect(pinnedSection(page)).toHaveCount(0, { timeout: 10_000 });
} finally {
await workspace.cleanup();
}
});
test("pins the active workspace while its status group is collapsed", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "pin-shortcut-status-" });
@@ -3,7 +3,6 @@ import { gotoAppShell } from "../support/helpers/app";
import { seedWorkspace } from "../support/helpers/seed-client";
import { openAgentRoute } from "../support/helpers/mock-agent";
import { getServerId } from "../support/helpers/server-id";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
function workspaceRowTestId(workspaceId: string): string {
return `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
@@ -13,13 +12,9 @@ function workspaceRenameModalTestId(workspaceId: string, suffix: string): string
return `sidebar-workspace-rename-modal-${getServerId()}:${workspaceId}-${suffix}`;
}
function workspaceRenameInputTestId(workspaceId: string): string {
return `sidebar-workspace-rename-input-${getServerId()}:${workspaceId}`;
}
async function chooseRenameFromKebab(page: Page, workspaceId: string): Promise<void> {
async function openRenameModal(page: Page, workspaceId: string) {
const serverId = getServerId();
const row = page.getByTestId(workspaceRowTestId(workspaceId));
const row = page.getByTestId(`sidebar-workspace-row-${serverId}:${workspaceId}`);
await expect(row).toBeVisible({ timeout: 30_000 });
await row.hover();
@@ -30,87 +25,40 @@ async function chooseRenameFromKebab(page: Page, workspaceId: string): Promise<v
const renameItem = page.getByTestId(`sidebar-workspace-menu-rename-${serverId}:${workspaceId}`);
await expect(renameItem).toBeVisible({ timeout: 10_000 });
await renameItem.click();
}
/** Lets the app render a server message the gate just forwarded. */
async function waitForTwoFrames(page: Page): Promise<void> {
await page.evaluate(async () => {
const nextFrame = () => new Promise((resolve) => requestAnimationFrame(resolve));
await nextFrame();
await nextFrame();
});
}
type DaemonWebSocketGate = Awaited<ReturnType<typeof installDaemonWebSocketGate>>;
/** Sets the title from a second daemon client and waits until this page has rendered it. */
async function renameFromAnotherClient(
page: Page,
gate: DaemonWebSocketGate,
workspace: Awaited<ReturnType<typeof seedWorkspace>>,
title: string,
): Promise<void> {
gate.holdNextServerMessage("workspace_update");
await workspace.client.setWorkspaceTitle(workspace.workspaceId, title);
await gate.waitForHeldServerMessage("workspace_update");
gate.releaseHeldServerMessage("workspace_update");
await waitForTwoFrames(page);
}
interface Box {
x: number;
y: number;
width: number;
height: number;
}
async function rowBox(page: Page, workspaceId: string): Promise<Box> {
const box = await page.getByTestId(workspaceRowTestId(workspaceId)).boundingBox();
if (!box) throw new Error(`sidebar row ${workspaceId} has no layout box`);
return box;
const input = page.getByTestId(workspaceRenameModalTestId(workspaceId, "input"));
await expect(input).toBeVisible({ timeout: 10_000 });
return input;
}
// In Model B the workspace title is its identity: renaming sets a custom title
// layered over the derived branch/directory name, and reconciliation never
// touches it. The sidebar row shows the title verbatim — no branch mutation.
test.describe("Sidebar workspace rename", () => {
test("renaming in place sets a custom title without moving the row, and survives reload", async ({
page,
}) => {
test("renaming via kebab sets a custom title that survives reload", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "sidebar-rename-" });
try {
expect(workspace.workspaceName).toBe("main");
await page.setViewportSize({ width: 1280, height: 800 });
await gotoAppShell(page);
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toBeVisible({
timeout: 30_000,
});
const restingBox = await rowBox(page, workspace.workspaceId);
await chooseRenameFromKebab(page, workspace.workspaceId);
const input = page.getByTestId(workspaceRenameInputTestId(workspace.workspaceId));
await expect(input).toBeFocused();
await expect(input).toBeEditable();
const input = await openRenameModal(page, workspace.workspaceId);
await expect(input).toHaveValue("main");
await expect(
page.getByTestId(workspaceRenameModalTestId(workspace.workspaceId, "input")),
).toHaveCount(0);
expect(await rowBox(page, workspace.workspaceId)).toEqual(restingBox);
const customTitle = "Payments Refactor";
// The whole title is selected, so typing replaces it.
await page.keyboard.type(customTitle);
expect(await rowBox(page, workspace.workspaceId)).toEqual(restingBox);
await page.keyboard.press("Enter");
await input.fill(customTitle);
await page.getByTestId(workspaceRenameModalTestId(workspace.workspaceId, "submit")).click();
await expect(input).toHaveCount(0, { timeout: 15_000 });
// The title is shown exactly as typed — not slugified into a branch name.
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toContainText(
customTitle,
{ timeout: 15_000 },
);
expect(await rowBox(page, workspace.workspaceId)).toEqual(restingBox);
// The custom title is backing metadata on the workspace: a full reload
// re-resolves the descriptor from persistence and must not lose it. This
@@ -125,259 +73,6 @@ test.describe("Sidebar workspace rename", () => {
await workspace.cleanup();
}
});
test("Escape discards an in-place rename and clicking away saves it", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "sidebar-rename-blur-" });
try {
await page.setViewportSize({ width: 1280, height: 800 });
await gotoAppShell(page);
const row = page.getByTestId(workspaceRowTestId(workspace.workspaceId));
const input = page.getByTestId(workspaceRenameInputTestId(workspace.workspaceId));
await chooseRenameFromKebab(page, workspace.workspaceId);
await expect(input).toBeFocused();
await page.keyboard.type("Discarded title");
await page.keyboard.press("Escape");
await expect(input).toHaveCount(0);
await expect(row).toContainText("main");
await expect(row).not.toContainText("Discarded title");
await chooseRenameFromKebab(page, workspace.workspaceId);
await expect(input).toBeFocused();
// Clicking inside the field places the caret; it does not press the row underneath.
await input.click();
await expect(input).toBeFocused();
await input.fill("Saved on blur");
// Click empty space in the main pane, away from any control.
await page.mouse.click(900, 700);
await expect(input).toHaveCount(0, { timeout: 15_000 });
await expect(row).toContainText("Saved on blur");
} finally {
await workspace.cleanup();
}
});
test("the submitted title stays on screen until the renamed workspace arrives", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "sidebar-rename-hold-" });
try {
const gate = await installDaemonWebSocketGate(page);
await page.setViewportSize({ width: 1280, height: 800 });
await gotoAppShell(page);
const row = page.getByTestId(workspaceRowTestId(workspace.workspaceId));
const input = page.getByTestId(workspaceRenameInputTestId(workspace.workspaceId));
await chooseRenameFromKebab(page, workspace.workspaceId);
await expect(input).toBeFocused();
await page.keyboard.type("Held title");
// The daemon answers the rename before it pushes the renamed workspace.
gate.holdNextServerMessage("workspace_update");
await page.keyboard.press("Enter");
await gate.waitForHeldServerMessage("workspace_update");
await expect(input).toHaveValue("Held title");
await expect(input).not.toBeEditable();
await expect(row).not.toContainText("main");
gate.releaseHeldServerMessage("workspace_update");
await expect(input).toHaveCount(0);
await expect(row).toContainText("Held title");
} finally {
await workspace.cleanup();
}
});
test("an untouched in-place rename keeps a title set elsewhere while it was open", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "sidebar-rename-external-" });
try {
const gate = await installDaemonWebSocketGate(page);
await page.setViewportSize({ width: 1280, height: 800 });
await gotoAppShell(page);
const row = page.getByTestId(workspaceRowTestId(workspace.workspaceId));
const input = page.getByTestId(workspaceRenameInputTestId(workspace.workspaceId));
await chooseRenameFromKebab(page, workspace.workspaceId);
await expect(input).toBeFocused();
await expect(input).toHaveValue("main");
await renameFromAnotherClient(page, gate, workspace, "External title");
await page.mouse.click(900, 700);
await expect(input).toHaveCount(0);
await expect(row).toContainText("External title");
expect(gate.getClientRequestCount("workspace.title.set.request")).toBe(0);
} finally {
await workspace.cleanup();
}
});
test("saving after a rename elsewhere holds the submitted title until it arrives", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "sidebar-rename-external-save-" });
try {
const gate = await installDaemonWebSocketGate(page);
await page.setViewportSize({ width: 1280, height: 800 });
await gotoAppShell(page);
const row = page.getByTestId(workspaceRowTestId(workspace.workspaceId));
const input = page.getByTestId(workspaceRenameInputTestId(workspace.workspaceId));
await chooseRenameFromKebab(page, workspace.workspaceId);
await expect(input).toBeFocused();
await renameFromAnotherClient(page, gate, workspace, "External title");
await input.fill("Submitted title");
gate.holdNextServerMessage("workspace_update");
await page.keyboard.press("Enter");
await gate.waitForHeldServerMessage("workspace_update");
await expect(input).toHaveValue("Submitted title");
await expect(row).not.toContainText("External title");
gate.releaseHeldServerMessage("workspace_update");
await expect(input).toHaveCount(0);
await expect(row).toContainText("Submitted title");
} finally {
await workspace.cleanup();
}
});
test("submitting the title another client just set closes the field", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "sidebar-rename-external-same-" });
try {
const gate = await installDaemonWebSocketGate(page);
await page.setViewportSize({ width: 1280, height: 800 });
await gotoAppShell(page);
const row = page.getByTestId(workspaceRowTestId(workspace.workspaceId));
const input = page.getByTestId(workspaceRenameInputTestId(workspace.workspaceId));
await chooseRenameFromKebab(page, workspace.workspaceId);
await expect(input).toBeFocused();
await renameFromAnotherClient(page, gate, workspace, "External title");
// The daemon accepts it, but the title does not change, so no renamed workspace follows.
await input.fill("External title");
await page.keyboard.press("Enter");
await expect(input).toHaveCount(0);
await expect(row).toContainText("External title");
// The row is a row again: its menu opens and renames.
await chooseRenameFromKebab(page, workspace.workspaceId);
await expect(input).toBeFocused();
} finally {
await workspace.cleanup();
}
});
test("a rename elsewhere delivered while saving does not close the field", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "sidebar-rename-external-pending-" });
try {
const gate = await installDaemonWebSocketGate(page);
await page.setViewportSize({ width: 1280, height: 800 });
await gotoAppShell(page);
const row = page.getByTestId(workspaceRowTestId(workspace.workspaceId));
const input = page.getByTestId(workspaceRenameInputTestId(workspace.workspaceId));
await chooseRenameFromKebab(page, workspace.workspaceId);
await expect(input).toBeFocused();
await input.fill("Submitted title");
// Another client's rename lands just before ours. The page receives, in daemon order: that
// rename, our response, our renamed workspace. Each is held until the previous is rendered.
gate.holdNextServerMessage("workspace_update");
await workspace.client.setWorkspaceTitle(workspace.workspaceId, "External title");
await gate.waitForHeldServerMessage("workspace_update");
gate.holdNextServerMessage("workspace.title.set.response");
gate.holdNextServerMessage("workspace_update");
await page.keyboard.press("Enter");
await gate.waitForHeldServerMessage("workspace.title.set.response");
// Releases the oldest held update: the other client's. Ours stays held behind it.
gate.releaseHeldServerMessage("workspace_update");
await gate.waitForHeldServerMessage("workspace_update");
await waitForTwoFrames(page);
await expect(input).toHaveValue("Submitted title");
gate.releaseHeldServerMessage("workspace.title.set.response");
await waitForTwoFrames(page);
await expect(input).toHaveValue("Submitted title");
await expect(row).not.toContainText("External title");
gate.releaseHeldServerMessage("workspace_update");
await expect(input).toHaveCount(0);
await expect(row).toContainText("Submitted title");
} finally {
await workspace.cleanup();
}
});
test("a later rename that replaces the saved title closes the field", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "sidebar-rename-superseded-" });
try {
const gate = await installDaemonWebSocketGate(page);
await page.setViewportSize({ width: 1280, height: 800 });
await gotoAppShell(page);
const row = page.getByTestId(workspaceRowTestId(workspace.workspaceId));
const input = page.getByTestId(workspaceRenameInputTestId(workspace.workspaceId));
await chooseRenameFromKebab(page, workspace.workspaceId);
await expect(input).toBeFocused();
await input.fill("Submitted title");
// The daemon builds each update from its latest record, so a rename that lands after ours
// is accepted can be published in its place. Our update stays held: the page never sees it.
gate.holdNextServerMessage("workspace.title.set.response");
gate.holdNextServerMessage("workspace_update");
await page.keyboard.press("Enter");
await gate.waitForHeldServerMessage("workspace.title.set.response");
await gate.waitForHeldServerMessage("workspace_update");
gate.releaseHeldServerMessage("workspace.title.set.response");
await waitForTwoFrames(page);
await expect(input).toHaveValue("Submitted title");
await workspace.client.setWorkspaceTitle(workspace.workspaceId, "Later title");
await expect(input).toHaveCount(0);
await expect(row).toContainText("Later title");
await chooseRenameFromKebab(page, workspace.workspaceId);
await expect(input).toBeFocused();
} finally {
await workspace.cleanup();
}
});
test("below the lg breakpoint renaming opens the modal", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "sidebar-rename-modal-" });
try {
await page.setViewportSize({ width: 800, height: 800 });
await gotoAppShell(page);
await chooseRenameFromKebab(page, workspace.workspaceId);
const input = page.getByTestId(workspaceRenameModalTestId(workspace.workspaceId, "input"));
await expect(input).toBeVisible({ timeout: 10_000 });
await expect(page.getByTestId(workspaceRenameInputTestId(workspace.workspaceId))).toHaveCount(
0,
);
await input.fill("Modal Title");
await page.getByTestId(workspaceRenameModalTestId(workspace.workspaceId, "submit")).click();
await expect(input).toHaveCount(0, { timeout: 15_000 });
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toContainText(
"Modal Title",
);
} finally {
await workspace.cleanup();
}
});
});
async function toggleBackgroundWorkspaces(page: Page): Promise<void> {
@@ -16,7 +16,6 @@ import {
pinWorkspaceFromSidebar,
scrollWorkspaceList,
sweepPointerAcrossWorkspaceRows,
watchWorkspaceHoverCard,
} from "../support/helpers/sidebar";
import { seedWorkspace } from "../support/helpers/seed-client";
import { expectWorkspaceHeader } from "../support/helpers/workspace-ui";
@@ -544,29 +543,6 @@ test.describe("Sidebar workspace list", () => {
});
});
// On a wide touch screen (iPad) a tap is not a hover. Opening the card on the tap changes the page
// under the finger, and iPad Safari then drops the tap instead of opening the workspace.
test.describe("Sidebar workspace rows on a touch screen", () => {
test.use({ hasTouch: true, viewport: { width: 1180, height: 820 } });
test("tapping a workspace row opens it without the hover card", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "sidebar-touch-tap-" });
try {
await gotoAppShell(page);
await waitForSidebarProject(page, path.basename(workspace.repoPath));
const row = await waitForSidebarWorkspace(page, workspace.workspaceId);
const hoverCard = await watchWorkspaceHoverCard(page);
await row.tap();
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
await expectWorkspaceHoverCardClosed(page);
// A card that opens during the tap is what makes iPad Safari drop the tap.
expect(await hoverCard.hasOpened()).toBe(false);
} finally {
await workspace.cleanup();
}
});
});
test.describe("Mobile sidebar panelState transition", () => {
test.use({ viewport: { width: 390, height: 844 } });
@@ -1,142 +0,0 @@
import type { Page } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import { expectAgentIdle } from "../support/helpers/agent-stream";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
// A long user message paints capped, with its fade and Show more, in the frame it mounts in. These
// tests sample every painted frame from before the chat opens and require each long message to
// show one state, the settled one, for its whole life on screen.
interface PreviewFrame {
height: number;
fade: boolean;
control: string | null;
}
declare global {
interface Window {
__previewFrames?: Record<string, PreviewFrame[]>;
}
}
function longPrompt(name: string) {
return Array.from({ length: 40 }, (_, line) => `${name} line ${line + 1}`).join("\n");
}
async function recordPreviewFrames(page: Page) {
await page.addInitScript(() => {
const frames: Record<string, PreviewFrame[]> = {};
window.__previewFrames = frames;
const sample = () => {
for (const row of document.querySelectorAll('[data-testid="user-message"]')) {
const rect = row.getBoundingClientRect();
if (rect.height === 0) continue;
const text = row.querySelector('[data-message-text="true"]');
if (!text) continue;
const name = (text.textContent ?? "").split(" line ")[0];
// The preview is the ancestor that carries the height cap; the fade paints inside it.
let preview = text.parentElement;
while (preview && preview !== row && getComputedStyle(preview).maxHeight === "none") {
preview = preview.parentElement;
}
const control = [...row.querySelectorAll("*")].find(
(node) => node.children.length === 0 && /^Show (more|less)$/.test(node.textContent ?? ""),
);
const frame = {
height: Math.round(rect.height),
fade: preview !== null && preview !== row && preview.querySelector("svg") !== null,
control: control?.textContent ?? null,
};
const seen = (frames[name] ??= []);
if (JSON.stringify(seen.at(-1)) !== JSON.stringify(frame)) seen.push(frame);
}
requestAnimationFrame(sample);
};
requestAnimationFrame(sample);
});
}
async function expectPaintedCollapsedFromFirstFrame(page: Page, name: string) {
const message = page.getByTestId("user-message").filter({ hasText: `${name} line 1` });
await expect(message.getByRole("button", { name: "Show more" })).toBeVisible();
// Let any late measurement land before reading the frames.
await page.waitForTimeout(500);
const frames = await page.evaluate((key) => window.__previewFrames?.[key] ?? [], name);
expect(frames).toEqual([{ height: expect.any(Number), fade: true, control: "Show more" }]);
}
test.describe("long user message preview", () => {
test("opening a chat paints its long message collapsed from the first frame", async ({
page,
}) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "user-message-open-",
title: "Preview open",
initialPrompt: longPrompt("Opened prompt"),
});
try {
await agent.client.waitForFinish(agent.agentId, 30_000);
await recordPreviewFrames(page);
await openAgentRoute(page, agent);
await expectPaintedCollapsedFromFirstFrame(page, "Opened prompt");
await page.reload();
await expectPaintedCollapsedFromFirstFrame(page, "Opened prompt");
} finally {
await agent.cleanup();
}
});
test("a long message arriving in an open chat paints collapsed from the first frame", async ({
page,
}) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "user-message-arrive-",
title: "Preview arrive",
initialPrompt: "short",
});
try {
await agent.client.waitForFinish(agent.agentId, 30_000);
await recordPreviewFrames(page);
await openAgentRoute(page, agent);
await expectAgentIdle(page);
await agent.client.sendAgentMessage(agent.agentId, longPrompt("Arrived prompt"));
await expectPaintedCollapsedFromFirstFrame(page, "Arrived prompt");
} finally {
await agent.cleanup();
}
});
test("a long message that arrived while its tab was hidden paints collapsed on return", async ({
page,
}) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "user-message-hidden-",
title: "Preview first",
initialPrompt: "short",
});
try {
await agent.client.createAgent({
provider: "mock",
cwd: agent.cwd,
workspaceId: agent.workspaceId,
title: "Preview second",
modeId: "load-test",
model: "e2e-fast-stream",
});
await agent.client.waitForFinish(agent.agentId, 30_000);
await recordPreviewFrames(page);
await openAgentRoute(page, agent);
await expectAgentIdle(page);
await page.getByRole("button", { name: "Preview second", exact: true }).click();
await agent.client.sendAgentMessage(agent.agentId, longPrompt("Hidden prompt"));
await agent.client.waitForFinish(agent.agentId, 30_000);
await page.getByRole("button", { name: "Preview first", exact: true }).click();
await expectPaintedCollapsedFromFirstFrame(page, "Hidden prompt");
} finally {
await agent.cleanup();
}
});
});
@@ -50,64 +50,73 @@ async function readClipboard(page: Page): Promise<string> {
return page.evaluate(() => navigator.clipboard.readText());
}
function terminalTab(page: Page, terminalId: string) {
return page.getByTestId(`workspace-tab-terminal_${terminalId}`).first();
}
test.describe("Workspace terminal tab rename", () => {
test("right-click copy terminal id writes the terminal id to the clipboard", async ({
context,
page,
}) => {
test.setTimeout(60_000);
async function copyTerminalId(page: Page, terminalId: string): Promise<void> {
const tab = terminalTab(page, terminalId);
await expect(tab).toBeVisible({ timeout: 15_000 });
await tab.click({ button: "right" });
const copy = page.getByRole("menuitem", { name: /^Copy terminal id/ });
await expect(copy).toBeVisible({ timeout: 10_000 });
await copy.click();
}
const workspace = await seedWorkspace({ repoPrefix: "workspace-terminal-copy-id-" });
let terminalId: string | null = null;
async function renameTerminalTab(page: Page, terminalId: string, title: string): Promise<void> {
const tab = terminalTab(page, terminalId);
await expect(tab).toBeVisible({ timeout: 15_000 });
await tab.click({ button: "right" });
const rename = page.getByRole("menuitem", { name: "Rename", exact: true });
await expect(rename).toBeVisible({ timeout: 10_000 });
await rename.click();
try {
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await gotoWorkspace(page, workspace.workspaceId);
await clickNewTerminal(page);
terminalId = await waitForCreatedTerminalId(workspace);
const modalPrefix = `workspace-tab-rename-modal-terminal-${terminalId}`;
const input = renameModalInput(page, modalPrefix);
await expect(input).toBeVisible({ timeout: 10_000 });
await input.fill(title);
await renameModalSubmit(page, modalPrefix).click();
await expect(input).toHaveCount(0, { timeout: 15_000 });
}
const tab = page.getByTestId(`workspace-tab-terminal_${terminalId}`).first();
await expect(tab).toBeVisible({ timeout: 15_000 });
test("terminal context menu copies its ID and persists a renamed title", async ({
context,
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "workspace-terminal-rename-" });
let terminalId: string | null = null;
try {
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await gotoWorkspace(page, workspace.workspaceId);
await clickNewTerminal(page);
const createdTerminalId = await waitForCreatedTerminalId(workspace);
terminalId = createdTerminalId;
await test.step("copy the terminal ID from its tab", async () => {
await copyTerminalId(page, createdTerminalId);
await expect.poll(() => readClipboard(page)).toBe(createdTerminalId);
});
await test.step("rename the same terminal and read back its saved title", async () => {
await renameTerminalTab(page, createdTerminalId, "My Renamed Terminal");
await expect(terminalTab(page, createdTerminalId)).toContainText("My Renamed Terminal", {
timeout: 15_000,
await tab.click({ button: "right" });
const copyTerminalId = page.getByRole("menuitem", {
name: /^Copy terminal id/,
});
await expect(copyTerminalId).toBeVisible({ timeout: 10_000 });
await copyTerminalId.click();
await expect.poll(() => readClipboard(page)).toBe(terminalId);
} finally {
await cleanupTerminal(workspace, terminalId);
}
});
test("right-click rename persists the terminal title and updates the tab label", async ({
page,
}) => {
test.setTimeout(60_000);
const workspace = await seedWorkspace({ repoPrefix: "workspace-terminal-rename-" });
let terminalId: string | null = null;
try {
await gotoWorkspace(page, workspace.workspaceId);
await clickNewTerminal(page);
terminalId = await waitForCreatedTerminalId(workspace);
const tab = page.getByTestId(`workspace-tab-terminal_${terminalId}`).first();
await expect(tab).toBeVisible({ timeout: 15_000 });
await tab.click({ button: "right" });
const renameItem = page.getByRole("menuitem", { name: "Rename", exact: true });
await expect(renameItem).toBeVisible({ timeout: 10_000 });
await renameItem.click();
const modalPrefix = `workspace-tab-rename-modal-terminal-${terminalId}`;
const input = renameModalInput(page, modalPrefix);
await expect(input).toBeVisible({ timeout: 10_000 });
await input.fill("My Renamed Terminal");
await renameModalSubmit(page, modalPrefix).click();
await expect(input).toHaveCount(0, { timeout: 15_000 });
await expect(tab).toContainText("My Renamed Terminal", { timeout: 15_000 });
await expect
.poll(() => fetchTerminalTitle(workspace, createdTerminalId))
.poll(() => fetchTerminalTitle(workspace, terminalId!))
.toBe("My Renamed Terminal");
});
} finally {
await cleanupTerminal(workspace, terminalId);
}
} finally {
await cleanupTerminal(workspace, terminalId);
}
});
});
@@ -1,56 +0,0 @@
import { test, expect } from "../support/fixtures";
import { openAgentRoute } from "../support/helpers/mock-agent";
import {
readReactRenders,
recordReactRenders,
resetReactRenders,
} from "../support/helpers/react-renders";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
async function createMockAgent(workspace: SeededWorkspace, title: string): Promise<string> {
const agent = await workspace.client.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title,
modeId: "load-test",
model: "e2e-fast-stream",
});
return agent.id;
}
test.describe("Updates to agents in other workspaces", () => {
let open: SeededWorkspace;
let other: SeededWorkspace;
test.afterEach(async () => {
await open?.cleanup();
await other?.cleanup();
});
test("do not re-render the open workspace screen", async ({ page }) => {
open = await seedWorkspace({ repoPrefix: "unrelated-update-open-", title: "Open" });
other = await seedWorkspace({ repoPrefix: "unrelated-update-other-", title: "Other" });
const openAgentId = await createMockAgent(open, "Open agent");
const otherAgentId = await createMockAgent(other, "Other agent");
await recordReactRenders(page);
await openAgentRoute(page, { workspaceId: open.workspaceId, agentId: openAgentId });
await expect(
page.getByTestId(`workspace-tab-agent_${openAgentId}`).filter({ visible: true }).first(),
).toBeVisible({ timeout: 60_000 });
await page.waitForTimeout(2_000);
await resetReactRenders(page);
await other.client.sendAgentMessage(otherAgentId, "update");
await other.client.waitForFinish(otherAgentId, 60_000);
// The favicon follows every agent on the host, so it renders when the other agent runs and
// settles. That confirms the updates reached this page.
await expect
.poll(async () => (await readReactRenders(page)).FaviconStatusSync ?? 0)
.toBeGreaterThan(0);
const renders = await readReactRenders(page);
expect(renders.WorkspaceScreenContent ?? 0).toBe(0);
});
});
@@ -1,6 +1,7 @@
import { randomUUID } from "node:crypto";
import { execFileSync } from "node:child_process";
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import {
restoreArchivedWorkspace,
@@ -299,7 +300,9 @@ test.describe("Worktree restore", () => {
).toHaveText(switchedBranch, { timeout: 30_000 });
});
test("restores the workspace and all agents closed by its archive", async ({ page }) => {
test("restores the workspace before explicitly unarchiving each selected agent", async ({
page,
}) => {
const { agents, worktree } = await createArchivedMissingWorktree("restore-agents", {
agentCount: 2,
keepAgentsArchived: true,
@@ -327,11 +330,13 @@ test.describe("Worktree restore", () => {
await expect(
page.getByTestId(`workspace-tab-agent_${firstAgent.id}`).filter({ visible: true }).first(),
).toHaveAttribute("aria-selected", "true");
await expect.poll(() => fetchAgentArchivedAt(client, firstAgent.id)).toBeNull();
await expect.poll(() => fetchAgentArchivedAt(client, secondAgent.id)).toBeNull();
await expect(
page.getByTestId(`workspace-tab-agent_${secondAgent.id}`).filter({ visible: true }).first(),
).toBeVisible();
expect(await fetchAgentArchivedAt(client, firstAgent.id)).not.toBeNull();
expect(await fetchAgentArchivedAt(client, secondAgent.id)).not.toBeNull();
await page.getByRole("button", { name: "Unarchive", exact: true }).click();
await expect
.poll(() => fetchAgentArchivedAt(client, firstAgent.id), { timeout: 30_000 })
.toBeNull();
expect(await fetchAgentArchivedAt(client, secondAgent.id)).not.toBeNull();
await expect(page.getByRole("button", { name: "Unarchive" })).toHaveCount(0, {
timeout: 30_000,
});
@@ -341,8 +346,13 @@ test.describe("Worktree restore", () => {
await expect(
page.getByTestId(`workspace-tab-agent_${secondAgent.id}`).filter({ visible: true }).first(),
).toBeVisible({ timeout: 30_000 });
await expect(page.getByRole("button", { name: "Unarchive" })).toHaveCount(0);
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeEditable();
await expect(page.getByRole("button", { name: "Unarchive" })).toBeVisible({
timeout: 30_000,
});
await page.getByRole("button", { name: "Unarchive" }).click();
await expect
.poll(() => fetchAgentArchivedAt(client, secondAgent.id), { timeout: 30_000 })
.toBeNull();
});
test("restores a branchless workspace so its archived agent can be used again", async ({
@@ -400,7 +410,12 @@ test.describe("Worktree restore", () => {
await expect
.poll(() => existsSync(backingWorktree.workspaceDirectory), { timeout: 30_000 })
.toBe(true);
await expect(page.getByRole("button", { name: "Unarchive", exact: true })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Unarchive", exact: true })).toBeVisible({
timeout: 30_000,
});
await captureBranchlessRestoreStep(page, "02-unarchive-agent.png");
await page.getByRole("button", { name: "Unarchive", exact: true }).click();
await expect.poll(() => fetchAgentArchivedAt(client, agent.id)).toBeNull();
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeEditable();
await captureBranchlessRestoreStep(page, "03-agent-ready.png");
@@ -491,11 +506,7 @@ test.describe("Worktree restore", () => {
});
async function captureBranchlessRestoreStep(page: Page, filename: string): Promise<void> {
const screenshotDirectory = path.join(
process.cwd(),
".artifacts",
"paseo-branchless-restore-evidence",
);
const screenshotDirectory = path.join(tmpdir(), "paseo-branchless-restore-evidence");
mkdirSync(screenshotDirectory, { recursive: true });
await page.screenshot({ path: path.join(screenshotDirectory, filename), fullPage: true });
}
@@ -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,
};
}
@@ -41,14 +41,6 @@ export async function hoverContextWindowMeter(page: Page, name = METER_NAME): Pr
return card;
}
/** Taps the meter on a touch screen and returns its details once they are on screen. */
export async function tapContextWindowMeter(page: Page, name = METER_NAME): Promise<Locator> {
await page.getByRole("img", { name, exact: true }).tap({ timeout: 30_000 });
const card = contextWindowDetails(page);
await expect(card).toBeVisible();
return card;
}
/** Walks the pointer from the meter up onto its hover card, across the gap between them. */
export async function moveOntoContextWindowDetails(page: Page, card: Locator): Promise<void> {
const meter = await page.getByRole("img", { name: METER_NAME }).boundingBox();
+4 -32
View File
@@ -31,13 +31,10 @@ async function expectContextMenuAtPointer(
menuTestID: string,
anchor: ContextMenuAnchor,
): Promise<void> {
// The visible measuring surface is replaced when layout places the menu at its anchor.
await expect(async () => {
const bounds = await page.getByTestId(menuTestID).boundingBox();
if (!bounds) throw new Error(`Could not measure ${menuTestID}`);
expect(Math.abs(bounds.x - anchor.x)).toBeLessThanOrEqual(2);
expect(Math.abs(bounds.y - (anchor.y + 4))).toBeLessThanOrEqual(2);
}).toPass({ timeout: 10_000 });
const bounds = await page.getByTestId(menuTestID).boundingBox();
if (!bounds) throw new Error(`Could not measure ${menuTestID}`);
expect(Math.abs(bounds.x - anchor.x)).toBeLessThanOrEqual(2);
expect(Math.abs(bounds.y - (anchor.y + 4))).toBeLessThanOrEqual(2);
}
export async function openWorkspaceContextMenu(page: Page, workspaceId: string): Promise<void> {
@@ -132,31 +129,6 @@ export async function expectWorkspaceHoverCardClosed(page: Page): Promise<void>
await expect(page.getByTestId("workspace-hover-card")).toHaveCount(0);
}
/**
* Records whether a workspace hover card mounts at any point from now on, including one that
* closes again before an assertion could see it.
*/
export async function watchWorkspaceHoverCard(
page: Page,
): Promise<{ hasOpened: () => Promise<boolean> }> {
await page.evaluate(() => {
const target = window as unknown as { __workspaceHoverCardOpened?: boolean };
const isOpen = () => document.querySelector('[data-testid="workspace-hover-card"]') !== null;
target.__workspaceHoverCardOpened = isOpen();
new MutationObserver(() => {
if (isOpen()) target.__workspaceHoverCardOpened = true;
}).observe(document.body, { childList: true, subtree: true });
});
return {
hasOpened: () =>
page.evaluate(
() =>
(window as unknown as { __workspaceHoverCardOpened?: boolean })
.__workspaceHoverCardOpened === true,
),
};
}
export async function closeWorkspaceContextMenu(page: Page, workspaceId: string): Promise<void> {
const workspaceKey = `${getServerId()}:${workspaceId}`;
await page.getByTestId(`sidebar-workspace-context-menu-${workspaceKey}-backdrop`).click();
@@ -8,12 +8,9 @@ android {
defaultConfig {
versionCode 1
versionName '0.1.0'
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
}
}
dependencies {
implementation 'com.facebook.react:react-android'
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test:runner:1.6.2'
androidTestImplementation 'androidx.test.ext:junit:1.2.1'
}
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity android:name="android.app.Activity" android:exported="false" />
</application>
</manifest>
@@ -1,81 +0,0 @@
package sh.paseo.keyboard
import android.app.Activity
import android.content.Intent
import android.view.KeyEvent
import android.widget.EditText
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
/** Exercises the real Android window and editable that receive physical keys. */
@RunWith(AndroidJUnit4::class)
class TerminalArrowTest {
private val instrumentation = InstrumentationRegistry.getInstrumentation()
private var activity: Activity? = null
@After fun finish() {
instrumentation.runOnMainSync {
activity?.let {
HardwareKeyboard.release(it.window)
it.finish()
}
}
}
@Test fun physicalArrowsDoNotMoveTheTerminalInputCaret() {
withInput(terminal = true) { input, host ->
for (code in listOf(KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_DPAD_DOWN)) {
for (modifiers in listOf(0, KeyEvent.META_SHIFT_ON, KeyEvent.META_ALT_ON, KeyEvent.META_CTRL_ON)) {
input.setSelection(3)
for (repeat in 0..2) {
assertTrue(host.window.callback.dispatchKeyEvent(KeyEvent(0, 0, KeyEvent.ACTION_DOWN, code, repeat, modifiers)))
assertEquals("Physical arrows must leave the hidden input caret at the end", 3, input.selectionStart)
}
assertTrue(host.window.callback.dispatchKeyEvent(KeyEvent(0, 0, KeyEvent.ACTION_UP, code, 0, modifiers)))
}
}
host.window.callback.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_X))
host.window.callback.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_X))
assertEquals("abcx", input.text.toString())
}
}
@Test fun physicalArrowsStillEditOtherTextFields() {
withInput(terminal = false) { input, host ->
host.window.callback.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_LEFT))
host.window.callback.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_DPAD_LEFT))
assertEquals(2, input.selectionStart)
host.window.callback.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_X))
host.window.callback.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_X))
assertEquals("abxc", input.text.toString())
}
}
private fun withInput(terminal: Boolean, check: (EditText, Activity) -> Unit) {
val host = instrumentation.startActivitySync(
Intent(instrumentation.targetContext, Activity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
activity = host
lateinit var input: EditText
instrumentation.runOnMainSync {
input = EditText(host)
if (terminal) input.setTag(com.facebook.react.R.id.view_tag_native_id, "paseo-terminal-input")
host.setContentView(input)
input.setText("abc")
input.requestFocus()
input.setSelection(3)
HardwareKeyboard.observe(host.window)
}
instrumentation.waitForIdleSync()
instrumentation.runOnMainSync {
assertSame("The editable must own window focus before keys are dispatched", input, host.window.currentFocus)
check(input, host)
}
}
}
@@ -10,24 +10,18 @@ import android.view.KeyEvent
import android.view.Window
import android.widget.EditText
import android.view.inputmethod.BaseInputConnection
import com.facebook.react.R
import com.facebook.react.bridge.UiThreadUtil
import com.facebook.react.bridge.ReactContext
import com.facebook.react.interfaces.ExtraWindowEventListener
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
// Matched against the hidden terminal input's `nativeID`
// (TERMINAL_INPUT_NATIVE_ID in terminal-input.native.tsx).
private const val TERMINAL_INPUT_NATIVE_ID = "paseo-terminal-input"
internal object HardwareKeyboard {
var module: PaseoHardwareKeyboardModule? = null
@Volatile var overlayKeys: Set<String> = emptySet()
private val windows = mutableMapOf<Window, KeyboardWindowCallback>()
val dispatcher = WindowKeyDispatcher<KeyEvent>()
private val consumedPresses = mutableSetOf<Int>()
private val terminalArrowCodes = setOf("ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight")
fun observe(window: Window) {
if (windows.containsKey(window)) return
@@ -44,7 +38,7 @@ internal object HardwareKeyboard {
consumedPresses.clear()
}
fun dispatch(event: KeyEvent, composing: Boolean, terminalFocused: Boolean): Boolean {
fun dispatch(event: KeyEvent, composing: Boolean): Boolean {
if (event.flags and KeyEvent.FLAG_SOFT_KEYBOARD != 0) return false
if (event.action != KeyEvent.ACTION_DOWN && event.action != KeyEvent.ACTION_UP) return false
val code = code(event.keyCode)
@@ -70,12 +64,8 @@ internal object HardwareKeyboard {
))
if (event.action == KeyEvent.ACTION_UP) return consumedPresses.remove(event.keyCode)
val overlayOwnsKey = key != "Dead" && key in overlayKeys && !event.isCtrlPressed && !event.isMetaPressed && !event.isAltPressed && !composing
// Without this the hidden input consumes the arrows as caret movement and
// its next text change stops being a plain append. Modifiers stay
// forwarded: Alt/Shift+arrow are terminal editing keys.
val terminalOwnsKey = terminalFocused && !composing && code in terminalArrowCodes
if (overlayOwnsKey || terminalOwnsKey) consumedPresses.add(event.keyCode)
return overlayOwnsKey || terminalOwnsKey
if (overlayOwnsKey) consumedPresses.add(event.keyCode)
return overlayOwnsKey
}
private fun code(keyCode: Int): String = when (keyCode) {
@@ -117,15 +107,9 @@ internal object HardwareKeyboard {
private class KeyboardWindowCallback(private val window: Window, val previous: Window.Callback) : Window.Callback by previous {
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
val focus = window.currentFocus
val editable = (focus as? EditText)?.text
val editable = (window.currentFocus as? EditText)?.text
val composing = editable != null && BaseInputConnection.getComposingSpanStart(editable) >= 0
val terminalFocused = focus?.getTag(R.id.view_tag_native_id) == TERMINAL_INPUT_NATIVE_ID
return HardwareKeyboard.dispatcher.dispatch(
event,
{ HardwareKeyboard.dispatch(event, composing, terminalFocused) },
{ previous.dispatchKeyEvent(event) },
)
return HardwareKeyboard.dispatcher.dispatch(event, { HardwareKeyboard.dispatch(event, composing) }, { previous.dispatchKeyEvent(event) })
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.16.0-beta.2",
"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);
@@ -4,12 +4,8 @@ import { within } from "@testing-library/dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { i18n as testI18n } from "@/i18n/i18next";
import { ExpandableBadge } from "@/components/message";
import { page, userEvent } from "vitest/browser";
import { PlanCard } from "@/components/plan-card";
import { MessagePreview } from "@/components/user-message";
import { createUserMessageExpansion } from "@/components/user-message/expansion";
import { UserMessageExpansionContext } from "@/components/user-message/expansion-context";
import { DisclosureRow } from "./disclosure";
import type { DisclosureTriggerEdge, StreamViewportHandle } from "./strategy";
@@ -76,19 +72,26 @@ const view = () => within(container);
const preLine = { whiteSpace: "pre-line" } as const;
async function waitForLayout() {
await act(async () => {
for (let frame = 0; frame < 2; frame += 1) {
await new Promise((resolve) => requestAnimationFrame(resolve));
}
});
}
describe("timeline disclosures hold their row", () => {
it("routes user message show more and show less through the row's hold", () => {
it("routes user message show more and show less through the row's hold", async () => {
const longText = Array.from({ length: 60 }, (_, line) => `Line ${line + 1}`).join("\n");
const expansion = createUserMessageExpansion();
const holds = mountRow(
"message-1",
() => view().queryByRole("button", { name: "Show less" }) !== null,
<UserMessageExpansionContext.Provider value={expansion}>
<MessagePreview itemId="message-1">
<span style={preLine}>{longText}</span>
</MessagePreview>
</UserMessageExpansionContext.Provider>,
<MessagePreview>
<span style={preLine}>{longText}</span>
</MessagePreview>,
);
// The preview learns it overflows from its first layout.
await waitForLayout();
act(() => view().getByRole("button", { name: "Show more" }).click());
expect(view().getByRole("button", { name: "Show less" })).toBeTruthy();
@@ -142,63 +145,3 @@ describe("timeline disclosures hold their row", () => {
expect(isExpanded()).toBe(true);
});
});
function renderFileDetails() {
return <div>File contents</div>;
}
describe("tool badge file action", () => {
it("keeps file opening separate from the accessible disclosure button", async () => {
let opened = 0;
function Badge({ hasFile }: { hasFile: boolean }) {
const [expanded, setExpanded] = React.useState(false);
const toggle = React.useCallback(() => setExpanded(!expanded), [expanded]);
const openFile = React.useCallback(() => {
opened += 1;
}, []);
return (
<ExpandableBadge
label="Read"
secondaryLabel="src/index.ts"
isExpanded={expanded}
onToggle={toggle}
onOpenFile={hasFile ? openFile : undefined}
renderDetails={renderFileDetails}
/>
);
}
container.style.width = "300px";
container.style.marginLeft = "20px";
act(() => root.render(<Badge hasFile={false} />));
const header = page.getByRole("button", { name: "Read src/index.ts", exact: true });
const headerElement = view().getByRole("button", { name: /^Read src\/index\.ts$/ });
expect(headerElement.getBoundingClientRect().width).toBeGreaterThan(260);
await header.click({ position: { x: 220, y: 10 } });
await expect.element(page.getByText("File contents")).toBeVisible();
await header.click({ position: { x: 2, y: 2 } });
await expect.element(page.getByText("File contents")).not.toBeInTheDocument();
await header.click({ position: { x: 2, y: 2 } });
await expect.element(page.getByText("File contents")).toBeVisible();
await header.click({ position: { x: 220, y: 10 } });
await expect.element(page.getByText("File contents")).not.toBeInTheDocument();
act(() => root.render(<Badge hasFile />));
await page.getByRole("button", { name: "Read src/index.ts", exact: true }).hover();
const file = view().getByRole("button", { name: /^Open file$/ });
expect(file.parentElement?.closest("button")).toBeNull();
await page.getByRole("button", { name: "Open file", exact: true }).click();
expect(opened).toBe(1);
const disclosure = view().getByRole("button", { name: /^Read src\/index\.ts$/ });
expect(view().queryByText("File contents")).toBeNull();
await header.click({ position: { x: 220, y: 10 } });
await expect.element(page.getByText("File contents")).toBeVisible();
expect(opened).toBe(1);
act(() => file.focus());
await userEvent.keyboard("{Enter}");
expect(opened).toBe(2);
expect(view().getByText("File contents")).toBeTruthy();
act(() => disclosure.focus());
await userEvent.keyboard("{Enter}");
expect(view().queryByText("File contents")).toBeNull();
});
});
@@ -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"));
+31 -34
View File
@@ -66,10 +66,6 @@ import {
useAgentToolCallExpansion,
useExpandedToolCallGroups,
} from "@/tool-calls/expansion-context";
import {
UserMessageExpansionContext,
useAgentUserMessageExpansion,
} from "@/components/user-message/expansion-context";
import { ToolCallSheetProvider } from "@/components/tool-call-sheet";
import { createStreamPresentation } from "./presentation";
import { OverviewToolCallGroupView } from "@/tool-calls/detail-level/overview/view";
@@ -386,7 +382,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
new Set(),
);
const toolCallExpansion = useAgentToolCallExpansion(agentId);
const userMessageExpansion = useAgentUserMessageExpansion(agentId);
const expandedToolCallGroups = useExpandedToolCallGroups(toolCallExpansion);
const [expandedTurnHostIds, setExpandedTurnHostIds] = useState<ReadonlySet<string>>(new Set());
@@ -411,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,
);
@@ -716,7 +715,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
(layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "user_message" }>) => {
return (
<UserMessage
itemId={item.id}
serverId={resolvedServerId}
agentId={agentId}
messageId={item.messageId}
@@ -1166,6 +1164,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
agentId={agentId}
serverId={resolvedServerId}
epoch={timelineEpoch}
rewindSeq={timelineRewindSeq}
items={findItems}
viewportRef={viewportRef}
revealLoadedMessage={revealLoadedMessage}
@@ -1175,34 +1174,32 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
<AssistantSelectionCopySurface style={stylesheet.container}>
<MessageOuterSpacingProvider disableOuterSpacing>
<ToolCallExpansionContext.Provider value={toolCallExpansion}>
<UserMessageExpansionContext.Provider value={userMessageExpansion}>
{streamRenderStrategy.render({
readingRowReplacements: presentation.readingRowReplacements,
agentId,
segments: renderModel.segments,
historyRowRevision,
liveHeadRowRevision: expandedToolCallGroups,
boundary,
renderers,
listEmptyComponent,
viewportRef,
routeBottomAnchorRequest,
isAuthoritativeHistoryReady,
isInitialHistoryLoading,
revealInitialHistory,
onNearBottomChange: nearBottom.set,
onReadingPositionChange: handleReadingPositionChange,
onNearHistoryStart: loadOlder,
isLoadingOlderHistory: isLoadingOlder,
hasOlderHistory: hasOlder,
olderHistoryProgressKey: progressKey,
scrollEnabled: streamScrollEnabled,
listStyle: stylesheet.list,
baseListContentContainerStyle: stylesheet.listContentContainer,
forwardListContentContainerStyle: stylesheet.forwardListContentContainer,
contentMaxWidth,
})}
</UserMessageExpansionContext.Provider>
{streamRenderStrategy.render({
readingRowReplacements: presentation.readingRowReplacements,
agentId,
segments: renderModel.segments,
historyRowRevision,
liveHeadRowRevision: expandedToolCallGroups,
boundary,
renderers,
listEmptyComponent,
viewportRef,
routeBottomAnchorRequest,
isAuthoritativeHistoryReady,
isInitialHistoryLoading,
revealInitialHistory,
onNearBottomChange: nearBottom.set,
onReadingPositionChange: handleReadingPositionChange,
onNearHistoryStart: loadOlder,
isLoadingOlderHistory: isLoadingOlder,
hasOlderHistory: hasOlder,
olderHistoryProgressKey: progressKey,
scrollEnabled: streamScrollEnabled,
listStyle: stylesheet.list,
baseListContentContainerStyle: stylesheet.listContentContainer,
forwardListContentContainerStyle: stylesheet.forwardListContentContainer,
contentMaxWidth,
})}
</ToolCallExpansionContext.Provider>
</MessageOuterSpacingProvider>
<ChatOutlineRail
@@ -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}>
@@ -63,25 +63,10 @@ const html = `<!doctype html>
#diagram svg {
display: block;
}
/* Keep Mermaid's temporary render container unscaled when the host mounts this frame
at 0x0 or 240x240 before the diagram's own dimensions are known. */
#staging-clip {
position: fixed;
width: 0;
height: 0;
overflow: hidden;
visibility: hidden;
pointer-events: none;
}
#staging {
width: 16384px;
height: 16384px;
}
</style>
</head>
<body>
<div id="diagram"></div>
<div id="staging-clip" aria-hidden="true"><div id="staging"></div></div>
<script>${js}</script>
</body>
</html>`;
@@ -40,9 +40,6 @@ function initializeMermaid(colorScheme: "light" | "dark"): void {
],
flowchart: { htmlLabels: false },
class: { htmlLabels: false },
// A gantt chart takes the width of the element it renders in, which is the oversized staging
// container. Keep it as wide as this frame, which on native is the message column.
gantt: { useWidth: document.body.clientWidth },
});
}
@@ -100,15 +97,7 @@ function measureDiagram(host: HTMLElement, svg: SVGSVGElement | null): DiagramSi
async function render(message: MermaidRuntimeRenderMessage): Promise<void> {
try {
initializeMermaid(message.colorScheme);
const staging = document.getElementById("staging");
if (!staging) {
throw new Error("Missing Mermaid staging container");
}
const { svg } = await mermaid.render(
`paseo-mermaid-${message.revision}`,
message.source,
staging,
);
const { svg } = await mermaid.render(`paseo-mermaid-${message.revision}`, message.source);
if (message.revision !== latestRevision) {
return;
}
File diff suppressed because one or more lines are too long
@@ -29,15 +29,9 @@ function waitForRuntimeMessage(
});
}
async function mountRuntime(
size?: { width: number; height: number },
options?: { allowSameOrigin?: boolean },
): Promise<HTMLIFrameElement> {
async function mountRuntime(size?: { width: number; height: number }): Promise<HTMLIFrameElement> {
const frame = document.createElement("iframe");
frame.sandbox.add("allow-scripts");
if (options?.allowSameOrigin) {
frame.sandbox.add("allow-same-origin");
}
if (size) {
frame.style.width = `${size.width}px`;
frame.style.height = `${size.height}px`;
@@ -118,103 +112,6 @@ describe("Mermaid sandbox runtime", () => {
expect(renderedSize(narrow)).toEqual(renderedSize(wide));
});
/**
* MermaidFenceHost renders the first revision inside a 240x240 measuring frame (or 0x0 before
* ResizeObserver settles), then resizes the frame to the reported diagram size without
* re-rendering. Diagrams such as xychart-beta set `width="100%"` and `viewBox="0 0 700 500"`
* before measuring axis text with `getBoundingClientRect()`, so rendering directly in a narrow
* frame shrinks measured glyph boxes and leaves the Y-axis title colliding with tick labels and
* X-axis labels clipped at the bottom edge.
*/
it("preserves unscaled SVG layout when rendered inside a narrow or zero-sized frame", async () => {
const source = [
"xychart-beta",
' title "Throughput by Stage (req/s)"',
' x-axis ["v1-base", "v2-parse", "v3-batch", "v4-cache", "v5-simd", "v6-final"]',
' y-axis "Throughput (req/s)" 92.5 --> 93.6',
" bar [92.7, 92.8, 93.1, 93.2, 93.3, 93.5]",
].join("\n");
const zeroFrame = await mountRuntime({ height: 0, width: 0 }, { allowSameOrigin: true });
const measuringFrame = await mountRuntime(
{ height: 240, width: 240 },
{ allowSameOrigin: true },
);
const fullFrame = await mountRuntime({ height: 500, width: 700 }, { allowSameOrigin: true });
await render(zeroFrame, { revision: 1, source });
await render(measuringFrame, { revision: 1, source });
await render(fullFrame, { revision: 1, source });
for (const frame of [zeroFrame, measuringFrame]) {
frame.style.width = "700px";
frame.style.height = "500px";
const doc = frame.contentDocument;
if (!doc) {
throw new Error("Expected inspectable runtime document");
}
const svgRect = doc.querySelector("#diagram svg")?.getBoundingClientRect();
const yTitleRect = doc.querySelector(".left-axis .title text")?.getBoundingClientRect();
const yLabels = Array.from(doc.querySelectorAll(".left-axis .label text"));
const xLabels = Array.from(doc.querySelectorAll(".bottom-axis .label text"));
expect(svgRect).not.toBeUndefined();
expect(yTitleRect).not.toBeUndefined();
expect(yLabels.length).toBeGreaterThan(0);
expect(xLabels.length).toBeGreaterThan(0);
for (const label of yLabels) {
expect(yTitleRect!.right).toBeLessThan(label.getBoundingClientRect().left);
}
for (const label of xLabels) {
expect(label.getBoundingClientRect().bottom).toBeLessThanOrEqual(svgRect!.bottom);
}
}
function axisOrigin(frame: HTMLIFrameElement): { leftAxisX: number; bottomAxisY: number } {
const doc = frame.contentDocument;
const leftPath = doc?.querySelector(".left-axis .axisl-line path")?.getAttribute("d") ?? "";
const bottomPath =
doc?.querySelector(".bottom-axis .axis-line path")?.getAttribute("d") ?? "";
const leftMatch = leftPath.match(/^M\s+([\d.]+),/);
const bottomMatch = bottomPath.match(/^M\s+[\d.]+,([\d.]+)/);
if (!leftMatch || !bottomMatch) {
throw new Error(`Unexpected axis paths: ${leftPath} | ${bottomPath}`);
}
return {
leftAxisX: Number.parseFloat(leftMatch[1]),
bottomAxisY: Number.parseFloat(bottomMatch[1]),
};
}
const fullOrigin = axisOrigin(fullFrame);
for (const candidate of [measuringFrame, zeroFrame]) {
const origin = axisOrigin(candidate);
expect(origin.leftAxisX).toBeCloseTo(fullOrigin.leftAxisX, 3);
expect(origin.bottomAxisY).toBeCloseTo(fullOrigin.bottomAxisY, 3);
}
});
/**
* A gantt chart takes the width of the element Mermaid renders it in. Rendering inside the large
* offscreen container must keep it as wide as the frame, which on native is the message column.
*/
it("renders a gantt chart as wide as the frame the host gives it", async () => {
const source = [
"gantt",
" title Release plan",
" dateFormat YYYY-MM-DD",
" section Build",
" Design :a1, 2024-01-01, 10d",
" Implement :after a1, 20d",
].join("\n");
const widths = [];
for (const width of [240, 350, 900]) {
const frame = await mountRuntime({ height: 240, width });
widths.push(renderedSize(await render(frame, { revision: 1, source })).width);
}
expect(widths).toEqual([240, 350, 900]);
});
it("coalesces queued input and never reports an obsolete result", async () => {
const frame = await mountRuntime();
const obsoleteSource = `flowchart TD\n${Array.from({ length: 250 }, (_, index) => `A${index} --> A${index + 1}`).join("\n")}`;
+60 -41
View File
@@ -92,8 +92,6 @@ export type { InlinePathTarget } from "@/assistant-file-links";
export type { AssistantForkTarget };
interface UserMessageProps {
/** The timeline item's id, which keeps the message's expanded state. */
itemId: string;
sender?: AgentMessage["sender"];
serverId?: string;
agentId?: string;
@@ -393,7 +391,6 @@ function UserMessageImagePill({ image, onOpen, accessibilityLabel }: UserMessage
const MESSAGE_TEXT_DATASET = { messageText: "true" };
export const UserMessage = memo(function UserMessage({
itemId,
sender,
serverId,
agentId,
@@ -483,7 +480,7 @@ export const UserMessage = memo(function UserMessage({
>
{sender ? <MessageSender sender={sender} serverId={serverId} /> : null}
<View style={userMessageStylesheet.bubble}>
<MessagePreview itemId={itemId}>
<MessagePreview>
{hasImages ? (
<View style={imagePreviewContainerStyle}>
{images.map((image) => (
@@ -925,19 +922,13 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius.lg,
borderWidth: theme.borderWidth[1],
borderColor: "transparent",
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1],
overflow: "hidden",
},
pressablePressed: {
opacity: 0.9,
},
disclosureButton: {
flex: 1,
minWidth: 0,
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1],
flexDirection: "row",
alignItems: "center",
},
headerRow: {
flexDirection: "row",
alignItems: "center",
@@ -993,12 +984,15 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
flexShrink: 0,
},
openFileButton: {
marginRight: theme.spacing[2],
marginLeft: theme.spacing[1],
padding: theme.spacing[1],
borderRadius: theme.borderRadius.md,
flexShrink: 0,
},
openFileButtonPlaceholderIcon: {
width: 14,
height: 14,
},
detailWrapper: {
borderBottomLeftRadius: theme.borderRadius.lg,
borderBottomRightRadius: theme.borderRadius.lg,
@@ -1604,6 +1598,7 @@ interface ExpandableBadgeWebShimmerOverlayProps {
secondaryLabel?: string;
shimmerLabelTextStyle: StyleProp<TextStyle>;
shimmerSecondaryTextStyle: StyleProp<TextStyle>;
showOpenFileButton: boolean;
}
function ExpandableBadgeWebShimmerOverlay({
@@ -1611,6 +1606,7 @@ function ExpandableBadgeWebShimmerOverlay({
secondaryLabel,
shimmerLabelTextStyle,
shimmerSecondaryTextStyle,
showOpenFileButton,
}: ExpandableBadgeWebShimmerOverlayProps) {
return (
<View style={expandableBadgeStylesheet.shimmerOverlay} pointerEvents="none">
@@ -1622,7 +1618,14 @@ function ExpandableBadgeWebShimmerOverlay({
{secondaryLabel}
</Text>
) : null}
{!secondaryLabel ? <View style={expandableBadgeStylesheet.spacer} /> : null}
{showOpenFileButton ? (
<View style={expandableBadgeStylesheet.openFileButton}>
<View style={expandableBadgeStylesheet.openFileButtonPlaceholderIcon} />
</View>
) : null}
{!secondaryLabel && !showOpenFileButton ? (
<View style={expandableBadgeStylesheet.spacer} />
) : null}
</View>
);
}
@@ -1646,6 +1649,11 @@ interface ExpandableBadgeLabelRowProps {
onLabelRowLayout: (event: LayoutChangeEvent) => void;
onLabelLayout: (event: LayoutChangeEvent) => void;
onSecondaryLayout: (event: LayoutChangeEvent) => void;
showOpenFileButton: boolean;
isOpenFileHovered: boolean;
onOpenFilePress: (event: GestureResponderEvent) => void;
onOpenFileHoverIn: () => void;
onOpenFileHoverOut: () => void;
}
function ExpandableBadgeLabelRow({
@@ -1667,7 +1675,13 @@ function ExpandableBadgeLabelRow({
onLabelRowLayout,
onLabelLayout,
onSecondaryLayout,
showOpenFileButton,
isOpenFileHovered,
onOpenFilePress,
onOpenFileHoverIn,
onOpenFileHoverOut,
}: ExpandableBadgeLabelRowProps) {
const { t } = useTranslation();
return (
<View
style={expandableBadgeStylesheet.labelRow}
@@ -1686,12 +1700,30 @@ function ExpandableBadgeLabelRow({
shouldMeasureWebShimmer={shouldMeasureWebShimmer}
onSecondaryLayout={onSecondaryLayout}
/>
{showOpenFileButton ? (
<Pressable
onPress={onOpenFilePress}
onHoverIn={onOpenFileHoverIn}
onHoverOut={onOpenFileHoverOut}
accessibilityRole="button"
accessibilityLabel={t("message.actions.openFile")}
testID="tool-call-open-file"
style={expandableBadgeStylesheet.openFileButton}
hitSlop={6}
>
<ThemedFileSymlinkIcon
size={14}
uniProps={isOpenFileHovered ? foregroundColorMapping : foregroundMutedColorMapping}
/>
</Pressable>
) : null}
{isWebShimmer ? (
<ExpandableBadgeWebShimmerOverlay
label={label}
secondaryLabel={secondaryLabel}
shimmerLabelTextStyle={shimmerLabelTextStyle}
shimmerSecondaryTextStyle={shimmerSecondaryTextStyle}
showOpenFileButton={showOpenFileButton}
/>
) : null}
{isNativeShimmer ? (
@@ -1896,7 +1928,6 @@ export const ExpandableBadge = memo(function ExpandableBadge({
borderlessWhenExpanded = false,
testID,
}: ExpandableBadgeProps) {
const { t } = useTranslation();
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
const [isHovered, setIsHovered] = useState(false);
const [isOpenFileHovered, setIsOpenFileHovered] = useState(false);
@@ -2159,13 +2190,13 @@ export const ExpandableBadge = memo(function ExpandableBadge({
onPointerEnter={isWeb ? handleHoverIn : undefined}
onPointerLeave={isWeb ? handleHoverOut : undefined}
>
<View style={[pressableStyle, expandableBadgeStylesheet.headerRow]}>
<Pressable
{...pressHandlers}
disabled={!isInteractive}
accessibilityState={accessibilityState}
style={expandableBadgeStylesheet.disclosureButton}
>
<Pressable
{...pressHandlers}
disabled={!isInteractive}
accessibilityState={accessibilityState}
style={pressableStyle}
>
<View style={expandableBadgeStylesheet.headerRow}>
<View style={expandableBadgeStylesheet.iconBadge}>{iconSlotNode}</View>
<ExpandableBadgeLabelRow
label={label}
@@ -2186,26 +2217,14 @@ export const ExpandableBadge = memo(function ExpandableBadge({
onLabelRowLayout={handleLabelRowLayout}
onLabelLayout={handleLabelLayout}
onSecondaryLayout={handleSecondaryLayout}
showOpenFileButton={Boolean(onOpenFile && isHovered)}
isOpenFileHovered={isOpenFileHovered}
onOpenFilePress={handleOpenFilePress}
onOpenFileHoverIn={handleOpenFileHoverIn}
onOpenFileHoverOut={handleOpenFileHoverOut}
/>
</Pressable>
{onOpenFile && isHovered ? (
<Pressable
onPress={handleOpenFilePress}
onHoverIn={handleOpenFileHoverIn}
onHoverOut={handleOpenFileHoverOut}
accessibilityRole="button"
accessibilityLabel={t("message.actions.openFile")}
testID="tool-call-open-file"
style={expandableBadgeStylesheet.openFileButton}
hitSlop={6}
>
<ThemedFileSymlinkIcon
size={14}
uniProps={isOpenFileHovered ? foregroundColorMapping : foregroundMutedColorMapping}
/>
</Pressable>
) : null}
</View>
</View>
</Pressable>
{detailContent ? (
<Pressable
ref={detailWrapperRef}
@@ -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()
@@ -17,7 +17,6 @@ import {
useEffect,
useRef,
type ReactElement,
type ReactNode,
type MutableRefObject,
type Ref,
type ComponentProps,
@@ -36,7 +35,7 @@ import type { Theme } from "@/styles/theme";
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
import { getSidebarRowBackdrop } from "@/components/sidebar/sidebar-row-backdrop";
import { type GestureType } from "react-native-gesture-handler";
import { useSidebarWorkspaceRename } from "@/workspace-rename";
import { WorkspaceRenameModal } from "@/components/workspace-rename-modal";
import { useWorkspaceClipboardActions } from "@/hooks/use-workspace-clipboard-actions";
import { ExternalLink, Settings, MoreVertical, Plus, Trash2 } from "lucide-react-native";
import { NestableScrollContainer } from "react-native-draggable-flatlist";
@@ -288,8 +287,6 @@ interface WorkspaceRowInnerProps {
onCopyBranchName?: () => void;
onCopyPath?: () => void;
onRename?: () => void;
/** Present while the row is renamed in place: the row stops pressing, dragging and hovering. */
titleEditor?: ReactNode;
onMarkAsRead?: () => void;
onMarkAsUnread?: () => void;
archiveShortcutKeys?: ShortcutKey[][] | null;
@@ -1078,7 +1075,6 @@ function WorkspaceRowInner({
onCopyBranchName,
onCopyPath,
onRename,
titleEditor = null,
onMarkAsRead,
onMarkAsUnread,
archiveShortcutKeys,
@@ -1120,14 +1116,9 @@ function WorkspaceRowInner({
}, [interaction]);
const accessibilityState = useMemo(() => ({ selected }), [selected]);
const isEditingTitle = titleEditor !== null;
return (
<SidebarWorkspaceRowFrame
workspace={workspace}
isDragging={isDragging}
isEditingTitle={isEditingTitle}
>
<SidebarWorkspaceRowFrame workspace={workspace} isDragging={isDragging}>
{({ isHovered, contextMenuOpen, onContextMenuOpenChange, hoverHandlers }) => {
const isDesktop = !isTouchPlatform;
const serviceSummary = isDesktop ? selectWorkspaceServiceSummary(workspace.scripts) : null;
@@ -1141,7 +1132,7 @@ function WorkspaceRowInner({
return (
<View
{...dragAttributes}
{...(isEditingTitle ? null : dragHandleProps?.listeners)}
{...dragHandleProps?.listeners}
ref={dragHandleProps?.setActivatorNodeRef as unknown as Ref<View>}
style={styles.workspaceRowContainer}
{...hoverHandlers}
@@ -1167,16 +1158,15 @@ function WorkspaceRowInner({
onTogglePin={onTogglePin}
openInFileManagerPath={workspace.workspaceDirectory}
disabled={isArchiving}
enabled={!isEditingTitle}
aria-selected={selected}
accessibilityRole="button"
accessibilityState={accessibilityState}
style={workspaceRowStyle}
highlightStyle={styles.workspaceRowPressed}
onPressIn={isEditingTitle ? undefined : handleWorkspacePressIn}
onTouchMove={isEditingTitle ? undefined : interaction.handleTouchMove}
onPressOut={isEditingTitle ? undefined : handleWorkspacePressOut}
onPress={isEditingTitle ? undefined : handlePress}
onPressIn={handleWorkspacePressIn}
onTouchMove={interaction.handleTouchMove}
onPressOut={handleWorkspacePressOut}
onPress={handlePress}
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
>
<SidebarWorkspaceRowContent
@@ -1192,7 +1182,6 @@ function WorkspaceRowInner({
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace}
titleEditor={titleEditor}
>
<WorkspaceRowRightGroup
workspace={workspace}
@@ -1262,7 +1251,7 @@ function WorkspaceRowWithMenu({
const { t } = useTranslation();
const toast = useToast();
const [isHidingWorkspace, setIsHidingWorkspace] = useState(false);
const rename = useSidebarWorkspaceRename(workspace);
const [isRenameOpen, setIsRenameOpen] = useState(false);
const isArchiving = workspace.archivingAt !== null || isHidingWorkspace;
const redirectAfterArchive = useCallback(() => {
redirectIfArchivingActiveWorkspace({
@@ -1298,6 +1287,14 @@ function WorkspaceRowWithMenu({
clipboard.copyBranchName(workspace);
}, [clipboard, workspace]);
const handleOpenRename = useCallback(() => {
setIsRenameOpen(true);
}, []);
const handleCloseRename = useCallback(() => {
setIsRenameOpen(false);
}, []);
const isPinned = workspace.pinnedAt != null;
const handleTogglePin = useCallback(() => {
onToggleWorkspacePin(workspace);
@@ -1355,8 +1352,7 @@ function WorkspaceRowWithMenu({
onArchive={handleArchive}
onCopyBranchName={canCopyBranchName ? handleCopyBranchName : undefined}
onCopyPath={handleCopyPath}
onRename={rename.begin}
titleEditor={rename.titleEditor}
onRename={handleOpenRename}
onMarkAsRead={hasClearableAttention ? handleMarkAsRead : undefined}
onMarkAsUnread={canMarkUnread ? handleMarkAsUnread : undefined}
archiveShortcutKeys={selected ? archiveShortcutKeys : null}
@@ -1364,7 +1360,12 @@ function WorkspaceRowWithMenu({
onTogglePin={onTogglePin}
reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace}
/>
{rename.modal}
<WorkspaceRenameModal
visible={isRenameOpen}
workspace={workspace}
onClose={handleCloseRename}
testID={`sidebar-workspace-rename-modal-${workspace.workspaceKey}`}
/>
</>
);
}
@@ -40,7 +40,7 @@ import {
CircleX,
} from "lucide-react-native";
import { useToast } from "@/contexts/toast-context";
import { useSidebarWorkspaceRename } from "@/workspace-rename";
import { WorkspaceRenameModal } from "@/components/workspace-rename-modal";
import { useWorkspaceClipboardActions } from "@/hooks/use-workspace-clipboard-actions";
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
@@ -637,7 +637,7 @@ function StatusWorkspaceRowWithMenu({
const { t } = useTranslation();
const toast = useToast();
const [isHidingWorkspace, setIsHidingWorkspace] = useState(false);
const rename = useSidebarWorkspaceRename(workspace);
const [isRenameOpen, setIsRenameOpen] = useState(false);
const isArchiving = workspace.archivingAt !== null || isHidingWorkspace;
const redirectAfterArchive = useCallback(() => {
@@ -674,6 +674,8 @@ function StatusWorkspaceRowWithMenu({
clipboard.copyBranchName(workspace);
}, [clipboard, workspace]);
const handleOpenRename = useCallback(() => setIsRenameOpen(true), []);
const handleCloseRename = useCallback(() => setIsRenameOpen(false), []);
const isPinned = workspace.pinnedAt != null;
const handleTogglePin = useCallback(() => {
onToggleWorkspacePin(workspace);
@@ -726,8 +728,7 @@ function StatusWorkspaceRowWithMenu({
onArchive={handleArchive}
onCopyBranchName={workspace.projectKind === "git" ? handleCopyBranchName : undefined}
onCopyPath={handleCopyPath}
onRename={rename.begin}
titleEditor={rename.titleEditor}
onRename={handleOpenRename}
onMarkAsRead={hasClearableAttention ? handleMarkAsRead : undefined}
onMarkAsUnread={canMarkUnread ? handleMarkAsUnread : undefined}
archiveShortcutKeys={selected ? archiveShortcutKeys : null}
@@ -739,7 +740,12 @@ function StatusWorkspaceRowWithMenu({
isDragging={isDragging}
dragHandleProps={dragHandleProps}
/>
{rename.modal}
<WorkspaceRenameModal
visible={isRenameOpen}
workspace={workspace}
onClose={handleCloseRename}
testID={`sidebar-workspace-rename-modal-${workspace.workspaceKey}`}
/>
</>
);
}
@@ -761,8 +767,6 @@ interface StatusWorkspaceRowInnerProps {
onCopyBranchName?: () => void;
onCopyPath?: () => void;
onRename?: () => void;
/** Present while the row is renamed in place: the row stops pressing, dragging and hovering. */
titleEditor?: ReactNode;
onMarkAsRead?: () => void;
onMarkAsUnread?: () => void;
archiveShortcutKeys?: ShortcutKey[][] | null;
@@ -810,7 +814,6 @@ function StatusWorkspaceRowInnerContent({
onCopyBranchName,
onCopyPath,
onRename,
titleEditor = null,
onMarkAsRead,
onMarkAsUnread,
archiveShortcutKeys,
@@ -839,7 +842,6 @@ function StatusWorkspaceRowInnerContent({
const serviceSummary = isDesktop ? selectWorkspaceServiceSummary(workspace.scripts) : null;
const accessibilityState = useMemo(() => ({ selected }), [selected]);
const isEditingTitle = titleEditor !== null;
const didLongPressRef = dragInteraction?.didLongPressRef;
const startDragPress = dragInteraction?.handlePressIn;
const moveDragPress = dragInteraction?.handleTouchMove;
@@ -864,11 +866,7 @@ function StatusWorkspaceRowInnerContent({
}, [endDragPress]);
return (
<SidebarWorkspaceRowFrame
workspace={workspace}
isDragging={isDragging}
isEditingTitle={isEditingTitle}
>
<SidebarWorkspaceRowFrame workspace={workspace} isDragging={isDragging}>
{({ isHovered, contextMenuOpen, onContextMenuOpenChange, hoverHandlers }) => {
const showShortcut = showShortcutBadge && shortcutNumber !== null;
const {
@@ -897,7 +895,7 @@ function StatusWorkspaceRowInnerContent({
return (
<View
{...dragAttributes}
{...(isEditingTitle ? null : dragHandleProps?.listeners)}
{...dragHandleProps?.listeners}
ref={dragHandleProps?.setActivatorNodeRef as unknown as Ref<View>}
style={styles.workspaceRowContainer}
{...hoverHandlers}
@@ -924,15 +922,14 @@ function StatusWorkspaceRowInnerContent({
onTogglePin={onTogglePin}
openInFileManagerPath={workspace.workspaceDirectory}
disabled={isArchiving}
enabled={!isEditingTitle}
accessibilityRole="button"
accessibilityState={accessibilityState}
style={workspaceRowStyle}
highlightStyle={styles.workspaceRowPressed}
onPressIn={isEditingTitle ? undefined : handlePressIn}
onTouchMove={isEditingTitle ? undefined : moveDragPress}
onPressOut={isEditingTitle ? undefined : handlePressOut}
onPress={isEditingTitle ? undefined : handlePress}
onPressIn={handlePressIn}
onTouchMove={moveDragPress}
onPressOut={handlePressOut}
onPress={handlePress}
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
>
<SidebarWorkspaceRowContent
@@ -947,7 +944,6 @@ function StatusWorkspaceRowInnerContent({
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace}
titleEditor={titleEditor}
>
{renderSlot ? (
<StatusWorkspaceActionSlot
@@ -46,13 +46,10 @@ const ThemedCircleAlert = withUnistyles(CircleAlert);
export function SidebarWorkspaceRowFrame({
workspace,
isDragging = false,
isEditingTitle = false,
children,
}: {
workspace: SidebarWorkspaceEntry;
isDragging?: boolean;
/** Hover would fade the kebab in over the title being edited and open the hover card. */
isEditingTitle?: boolean;
children: (input: {
isHovered: boolean;
contextMenuOpen: boolean;
@@ -80,10 +77,10 @@ export function SidebarWorkspaceRowFrame({
workspace={workspace}
prHint={workspace.prHint}
isDragging={isDragging}
disabled={contextMenuOpen || isEditingTitle}
disabled={contextMenuOpen}
>
{children({
isHovered: isHovered && !contextMenuOpen && !isDragging && !isEditingTitle,
isHovered: isHovered && !contextMenuOpen && !isDragging,
contextMenuOpen,
onContextMenuOpenChange: handleContextMenuOpenChange,
hoverHandlers,
@@ -105,7 +102,6 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
shortcutNumber = null,
showShortcutBadge = false,
reserveIdleStatusIndicatorSpace = true,
titleEditor = null,
children,
}: {
workspace: SidebarWorkspaceEntry;
@@ -123,8 +119,6 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
showShortcutBadge?: boolean;
/** Keep the empty leading slot when the workspace has no active status. */
reserveIdleStatusIndicatorSpace?: boolean;
/** Takes the title's place, in a box exactly one title line tall, while renaming in place. */
titleEditor?: ReactNode;
children?: ReactNode;
}) {
const {
@@ -166,17 +160,13 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
)}
<View style={styles.workspaceContentColumn}>
<View style={styles.workspaceTitleRow}>
{titleEditor ? (
<View style={styles.workspaceTitleEditorSlot}>{titleEditor}</View>
) : (
<Text
style={workspaceBranchTextStyle}
numberOfLines={1}
testID={`sidebar-workspace-title-${workspace.workspaceKey}`}
>
{workspaceLabel}
</Text>
)}
<Text
style={workspaceBranchTextStyle}
numberOfLines={1}
testID={`sidebar-workspace-title-${workspace.workspaceKey}`}
>
{workspaceLabel}
</Text>
<View style={sidebarWorkspaceRowStyles.rowRight}>{children}</View>
</View>
<WorkspaceMetaRow
@@ -567,11 +557,6 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
minWidth: 0,
},
workspaceTitleEditorSlot: {
height: TITLE_LINE_HEIGHT,
flex: 1,
minWidth: 0,
},
workspaceBranchTextCreating: {
opacity: 0.92,
},
@@ -0,0 +1,524 @@
import { memo, useCallback, useMemo, useState, type Ref } from "react";
import { useTranslation } from "react-i18next";
import { View, Text, type GestureResponderEvent } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import type { HostBadgeModel } from "@/hosts/appearance";
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
import { density } from "@/styles/density";
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
import type { DraggableListDragHandleProps } from "@/components/draggable-list.types";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { WorkspaceRenameModal } from "@/components/workspace-rename-modal";
import { useWorkspaceClipboardActions } from "@/hooks/use-workspace-clipboard-actions";
import { useToast } from "@/contexts/toast-context";
import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning";
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import { useWorkspaceReadState } from "@/hooks/use-workspace-read-state";
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
import { isNative as platformIsNative } from "@/constants/platform";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction";
import {
SidebarWorkspaceContextMenu,
SidebarWorkspaceMenu,
} from "@/components/sidebar/sidebar-workspace-menu";
import {
SidebarWorkspaceRowFrame,
SidebarWorkspaceRowContent,
resolveTrailingActionVisibility,
SidebarWorkspaceTrailingActionBase,
SidebarWorkspaceTrailingActionOverlay,
SidebarWorkspaceTrailingActionSlot,
} from "@/components/sidebar/sidebar-workspace-row-content";
import { useOpenKebabMenuVisibility } from "@/components/sidebar/use-open-kebab-menu-visibility";
import { getSidebarRowBackdrop } from "@/components/sidebar/sidebar-row-backdrop";
import { selectWorkspaceServiceSummary } from "@/components/sidebar/workspace-meta-row";
import {
SidebarWorkspaceTrailingContent,
useSidebarWorkspaceTrailing,
type SidebarWorkspaceTrailing,
} from "@/components/sidebar/workspace-trailing";
interface SidebarWorkspaceRowProps {
workspace: SidebarWorkspaceEntry;
selected: boolean;
shortcutNumber: number | null;
showShortcutBadge: boolean;
canCopyBranchName: boolean;
onPress: () => void;
/** The host pill after the title. Absent → the sidebar spans one host, or this one is hidden. */
hostBadge?: HostBadgeModel | null;
/** Project grouping only: shows a transient "creating" affordance. */
isCreating?: boolean;
/** Project grouping only: drag-to-reorder wiring. Absent → not draggable. */
drag?: () => void;
isDragging?: boolean;
dragHandleProps?: DraggableListDragHandleProps;
}
export function SidebarWorkspaceRow({
workspace,
selected,
shortcutNumber,
showShortcutBadge,
canCopyBranchName,
onPress,
hostBadge,
isCreating = false,
drag,
isDragging = false,
dragHandleProps,
}: SidebarWorkspaceRowProps) {
const { t } = useTranslation();
const toast = useToast();
const [isHidingWorkspace, setIsHidingWorkspace] = useState(false);
const [isRenameOpen, setIsRenameOpen] = useState(false);
const isArchiving = workspace.archivingAt !== null || isHidingWorkspace;
const redirectAfterArchive = useCallback(() => {
redirectIfArchivingActiveWorkspace({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
activeWorkspaceSelection: selected
? { serverId: workspace.serverId, workspaceId: workspace.workspaceId }
: null,
});
}, [selected, workspace]);
const archiveController = useWorkspaceArchive({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
workspaceKind: workspace.workspaceKind,
name: workspace.name,
...toWorktreeArchiveRisk(workspace),
onArchiveStarted: redirectAfterArchive,
onSetHiding: setIsHidingWorkspace,
});
const handleArchive = useCallback(() => {
if (isArchiving) {
return;
}
archiveController.archive();
}, [archiveController, isArchiving]);
const clipboard = useWorkspaceClipboardActions();
const handleCopyPath = useCallback(() => {
clipboard.copyPath(workspace);
}, [clipboard, workspace]);
const handleCopyBranchName = useCallback(() => {
clipboard.copyBranchName(workspace);
}, [clipboard, workspace]);
const handleOpenRename = useCallback(() => {
setIsRenameOpen(true);
}, []);
const handleCloseRename = useCallback(() => {
setIsRenameOpen(false);
}, []);
const archiveShortcutKeys = useShortcutKeys("archive-workspace");
const { hasClearableAttention, canMarkUnread, clearAttention, markUnread } =
useWorkspaceReadState({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
});
const handleMarkAsRead = useCallback(() => {
void clearAttention().catch((error) => {
toast.error(error instanceof Error ? error.message : "Failed to mark workspace as read");
});
}, [clearAttention, toast]);
const handleMarkAsUnread = useCallback(() => {
void markUnread().catch((error) => {
toast.error(error instanceof Error ? error.message : "Failed to mark workspace as unread");
});
}, [markUnread, toast]);
useKeyboardActionHandler({
handlerId: `workspace-archive-${workspace.workspaceKey}`,
actions: ["workspace.archive"],
enabled: selected && !isArchiving,
priority: 0,
handle: () => {
handleArchive();
return true;
},
});
return (
<>
<WorkspaceRowBody
workspace={workspace}
selected={selected}
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
hostBadge={hostBadge}
isCreating={isCreating}
isArchiving={isArchiving}
onPress={onPress}
drag={drag}
isDragging={isDragging}
dragHandleProps={dragHandleProps}
archiveLabel={t("sidebar.workspace.actions.archive")}
archiveStatus={isArchiving ? "pending" : "idle"}
archivePendingLabel={t("sidebar.workspace.actions.archiving")}
onArchive={handleArchive}
onCopyBranchName={canCopyBranchName ? handleCopyBranchName : undefined}
onCopyPath={handleCopyPath}
onRename={handleOpenRename}
onMarkAsRead={hasClearableAttention ? handleMarkAsRead : undefined}
onMarkAsUnread={canMarkUnread ? handleMarkAsUnread : undefined}
archiveShortcutKeys={selected ? archiveShortcutKeys : null}
/>
<WorkspaceRenameModal
visible={isRenameOpen}
workspace={workspace}
onClose={handleCloseRename}
testID={`sidebar-workspace-rename-modal-${workspace.workspaceKey}`}
/>
</>
);
}
interface WorkspaceRowBodyProps {
workspace: SidebarWorkspaceEntry;
selected: boolean;
shortcutNumber: number | null;
showShortcutBadge: boolean;
hostBadge?: HostBadgeModel | null;
isCreating: boolean;
isArchiving: boolean;
onPress: () => void;
drag?: () => void;
isDragging: boolean;
dragHandleProps?: DraggableListDragHandleProps;
archiveLabel?: string;
archiveStatus?: "idle" | "pending" | "success";
archivePendingLabel?: string;
onArchive?: () => void;
onCopyBranchName?: () => void;
onCopyPath?: () => void;
onRename?: () => void;
onMarkAsRead?: () => void;
onMarkAsUnread?: () => void;
archiveShortcutKeys?: ShortcutKey[][] | null;
}
function WorkspaceRowBody({
workspace,
selected,
shortcutNumber,
showShortcutBadge,
hostBadge,
isCreating,
isArchiving,
onPress,
drag,
isDragging,
dragHandleProps,
archiveLabel,
archiveStatus = "idle",
archivePendingLabel,
onArchive,
onCopyBranchName,
onCopyPath,
onRename,
onMarkAsRead,
onMarkAsUnread,
archiveShortcutKeys,
}: WorkspaceRowBodyProps) {
const isCompact = useIsCompactFormFactor();
const isTouchPlatform = platformIsNative || isCompact;
const [isPressed, setIsPressed] = useState(false);
const trailing = useSidebarWorkspaceTrailing();
const draggable = Boolean(drag);
const interaction = useLongPressDragInteraction({
drag,
menuController: null,
});
const {
role: _dragRole,
tabIndex: _dragTabIndex,
"aria-roledescription": _dragRoleDescription,
...dragAttributes
} = dragHandleProps?.attributes ?? {};
const handlePress = useCallback(() => {
if (interaction.didLongPressRef.current) {
interaction.didLongPressRef.current = false;
return;
}
onPress();
}, [interaction.didLongPressRef, onPress]);
const handleWorkspacePressIn = useCallback(
(event: GestureResponderEvent) => {
setIsPressed(true);
if (draggable) interaction.handlePressIn(event);
},
[draggable, interaction],
);
const handleWorkspacePressOut = useCallback(() => {
setIsPressed(false);
if (draggable) interaction.handlePressOut();
}, [draggable, interaction]);
const accessibilityState = useMemo(() => ({ selected }), [selected]);
return (
<SidebarWorkspaceRowFrame workspace={workspace} isDragging={isDragging}>
{({ isHovered, contextMenuOpen, onContextMenuOpenChange, hoverHandlers }) => {
const isDesktop = !isTouchPlatform;
const serviceSummary = isDesktop ? selectWorkspaceServiceSummary(workspace.scripts) : null;
const workspaceRowStyle = getWorkspaceRowStyle({
isDragging,
isPressed,
selected,
isHovered,
});
const backdrop = getSidebarRowBackdrop({ isDragging, isPressed, selected, isHovered });
return (
<View
{...(draggable ? dragAttributes : {})}
{...(draggable ? dragHandleProps?.listeners : {})}
ref={
draggable ? (dragHandleProps?.setActivatorNodeRef as unknown as Ref<View>) : undefined
}
style={styles.workspaceRowContainer}
{...hoverHandlers}
>
<SidebarWorkspaceContextMenu
contextMenuOpen={contextMenuOpen}
onContextMenuOpenChange={onContextMenuOpenChange}
workspace={workspace}
hostBadgeLabel={hostBadge?.label}
serviceSummary={serviceSummary}
workspaceKey={workspace.workspaceKey}
onCopyPath={onCopyPath}
onCopyBranchName={onCopyBranchName}
onRename={onRename}
onMarkAsRead={onMarkAsRead}
onMarkAsUnread={onMarkAsUnread}
onArchive={onArchive}
archiveLabel={archiveLabel}
archiveStatus={archiveStatus}
archivePendingLabel={archivePendingLabel}
archiveShortcutKeys={archiveShortcutKeys}
openInFileManagerPath={workspace.workspaceDirectory}
disabled={isArchiving}
aria-selected={selected}
accessibilityRole="button"
accessibilityState={accessibilityState}
style={workspaceRowStyle}
highlightStyle={styles.workspaceRowPressed}
onPressIn={handleWorkspacePressIn}
onTouchMove={draggable ? interaction.handleTouchMove : undefined}
onPressOut={handleWorkspacePressOut}
onPress={handlePress}
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
>
<SidebarWorkspaceRowContent
workspace={workspace}
hostBadge={hostBadge}
serviceSummary={serviceSummary}
backdrop={backdrop}
isHovered={isHovered}
isLoading={isArchiving || isCreating}
isCreating={isCreating}
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
>
<WorkspaceRowTrailingActions
workspace={workspace}
backdrop={backdrop}
trailing={trailing}
isHovered={isHovered}
isTouchPlatform={isTouchPlatform}
isCreating={isCreating}
showShortcutBadge={showShortcutBadge}
shortcutNumber={shortcutNumber}
archiveLabel={archiveLabel}
archiveStatus={archiveStatus}
archivePendingLabel={archivePendingLabel}
archiveShortcutKeys={archiveShortcutKeys}
onArchive={onArchive}
onCopyBranchName={onCopyBranchName}
onCopyPath={onCopyPath}
onRename={onRename}
onMarkAsRead={onMarkAsRead}
onMarkAsUnread={onMarkAsUnread}
/>
</SidebarWorkspaceRowContent>
</SidebarWorkspaceContextMenu>
</View>
);
}}
</SidebarWorkspaceRowFrame>
);
}
function WorkspaceRowTrailingActions({
workspace,
backdrop,
trailing,
isHovered,
isTouchPlatform,
isCreating,
showShortcutBadge,
shortcutNumber,
archiveLabel,
archiveStatus,
archivePendingLabel,
archiveShortcutKeys,
onArchive,
onMarkAsRead,
onMarkAsUnread,
onCopyBranchName,
onCopyPath,
onRename,
}: {
workspace: SidebarWorkspaceEntry;
backdrop: SidebarSurfaceBackdrop;
trailing: SidebarWorkspaceTrailing;
isHovered: boolean;
isTouchPlatform: boolean;
isCreating: boolean;
showShortcutBadge: boolean;
shortcutNumber: number | null;
archiveLabel?: string;
archiveStatus?: "idle" | "pending" | "success";
archivePendingLabel?: string;
archiveShortcutKeys?: ShortcutKey[][] | null;
onArchive?: () => void;
onMarkAsRead?: () => void;
onMarkAsUnread?: () => void;
onCopyBranchName?: () => void;
onCopyPath?: () => void;
onRename?: () => void;
}) {
const { t } = useTranslation();
const showShortcut = showShortcutBadge && shortcutNumber !== null;
const {
trailingPresentation,
showKebab: showKebabInSlot,
showArchiveButton,
showScrim,
renderSlot,
reserveSlotWidth,
} = resolveTrailingActionVisibility({
workspace,
trailing,
hasArchiveAction: Boolean(onArchive),
isHovered,
isTouchPlatform,
showShortcut,
});
const kebab = useOpenKebabMenuVisibility(showKebabInSlot);
return (
<>
{isCreating ? (
<Text style={styles.workspaceCreatingText}>{t("sidebar.workspace.status.creating")}</Text>
) : null}
{renderSlot ? (
<SidebarWorkspaceTrailingActionSlot reserveWidth={reserveSlotWidth}>
<SidebarWorkspaceTrailingActionBase presentation={trailingPresentation}>
<SidebarWorkspaceTrailingContent workspace={workspace} trailing={trailing} />
</SidebarWorkspaceTrailingActionBase>
<SidebarWorkspaceTrailingActionOverlay
visible={kebab.showKebab}
scrimBackdrop={showScrim ? backdrop : undefined}
workspaceKey={workspace.workspaceKey}
onArchiveButtonPress={showArchiveButton ? onArchive : undefined}
>
{onArchive ? (
<SidebarWorkspaceMenu
{...kebab.menuProps}
workspaceKey={workspace.workspaceKey}
serverId={workspace.serverId}
workspaceId={workspace.workspaceId}
workspaceLabels={workspace.labels}
onCopyPath={onCopyPath}
onCopyBranchName={onCopyBranchName}
onRename={onRename}
onMarkAsRead={onMarkAsRead}
onMarkAsUnread={onMarkAsUnread}
onArchive={onArchive}
archiveLabel={archiveLabel}
archiveStatus={archiveStatus}
archivePendingLabel={archivePendingLabel}
archiveShortcutKeys={archiveShortcutKeys}
/>
) : null}
</SidebarWorkspaceTrailingActionOverlay>
</SidebarWorkspaceTrailingActionSlot>
) : null}
</>
);
}
function getWorkspaceRowStyle({
isDragging,
isPressed,
selected,
isHovered,
}: {
isDragging: boolean;
isPressed: boolean;
selected: boolean;
isHovered: boolean;
}) {
return [
styles.workspaceRow,
isHovered && styles.workspaceRowHovered,
selected && styles.sidebarRowSelected,
isDragging && styles.workspaceRowDragging,
isPressed && styles.workspaceRowPressed,
];
}
export const MemoSidebarWorkspaceRow = memo(SidebarWorkspaceRow);
const styles = StyleSheet.create((theme) => ({
workspaceRowContainer: {
position: "relative",
},
workspaceRow: {
minHeight: density.rowHeight,
marginBottom: theme.spacing[1],
paddingVertical: theme.spacing[2],
paddingLeft: theme.spacing[2],
paddingRight: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
flexDirection: "column",
alignItems: "stretch",
justifyContent: "center",
gap: theme.spacing[1],
userSelect: "none",
},
workspaceRowHovered: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
workspaceRowPressed: {
backgroundColor: theme.colors.surface2,
},
workspaceRowDragging: {
backgroundColor: theme.colors.surface2,
borderWidth: 1,
borderColor: theme.colors.border,
transform: [{ scale: 1.02 }],
zIndex: 3,
...theme.shadow.md,
},
sidebarRowSelected: {
backgroundColor: theme.colors.surfaceSidebarSelected,
},
workspaceCreatingText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
flexShrink: 0,
},
}));
@@ -51,7 +51,10 @@ import {
TERMINAL_SELECTION_GRIP_SIZE,
} from "../terminal/native-renderer/terminal-selection-controller";
import { resolveNativeTerminalMountAction } from "../terminal/native-renderer/terminal-mount-policy";
import { type NativeTerminalKeyEvent } from "../terminal/native-renderer/terminal-key-events";
import {
forwardNativeTerminalKey,
type NativeTerminalKey,
} from "../terminal/native-renderer/terminal-key-events";
import {
TerminalInput,
type TerminalInputHandle,
@@ -508,9 +511,9 @@ function NativeTerminalEmulator({
);
const handleNativeTerminalKey = useCallback(
(event: NativeTerminalKeyEvent) => {
(key: NativeTerminalKey) => {
claimActiveTerminalSizeForAction("key");
callbacksRef.current.onTerminalKey?.(event);
forwardNativeTerminalKey({ key, onTerminalKey: callbacksRef.current.onTerminalKey });
},
[claimActiveTerminalSizeForAction],
);
@@ -165,9 +165,7 @@ export function ContextMenuTrigger({
const handleContextMenu = useCallback(
(event: unknown) => {
// A trigger with no menu of its own leaves the browser's menu alone, e.g. copy and paste
// in a field rendered inside it.
if (isNative || !shouldEnableOnThisPlatform) return;
if (isNative) return;
if (typeof event === "object" && event !== null) {
const preventDefault = Reflect.get(event, "preventDefault");
const stopPropagation = Reflect.get(event, "stopPropagation");
@@ -177,7 +175,7 @@ export function ContextMenuTrigger({
onContextMenu?.(event);
openAtEvent(event);
},
[onContextMenu, openAtEvent, shouldEnableOnThisPlatform],
[onContextMenu, openAtEvent],
);
const resolveDynamicStyle = useCallback(
+8 -55
View File
@@ -12,14 +12,7 @@ import {
type ReactNode,
type RefObject,
} from "react";
import {
Dimensions,
View,
type PointerEvent as ViewPointerEvent,
type Role,
type StyleProp,
type ViewStyle,
} from "react-native";
import { Dimensions, View, type Role, type StyleProp, type ViewStyle } from "react-native";
import { FadeIn, FadeOut } from "react-native-reanimated";
import { StyleSheet } from "react-native-unistyles";
import { Portal } from "@gorhom/portal";
@@ -42,7 +35,7 @@ interface HoverCardContextValue {
setTriggerRef: (node: View | null) => void;
triggerRef: RefObject<View | null>;
contentRef: RefObject<View | null>;
pointerEntered: (event: ViewPointerEvent) => void;
openNow: () => void;
scheduleClose: () => void;
}
@@ -62,34 +55,21 @@ const HoverCardContext = createContext<HoverCardContextValue | null>(null);
*/
export function HoverCard({
disabled = false,
ignoreTouch = false,
children,
}: {
/** Closes the card and keeps it closed, e.g. while its trigger is dragged. */
disabled?: boolean;
/**
* The trigger handles a tap itself, so a touch does not open the card: neither the touch's
* pointerenter nor the focus the tap leaves on the trigger. Opening during a tap changes the
* page under the finger, and iPad Safari then drops the tap.
*/
ignoreTouch?: boolean;
children: ReactNode;
}): ReactNode {
if (!isWeb) return children;
return (
<WebHoverCard disabled={disabled} ignoreTouch={ignoreTouch}>
{children}
</WebHoverCard>
);
return <WebHoverCard disabled={disabled}>{children}</WebHoverCard>;
}
function WebHoverCard({
disabled,
ignoreTouch,
children,
}: {
disabled: boolean;
ignoreTouch: boolean;
children: ReactNode;
}): ReactElement {
const layer = useOverlayLayer("floating");
@@ -99,8 +79,6 @@ function WebHoverCard({
const graceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Read when focus arrives: by focusout the element losing focus no longer matches :focus-visible.
const keyboardFocusedRef = useRef(false);
// Whether the latest press was a touch, so with ignoreTouch the focus that tap leaves is ignored.
const touchPressedRef = useRef(false);
const clearGraceTimer = useCallback(() => {
if (graceTimerRef.current) {
@@ -147,13 +125,6 @@ function WebHoverCard({
setOpen(true);
}, [clearGraceTimer, closeNow, disabled, layer]);
const pointerEntered = useCallback(
(event: ViewPointerEvent) => {
if (!(ignoreTouch && event.nativeEvent.pointerType === "touch")) openNow();
},
[ignoreTouch, openNow],
);
useEffect(() => {
if (!open) return;
return () => {
@@ -220,7 +191,7 @@ function WebHoverCard({
const target = event.target as Element;
keyboardFocusedRef.current = target.matches(":focus-visible");
if (trigger.contains(target)) {
if (!(ignoreTouch && touchPressedRef.current)) openNow();
openNow();
} else if ((contentRef.current as unknown as HTMLElement | null)?.contains(target)) {
clearGraceTimer();
}
@@ -237,12 +208,6 @@ function WebHoverCard({
scheduleClose();
}
};
const pointerPressed = (event: PointerEvent) => {
touchPressedRef.current = event.pointerType === "touch";
};
const keyPressedAnywhere = () => {
touchPressedRef.current = false;
};
const activateClosedTrigger = (event: KeyboardEvent) => {
if (
!open &&
@@ -256,23 +221,19 @@ function WebHoverCard({
const scrolled = (event: Event) => {
if ((event.target as Node).contains(trigger)) close();
};
document.addEventListener("pointerdown", pointerPressed, true);
document.addEventListener("keydown", keyPressedAnywhere, true);
document.addEventListener("focusin", focusEntered);
document.addEventListener("focusout", focusLeft);
trigger.addEventListener("keydown", activateClosedTrigger);
document.addEventListener("scroll", scrolled, true);
window.addEventListener("resize", close);
return () => {
document.removeEventListener("pointerdown", pointerPressed, true);
document.removeEventListener("keydown", keyPressedAnywhere, true);
document.removeEventListener("focusin", focusEntered);
document.removeEventListener("focusout", focusLeft);
trigger.removeEventListener("keydown", activateClosedTrigger);
document.removeEventListener("scroll", scrolled, true);
window.removeEventListener("resize", close);
};
}, [clearGraceTimer, ignoreTouch, keyPressed, open, openNow, scheduleClose]);
}, [clearGraceTimer, keyPressed, open, openNow, scheduleClose]);
// While open, the safe zone covers trigger + content + the bridge between
// them. Close only fires when the pointer leaves the safe zone; re-entering
@@ -295,16 +256,8 @@ function WebHoverCard({
useEffect(() => clearGraceTimer, [clearGraceTimer]);
const value = useMemo<HoverCardContextValue>(
() => ({
open,
layer,
setTriggerRef,
triggerRef,
contentRef,
pointerEntered,
scheduleClose,
}),
[open, layer, setTriggerRef, pointerEntered, scheduleClose],
() => ({ open, layer, setTriggerRef, triggerRef, contentRef, openNow, scheduleClose }),
[open, layer, setTriggerRef, openNow, scheduleClose],
);
return (
@@ -332,7 +285,7 @@ export function HoverCardTrigger({
accessibilityRole={focusable ? "button" : undefined}
accessibilityLabel={accessibilityLabel}
collapsable={false}
onPointerEnter={ctx.pointerEntered}
onPointerEnter={ctx.openNow}
onPointerLeave={ctx.scheduleClose}
>
{children}
@@ -1,30 +0,0 @@
import { createContext, useCallback, useContext, useState, useSyncExternalStore } from "react";
import { createUserMessageExpansion, type UserMessageExpansion } from "./expansion";
export const UserMessageExpansionContext = createContext<UserMessageExpansion | null>(null);
/** One store per viewed agent; switching agents starts with every message collapsed. */
export function useAgentUserMessageExpansion(agentId: string): UserMessageExpansion {
const [owner, setOwner] = useState(() => ({
agentId,
expansion: createUserMessageExpansion(),
}));
if (owner.agentId === agentId) return owner.expansion;
const next = { agentId, expansion: createUserMessageExpansion() };
setOwner(next);
return next.expansion;
}
/** Subscribes one message to its own expanded state. */
export function useUserMessageExpanded(itemId: string) {
const expansion = useContext(UserMessageExpansionContext);
if (!expansion) throw new Error("MessagePreview requires a UserMessageExpansionContext");
const subscribe = useCallback(
(listener: () => void) => expansion.subscribe(itemId, listener),
[expansion, itemId],
);
const getSnapshot = useCallback(() => expansion.isExpanded(itemId), [expansion, itemId]);
const expanded = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
const toggle = useCallback(() => expansion.toggle(itemId), [expansion, itemId]);
return { expanded, toggle };
}
@@ -1,31 +0,0 @@
import { describe, expect, it } from "vitest";
import { createUserMessageExpansion } from "./expansion";
describe("user message expansion", () => {
it("toggles one message and notifies only that message", () => {
const expansion = createUserMessageExpansion();
const notified: string[] = [];
expansion.subscribe("first", () => notified.push("first"));
expansion.subscribe("second", () => notified.push("second"));
expansion.toggle("first");
expect(expansion.isExpanded("first")).toBe(true);
expect(expansion.isExpanded("second")).toBe(false);
expansion.toggle("first");
expect(expansion.isExpanded("first")).toBe(false);
expect(notified).toEqual(["first", "first"]);
});
it("keeps a message's choice after its subscribers leave, as a remounting row does", () => {
const expansion = createUserMessageExpansion();
const unsubscribe = expansion.subscribe("first", () => undefined);
expansion.toggle("first");
unsubscribe();
const notified: string[] = [];
expansion.subscribe("first", () => notified.push("first"));
expect(expansion.isExpanded("first")).toBe(true);
expect(notified).toEqual([]);
});
});
@@ -1,34 +0,0 @@
type Listener = () => void;
/**
* Which of one agent's long user messages the user expanded past the preview. It lives with the
* timeline, not the row, so a row that remounts (a virtualized row coming back into view, a row
* reassigned to another list container) paints the choice the user made. A toggle notifies only
* that message's subscribers.
*/
export function createUserMessageExpansion() {
const expanded = new Set<string>();
const listeners = new Map<string, Set<Listener>>();
return {
isExpanded: (itemId: string) => expanded.has(itemId),
subscribe(itemId: string, listener: Listener) {
let itemListeners = listeners.get(itemId);
if (!itemListeners) {
itemListeners = new Set();
listeners.set(itemId, itemListeners);
}
itemListeners.add(listener);
return () => {
itemListeners.delete(listener);
if (itemListeners.size === 0) listeners.delete(itemId);
};
},
toggle(itemId: string) {
if (expanded.has(itemId)) expanded.delete(itemId);
else expanded.add(itemId);
for (const listener of listeners.get(itemId) ?? []) listener();
},
};
}
export type UserMessageExpansion = ReturnType<typeof createUserMessageExpansion>;
@@ -1,81 +0,0 @@
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { within } from "@testing-library/dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { i18n as testI18n } from "@/i18n/i18next";
import { MessagePreview } from "./index";
import { createUserMessageExpansion, type UserMessageExpansion } from "./expansion";
import { UserMessageExpansionContext } from "./expansion-context";
// Load translations so the toggle exposes its real accessible name.
void testI18n;
// App sources compile against the classic JSX runtime, which expects React on the global.
beforeEach(() => vi.stubGlobal("React", React));
let root: Root;
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
container.style.width = "400px";
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
const longText = Array.from({ length: 60 }, (_, line) => `Line ${line + 1}`).join("\n");
const preLine = { whiteSpace: "pre-line" } as const;
const view = () => within(container);
function renderMessage(expansion: UserMessageExpansion) {
act(() =>
root.render(
<UserMessageExpansionContext.Provider value={expansion}>
<MessagePreview itemId="message-1">
<span style={preLine}>{longText}</span>
</MessagePreview>
</UserMessageExpansionContext.Provider>,
),
);
}
async function waitForLayout() {
await act(async () => {
for (let frame = 0; frame < 2; frame += 1) {
await new Promise((resolve) => requestAnimationFrame(resolve));
}
});
}
describe("long user message preview", () => {
it("commits the control with the mount, so the first frame is the settled one", async () => {
renderMessage(createUserMessageExpansion());
// Nothing has painted yet: whatever is in the DOM now is the first frame.
expect(view().getByRole("button", { name: "Show more" })).toBeTruthy();
const firstFrameHeight = container.getBoundingClientRect().height;
await waitForLayout();
expect(view().getByRole("button", { name: "Show more" })).toBeTruthy();
expect(container.getBoundingClientRect().height).toBe(firstFrameHeight);
});
it("paints the user's expanded choice when the row remounts", async () => {
const expansion = createUserMessageExpansion();
renderMessage(expansion);
act(() => view().getByRole("button", { name: "Show more" }).click());
await waitForLayout();
const expandedHeight = container.getBoundingClientRect().height;
act(() => root.render(null));
renderMessage(expansion);
expect(view().getByRole("button", { name: "Show less" })).toBeTruthy();
expect(container.getBoundingClientRect().height).toBe(expandedHeight);
});
});
@@ -1,4 +1,4 @@
import { useState, useCallback, useLayoutEffect, useMemo, useRef, type ReactNode } from "react";
import { useState, useCallback, useMemo, type ReactNode } from "react";
import { View, type LayoutChangeEvent, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
@@ -10,7 +10,6 @@ import { useSessionStore } from "@/stores/session-store";
import { navigateToAgent } from "@/utils/navigate-to-agent";
import { isNative } from "@/constants/platform";
import { useDisclosureHold } from "@/agent-stream/disclosure";
import { useUserMessageExpanded } from "./expansion-context";
const PREVIEW_HEIGHT = 240;
// Show more and show less sit under the text they open and close.
@@ -22,35 +21,24 @@ const previewStyle: ViewStyle = {
overflow: isNative ? "scroll" : "hidden",
};
/** Caps a long message at the preview height, with a control to read the rest. */
export function MessagePreview({ itemId, children }: { itemId: string; children: ReactNode }) {
export function MessagePreview({ children }: { children: ReactNode }) {
const { t } = useTranslation();
const { expanded, toggle: toggleExpanded } = useUserMessageExpanded(itemId);
const bodyRef = useRef<View>(null);
const [expanded, setExpanded] = useState(false);
const [overflowing, setOverflowing] = useState(false);
// A hidden panel lays out at zero height, which says nothing about the message.
const applyBodyHeight = useCallback((height: number) => {
if (height > 0) setOverflowing(height > PREVIEW_HEIGHT);
}, []);
// Measure in the commit that mounts the body: an update from a layout effect paints with it, so
// the first frame already shows the fade and the control. `onLayout` only lands after paint.
useLayoutEffect(() => {
applyBodyHeight(bodyRef.current!.getBoundingClientRect().height);
}, [applyBodyHeight]);
const measure = useCallback(
(event: LayoutChangeEvent) => applyBodyHeight(event.nativeEvent.layout.height),
[applyBodyHeight],
(event: LayoutChangeEvent) => setOverflowing(event.nativeEvent.layout.height > PREVIEW_HEIGHT),
[],
);
const hold = useDisclosureHold();
const toggle = useCallback(
() => hold(toggleExpanded, TRIGGER_BELOW_CONTENT),
[hold, toggleExpanded],
() => hold(() => setExpanded((value) => !value), TRIGGER_BELOW_CONTENT),
[hold],
);
const accessibilityState = useMemo(() => ({ expanded }), [expanded]);
return (
<>
<View style={!expanded && previewStyle}>
<View ref={bodyRef} style={styles.body} onLayout={measure}>
<View style={styles.body} onLayout={measure}>
{children}
</View>
{overflowing && !expanded ? (
@@ -64,7 +64,7 @@ export function WorkspaceHoverCard({
}
return (
<HoverCard disabled={isDragging || disabled} ignoreTouch>
<HoverCard disabled={isDragging || disabled}>
<HoverCardTrigger>{children}</HoverCardTrigger>
<HoverCardContent
placement="right"
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { WorkspaceRenameModal } from "@/workspace-rename";
import { WorkspaceRenameModal } from "@/components/workspace-rename-modal";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import type { KeyboardActionId } from "@/keyboard/keyboard-action-dispatcher";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
@@ -0,0 +1,70 @@
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { useMutation } from "@tanstack/react-query";
import { AdaptiveRenameModal } from "@/components/rename-modal";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
// The subset of a workspace the rename dialog needs. Narrower than SidebarWorkspaceEntry so the
// command center can build one from the active route selection without a sidebar row.
export interface RenamableWorkspace {
serverId: string;
workspaceId: string;
name: string;
title?: string | null;
}
export interface WorkspaceRenameModalProps {
visible: boolean;
workspace: RenamableWorkspace;
onClose: () => void;
/**
* Prefix for the modal's testIDs. The sidebar callers must keep passing
* `sidebar-workspace-rename-modal-${workspaceKey}` — e2e/browser/sidebar-workspace-rename.spec.ts
* builds its locators from exactly that prefix.
*/
testID?: string;
}
/**
* Owns the setWorkspaceTitle mutation and every rename string, so a caller only tracks its own
* open/closed boolean. Errors surface inline inside AdaptiveRenameModal; the dialog stays open.
*/
export function WorkspaceRenameModal({
visible,
workspace,
onClose,
testID,
}: WorkspaceRenameModalProps) {
const { t } = useTranslation();
const renameMutation = useMutation({
mutationFn: async (title: string) => {
const client = getHostRuntimeStore().getClient(workspace.serverId);
if (!client) {
throw new Error(t("sidebar.workspace.toasts.hostDisconnected"));
}
await client.setWorkspaceTitle(workspace.workspaceId, title.length === 0 ? null : title);
},
});
const renameAsync = renameMutation.mutateAsync;
const handleSubmit = useCallback(
async (value: string) => {
await renameAsync(value.trim());
},
[renameAsync],
);
return (
<AdaptiveRenameModal
visible={visible}
title={t("sidebar.workspace.rename.title")}
initialValue={workspace.title ?? workspace.name}
placeholder={workspace.name}
submitLabel={t("sidebar.workspace.rename.submit")}
onClose={onClose}
onSubmit={handleSubmit}
testID={testID}
/>
);
}
@@ -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;
@@ -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"),
},
],
@@ -1,11 +1,10 @@
import { useMemo, useCallback, useRef, useSyncExternalStore } from "react";
import { useMemo, useCallback, useEffect, useRef, useSyncExternalStore } from "react";
import equal from "fast-deep-equal";
import { useShallow } from "zustand/shallow";
import { useSessionStore } from "@/stores/session-store";
import type { AgentDirectoryEntry } from "@/types/agent-directory";
import type { Agent } from "@/stores/session-store";
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
import { useAllDirectoryDemand } from "./use-directory-demand";
export interface AggregatedAgent extends AgentDirectoryEntry {
serverId: string;
@@ -20,9 +19,6 @@ export interface AggregatedAgentsResult {
refreshAll: () => void;
}
const NO_AGENT_MAPS: Record<string, Map<string, Agent> | undefined> = Object.freeze({});
const NO_AGENTS: AggregatedAgent[] = [];
export function useAggregatedAgents(options?: {
includeArchived?: boolean;
demand?: boolean;
@@ -32,21 +28,19 @@ export function useAggregatedAgents(options?: {
const includeArchived = options?.includeArchived ?? false;
const demand = options?.demand ?? true;
const serverIds = useMemo(() => daemons.map((daemon) => daemon.serverId), [daemons]);
useAllDirectoryDemand({ serverIds, enabled: demand });
useEffect(() => {
if (!demand) return;
const releases = serverIds.map((serverId) => runtime.acquireDirectoryDemand(serverId));
return () => releases.forEach((release) => release());
}, [demand, runtime, serverIds]);
const runtimeVersion = useSyncExternalStore(
(onStoreChange) => runtime.subscribeAll(onStoreChange),
() => runtime.getVersion(),
() => runtime.getVersion(),
);
// The caller has said it does not need the directory, so stay off the agent maps entirely:
// every map change otherwise re-renders this hook and rebuilds the whole list for a surface
// that is not on screen (the command center while it is closed).
const sessionAgents = useSessionStore(
useShallow((state) => {
if (!demand) {
return NO_AGENT_MAPS;
}
const result: Record<string, Map<string, Agent> | undefined> = {};
for (const [serverId, session] of Object.entries(state.sessions)) {
result[serverId] = session.agents;
@@ -70,16 +64,6 @@ export function useAggregatedAgents(options?: {
const result = useMemo(() => {
// runtimeVersion is referenced so the memo recomputes when runtime state changes.
void runtimeVersion;
if (!demand) {
prevAgentsRef.current = new Map();
prevSortedRef.current = NO_AGENTS;
return {
agents: NO_AGENTS,
isLoading: false,
isInitialLoad: false,
isRevalidating: false,
};
}
const allAgents: AggregatedAgent[] = [];
const serverLabelById = new Map(
daemons.map((daemon) => [daemon.serverId, daemon.label] as const),
@@ -174,7 +158,7 @@ export function useAggregatedAgents(options?: {
isInitialLoad,
isRevalidating,
};
}, [daemons, demand, includeArchived, runtime, runtimeVersion, sessionAgents]);
}, [daemons, includeArchived, runtime, runtimeVersion, sessionAgents]);
return {
...result,
@@ -14,21 +14,3 @@ export function useDirectoryDemand(serverId: string | null | undefined): void {
return getHostRuntimeStore().acquireDirectoryDemand(serverId);
}, [serverId]);
}
/**
* Holds directory demand for several hosts at once, so a caller that needs the whole fleet
* (the favicon, the aggregated agent list) does not have to subscribe to every agent map to
* keep the directories loading.
*/
export function useAllDirectoryDemand(input: {
serverIds: readonly string[];
enabled: boolean;
}): void {
const { serverIds, enabled } = input;
useEffect(() => {
if (!enabled) return;
const runtime = getHostRuntimeStore();
const releases = serverIds.map((serverId) => runtime.acquireDirectoryDemand(serverId));
return () => releases.forEach((release) => release());
}, [enabled, serverIds]);
}
+32 -23
View File
@@ -1,13 +1,12 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { getIsElectronRuntimeMac } from "@/constants/layout";
import { useAllDirectoryDemand } from "./use-directory-demand";
import { useAggregatedAgents } from "./use-aggregated-agents";
import { getDesktopHost } from "@/desktop/host";
import { createMacDockBadgeCountSelector } from "@/utils/desktop-badge-state";
import { createFaviconStatusSelector, type FaviconStatus } from "@/utils/favicon-status";
import { useWorkspaceStatusesForBadges } from "@/stores/session-store-hooks";
import { deriveMacDockBadgeCountFromWorkspaceStatuses } from "@/utils/desktop-badge-state";
import { isNative } from "@/constants/platform";
import { useSessionStore } from "@/stores/session-store";
import { useHosts } from "@/runtime/host-runtime";
type FaviconStatus = "none" | "running" | "attention";
type ColorScheme = "dark" | "light";
/* eslint-disable @typescript-eslint/no-require-imports */
@@ -25,6 +24,21 @@ const FAVICON_IMAGES: Record<ColorScheme, Record<FaviconStatus, { uri: string }
};
/* eslint-enable @typescript-eslint/no-require-imports */
function deriveFaviconStatus(
agents: ReturnType<typeof useAggregatedAgents>["agents"],
): FaviconStatus {
const hasRunning = agents.some((agent) => agent.status === "running");
if (hasRunning) {
return "running";
}
const hasAttention = agents.some((agent) => agent.requiresAttention);
const hasNeedsInput = agents.some((agent) => (agent.pendingPermissionCount ?? 0) > 0);
if (hasAttention || hasNeedsInput) {
return "attention";
}
return "none";
}
function getFaviconUri(status: FaviconStatus, colorScheme: ColorScheme): string {
const image = FAVICON_IMAGES[colorScheme][status];
if (typeof image === "object" && "uri" in image) {
@@ -80,15 +94,8 @@ async function updateMacDockBadge(count?: number) {
}
export function useFaviconStatus() {
const daemons = useHosts();
const serverIds = useMemo(() => daemons.map((daemon) => daemon.serverId), [daemons]);
// The favicon reads the whole fleet, so it has to keep every host's directory loading even
// though it no longer subscribes to the agent maps.
useAllDirectoryDemand({ serverIds, enabled: !isNative });
const selectFaviconStatus = useMemo(createFaviconStatusSelector, []);
const selectMacDockBadgeCount = useMemo(createMacDockBadgeCountSelector, []);
const status = useSessionStore(selectFaviconStatus);
const dockBadgeCount = useSessionStore(selectMacDockBadgeCount);
const { agents } = useAggregatedAgents({ demand: !isNative });
const workspaceStatuses = useWorkspaceStatusesForBadges();
const [colorScheme, setColorScheme] = useState<ColorScheme>(getSystemColorScheme);
const lastDockBadgeCountRef = useRef<number | undefined>(undefined);
@@ -105,15 +112,17 @@ export function useFaviconStatus() {
return () => mediaQuery.removeEventListener("change", handler);
}, []);
// Update favicon when agents or color scheme changes
useEffect(() => {
if (isNative) return;
updateFavicon(status, colorScheme);
}, [status, colorScheme]);
useEffect(() => {
if (isNative) return;
if (dockBadgeCount === lastDockBadgeCountRef.current) return;
lastDockBadgeCountRef.current = dockBadgeCount;
void updateMacDockBadge(dockBadgeCount);
}, [dockBadgeCount]);
const status = deriveFaviconStatus(agents);
updateFavicon(status, colorScheme);
const dockBadgeCount = deriveMacDockBadgeCountFromWorkspaceStatuses(workspaceStatuses);
if (dockBadgeCount !== lastDockBadgeCountRef.current) {
lastDockBadgeCountRef.current = dockBadgeCount;
void updateMacDockBadge(dockBadgeCount);
}
}, [agents, colorScheme, workspaceStatuses]);
}
+2
View File
@@ -722,6 +722,8 @@ export const ar: TranslationResources = {
resumeCommandUnavailable: "أمر الاستئناف غير متوفر",
reloadingAgent: "وكيل إعادة التحميل...",
reloadedAgent: "وكيل إعادة تحميل",
reloadedAgentHistoryDiverged:
"تمت إعادة تحميل الوكيل، لكن سجل المزوّد لا يطابق هذه المحادثة، لذا لم تتم مزامنته",
failedToReloadAgent: "فشل في إعادة تحميل الوكيل",
failedToCloseAgent: "فشل في إغلاق الوكيل",
},
+2
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",
},
+2
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",
},
+2
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",
},
+2
View File
@@ -729,6 +729,8 @@ export const ja: TranslationResources = {
resumeCommandUnavailable: "再開コマンドが利用できません",
reloadingAgent: "エージェントを再読み込み中...",
reloadedAgent: "エージェントを再読み込みしました",
reloadedAgentHistoryDiverged:
"エージェントを再読み込みしましたが、プロバイダーの履歴がこのチャットと一致しないため同期しませんでした",
failedToReloadAgent: "エージェントの再読み込みに失敗しました",
failedToCloseAgent: "エージェントを閉じられませんでした",
},
+2
View File
@@ -724,6 +724,8 @@ export const ko: TranslationResources = {
resumeCommandUnavailable: "재개 명령을 사용할 수 없습니다",
reloadingAgent: "에이전트 다시 로드 중...",
reloadedAgent: "에이전트를 다시 로드했습니다",
reloadedAgentHistoryDiverged:
"에이전트를 다시 로드했지만 제공자 기록이 이 채팅과 일치하지 않아 동기화하지 않았습니다",
failedToReloadAgent: "에이전트를 다시 로드하지 못했습니다",
failedToCloseAgent: "에이전트를 닫지 못했습니다",
},
+2
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",
},
+2
View File
@@ -729,6 +729,8 @@ export const ru: TranslationResources = {
resumeCommandUnavailable: "Команда возобновления недоступна",
reloadingAgent: "Перезагрузка агента...",
reloadedAgent: "Агент перезагружен",
reloadedAgentHistoryDiverged:
"Агент перезагружен, но история провайдера не совпадает с этим чатом, поэтому она не синхронизирована",
failedToReloadAgent: "Не удалось перезагрузить агента",
failedToCloseAgent: "Не удалось закрыть агента",
},
+2
View File
@@ -722,6 +722,8 @@ export const zhCN: TranslationResources = {
resumeCommandUnavailable: "恢复命令不可用",
reloadingAgent: "正在重新加载 Agent...",
reloadedAgent: "已重新加载 Agent",
reloadedAgentHistoryDiverged:
"已重新加载 Agent,但提供方的历史记录与此对话不一致,因此未同步",
failedToReloadAgent: "重新加载 Agent 失败",
failedToCloseAgent: "关闭 Agent 失败",
},
-2
View File
@@ -8,8 +8,6 @@ export {
subscribeOverlays,
} from "./internal/overlay-dispatch";
export { useKeyboardFocusSurface } from "./internal/use-focus-surface";
export { subscribeHardwareKeyboard } from "./internal/sources/hardware";
export type { HardwareKeyboardEvent } from "./internal/sources/hardware";
export { usesDesktopBindings, supportsConfigurableComposerSendKeys } from "./internal/conventions";
export type { BrowserKeyboardPolicy } from "./internal/sources/electron-browser";
export { useHardwareKeyboardPresent } from "./availability";
@@ -1,8 +1,7 @@
import { i18n } from "@/i18n/i18next";
import { fr } from "@/i18n/resources/fr";
import { describe, expect, it } from "vitest";
import type { Agent, WorkspaceDescriptor } from "@/stores/session-store";
import { normalizeAgentSnapshot, projectAgentSnapshot } from "@/utils/agent-snapshots";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import {
createPluginAgentSnapshot,
createPluginWorkspaceSnapshot,
@@ -110,26 +109,6 @@ describe("sidebar entity snapshots", () => {
).toBe(after);
});
it("carries the background work count the host publishes for the agent", () => {
// Round-trip through the wire payload, the way the app receives each agent update.
const published = (backgroundWork: Agent["backgroundWork"]) =>
createPluginAgentSnapshot(
normalizeAgentSnapshot({ ...projectAgentSnapshot(agent), backgroundWork }, agent.serverId),
workspace.id,
).backgroundWork;
expect(published(undefined)).toEqual({ count: 0 });
expect(published({ count: 2 })).toEqual({ count: 2 });
expect(published({ count: 0 })).toEqual({ count: 0 });
expect(Object.isFrozen(published({ count: 1 }))).toBe(true);
});
it("marks a background agent", () => {
expect(createPluginAgentSnapshot(agent, workspace.id).background).toBe(false);
expect(createPluginAgentSnapshot({ ...agent, background: true }, workspace.id).background).toBe(
true,
);
});
it("reads projects without requiring a workspace", () => {
const project = {
projectId: "empty",
@@ -86,8 +86,6 @@ export function createPluginAgentSnapshot(agent: Agent, workspaceId: string): Pl
workspaceId,
provider: agent.provider,
status: agent.status,
backgroundWork: Object.freeze({ count: agent.backgroundWork?.count ?? 0 }),
background: agent.background ?? false,
createdAt: agent.createdAt.toISOString(),
updatedAt: agent.updatedAt.toISOString(),
lastActivityAt: agent.lastActivityAt.toISOString(),
@@ -51,8 +51,6 @@ const agent: PluginAgentSnapshot = {
workspaceId: workspace.id,
provider: "codex",
status: "running",
backgroundWork: { count: 0 },
background: false,
createdAt: "2026-08-16T10:00:00.000Z",
updatedAt: "2026-08-16T10:00:00.000Z",
lastActivityAt: "2026-08-16T10:00:00.000Z",
@@ -91,7 +91,7 @@ describe("AgentDirectoryReplica", () => {
id: "reply",
text: "server history",
timestamp: new Date(),
timelineCursor: { epoch: "e", seq: 1 },
timelineCursor: { epoch: "e", seq: 1, anchorSeq: 1 },
},
],
head: [],
+12 -4
View File
@@ -244,6 +244,7 @@ const PROBE_STEADY_MS = 10_000;
const PROBE_MAX_BACKOFF_MS = 30_000;
const PROBE_INACTIVE_WHILE_ONLINE_MS = 120_000;
const ADAPTIVE_SWITCH_THRESHOLD_MS = 40;
const MAX_STALE_TIMELINE_PAGE_ATTEMPTS = 3;
const ADAPTIVE_SWITCH_CONSECUTIVE_PROBES = 3;
const CONFIGURED_OVERRIDE_BOOTSTRAP_RETRY_MS = 1_000;
@@ -2857,12 +2858,19 @@ export class HostRuntimeStore {
const directory = this.directorySyncByServer.get(serverId);
if (!directory) throw new Error(`Unknown host runtime for serverId ${serverId}`);
const owner = useSessionStore.getState().sessions[serverId]?.viewedTimelineSync;
const page = await directory.fetchTimeline(agentId, request);
if (owner && useSessionStore.getState().sessions[serverId]?.viewedTimelineSync === owner) {
// A page served before a rewind this client applied is asked for again. Seqs only grow within
// an epoch, so the next page includes the rewind unless another one landed meanwhile.
for (let attempt = 1; ; attempt += 1) {
const page = await directory.fetchTimeline(agentId, request);
if (!owner || useSessionStore.getState().sessions[serverId]?.viewedTimelineSync !== owner) {
return page;
}
owner.flushStreamAgent(agentId);
owner.applyTimelineResponse(page);
if (owner.applyTimelineResponse(page) === "applied") return page;
if (attempt === MAX_STALE_TIMELINE_PAGE_ATTEMPTS) {
throw new Error(`Timeline page for ${agentId} stayed behind a rewind`);
}
}
return page;
}
createViewedTimelineOwner(
@@ -184,7 +184,7 @@ function timelineItem(text = "Cached"): StreamItem {
id: "message-1",
text,
timestamp: new Date("2026-07-18T08:02:00.000Z"),
timelineCursor: { epoch: "epoch-1", seq: 12 },
timelineCursor: { epoch: "epoch-1", seq: 12, anchorSeq: 12 },
};
}
@@ -789,7 +789,7 @@ describe("ReplicaCache", () => {
version: 1,
data: { passed: 4, failed: 0 },
timestamp: new Date("2026-07-18T08:02:00.000Z"),
timelineCursor: { epoch: "epoch-1", seq: 12 },
timelineCursor: { epoch: "epoch-1", seq: 12, anchorSeq: 12 },
};
writer.commitTimeline(SERVER_ID, "agent-1", {
agentId: "agent-1",
@@ -834,6 +834,32 @@ describe("ReplicaCache", () => {
expect(await createCache(storage).readTimeline(SERVER_ID, "agent-1")).toBeUndefined();
});
it("keeps where each cached row started, and refetches a timeline cached without it", async () => {
const storage = new MemoryStorage();
const writer = createCache(storage);
const updatedAfterItStarted: StreamItem = {
...timelineItem(),
timelineCursor: { epoch: "epoch-1", seq: 12, anchorSeq: 9 },
};
writer.commitTimeline(SERVER_ID, "agent-1", { ...timeline(), items: [updatedAfterItStarted] });
await writer.flush();
expect((await createCache(storage).readTimeline(SERVER_ID, "agent-1"))?.items).toEqual([
updatedAfterItStarted,
]);
const row = requireTimelineRow(storage);
const payload = JSON.parse(row.payload) as {
items: Array<{ timelineCursor?: Record<string, unknown> }>;
};
delete payload.items[0]?.timelineCursor?.anchorSeq;
storage.rows.set(`${row.serverId}:${row.kind}:${row.id}`, {
...row,
payload: JSON.stringify(payload),
});
expect(await createCache(storage).readTimeline(SERVER_ID, "agent-1")).toBeUndefined();
});
it("refetches old cached Markdown fragments instead of offering them as whole messages", async () => {
const storage = new MemoryStorage();
const writer = createCache(storage);
@@ -1197,7 +1223,7 @@ it("retains semantic agent messages across disk-cache reconstruction", async ()
kind: "tool_call",
id: "delivery",
timestamp: new Date(1),
timelineCursor: { epoch: "epoch-1", seq: 12 },
timelineCursor: { epoch: "epoch-1", seq: 12, anchorSeq: 12 },
payload: {
source: "agent",
data: {
@@ -19,6 +19,7 @@ import {
isUnreconciledLocalUserMessage,
type AgentToolCallData,
type StreamItem,
type TimelinePosition,
} from "@/types/stream";
import type { UnsettledTurn } from "@/timeline/turn-liveness";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
@@ -79,7 +80,15 @@ const IsoDateSchema = z.iso.datetime();
const TimelinePositionSchema = z.strictObject({
epoch: z.string(),
seq: z.number().int().nonnegative(),
// COMPAT(timelineRewindOperations): absent on caches written before rows were anchored, added in
// v0.14.1, remove after 2027-05-10. Such a cache is not restored; the chat loads its tail instead.
anchorSeq: z.number().int().nonnegative().optional(),
});
type StoredTimelinePosition = z.infer<typeof TimelinePositionSchema>;
function isAnchored(position: StoredTimelinePosition | undefined): boolean {
return position === undefined || position.anchorSeq !== undefined;
}
const PluginTimelineDataSchema: z.ZodType<PluginTimelineData> = z.lazy(() =>
z.union([
z.null(),
@@ -413,6 +422,10 @@ function deserializeTimeline(stored: StoredTimeline | null): CachedTimeline | nu
if (!stored) {
return null;
}
// A rewind retires rows by anchor, so an unanchored row could not be retired.
if (!stored.items.every((item) => isAnchored(item.timelineCursor))) {
return null;
}
return {
agentId: stored.agentId,
items: stored.items.map(deserializeTimelineItem),
@@ -527,11 +540,20 @@ function serializeTimelineItem(item: StreamItem): StoredTimelineItem | null {
}
}
function anchoredPosition(position: StoredTimelinePosition | undefined): {
timelineCursor?: TimelinePosition;
} {
if (!position || position.anchorSeq === undefined) return {};
return {
timelineCursor: { epoch: position.epoch, seq: position.seq, anchorSeq: position.anchorSeq },
};
}
function deserializeTimelineItem(item: StoredTimelineItem): StreamItem {
if (item.kind === "plugin") {
return {
id: item.id,
...(item.timelineCursor ? { timelineCursor: item.timelineCursor } : {}),
...anchoredPosition(item.timelineCursor),
...(item.turnId ? { turnId: item.turnId } : {}),
timestamp: new Date(item.timestamp),
kind: item.kind,
@@ -550,7 +572,7 @@ function deserializeBuiltinTimelineItem(
): StreamItem {
const base = {
id: item.id,
...(item.timelineCursor ? { timelineCursor: item.timelineCursor } : {}),
...anchoredPosition(item.timelineCursor),
...(item.turnId ? { turnId: item.turnId } : {}),
timestamp: new Date(item.timestamp),
};
@@ -2215,11 +2215,10 @@ function WorkspaceScreenContent({
toast.show(t("workspace.tabs.toasts.reloadingAgent"), { durationMs: null });
try {
await client.refreshAgent(agentId);
// Send the existing cursor so the server detects the new epoch and
// returns reset:true. Without a cursor, the server returns reset:false
// and the client takes the incremental path, where new-epoch rows are
// dropped against the stale cursor.
const refreshed = await client.refreshAgent(agentId);
// Send the existing cursor: Reload agent keeps the history, so the server
// returns only the rows it added. When the history was imported again
// under a new epoch, the cursor reads as stale and the server resets.
const sessionState = useSessionStore.getState().sessions[normalizedServerId];
const currentCursor = sessionState?.agentTimelineCursor.get(agentId);
await getHostRuntimeStore().fetchAgentTimeline(normalizedServerId, agentId, {
@@ -2228,7 +2227,13 @@ function WorkspaceScreenContent({
? { cursor: { epoch: currentCursor.epoch, seq: currentCursor.endSeq } }
: {}),
});
toast.show(t("workspace.tabs.toasts.reloadedAgent"), { variant: "success" });
if (refreshed.historyDiverged) {
toast.show(t("workspace.tabs.toasts.reloadedAgentHistoryDiverged"), {
variant: "warning",
});
} else {
toast.show(t("workspace.tabs.toasts.reloadedAgent"), { variant: "success" });
}
} catch (error) {
toast.error(
error instanceof Error ? error.message : t("workspace.tabs.toasts.failedToReloadAgent"),

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