mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-12 19:35:04 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2c5642827 | ||
|
|
ee6a2bba77 | ||
|
|
83f4ef9909 | ||
|
|
a99f18b405 | ||
|
|
6e5742fbc6 | ||
|
|
97d39a0158 | ||
|
|
8b8957ab77 | ||
|
|
603ee5ced1 | ||
|
|
de1b85ecfb | ||
|
|
33c818cc6b | ||
|
|
de67a7370c | ||
|
|
f684804821 | ||
|
|
e7432cb2a5 | ||
|
|
4aeace7a17 | ||
|
|
e7b31bb746 | ||
|
|
fb435d225f |
+4
-4
@@ -3,15 +3,15 @@
|
||||
## 0.16.0-beta.1 - 2026-11-10
|
||||
|
||||
- **Background tasks:** Paseo now shows the background tasks an agent is running, such as shells and monitors, above the composer, with a Stop button for each. Works with Claude Code, Codex and OpenCode v2. ([#6220](https://github.com/getpaseo/paseo/pull/6220), [#9162](https://github.com/getpaseo/paseo/pull/9162) by [@fxlelouarn](https://github.com/fxlelouarn))
|
||||
- **Faster diff loading:** Opening Changes shows the file tree right away while file contents load. Large diffs arrive in batches, so the rest of the app keeps responding while they load, and diffs take priority over the daemon's background worktree scans. ([#8907](https://github.com/getpaseo/paseo/pull/8907), [#9241](https://github.com/getpaseo/paseo/pull/9241), [#9243](https://github.com/getpaseo/paseo/pull/9243))
|
||||
- **Fewer reconnects on return:** Returning to the app keeps a working connection, including one in the middle of a large response, and reconnects once when it has to. On a host with several saved connections, Paseo stays on the one that works. ([#8651](https://github.com/getpaseo/paseo/pull/8651), [#9086](https://github.com/getpaseo/paseo/pull/9086), [#8855](https://github.com/getpaseo/paseo/pull/8855), [#9116](https://github.com/getpaseo/paseo/pull/9116))
|
||||
- **Keyboard shortcuts on mobile:** With a keyboard connected, iPad and Android now run the same shortcuts as desktop, including your custom bindings. Shortcut hints and Settings → Shortcuts appear when a keyboard is detected. Shortcuts also work in narrow desktop and browser windows. ([#8625](https://github.com/getpaseo/paseo/pull/8625) by [@jeroenfrenken](https://github.com/jeroenfrenken), [@lstroud](https://github.com/lstroud))
|
||||
- **Touch-sized tablet controls:** On iPad and other touch-screen devices, the wide layout now has touch-sized buttons, rows and bars. The composer toolbar folds its controls into the model menu when space runs out, and swiping right on the workspace reveals a collapsed sidebar as an overlay. ([#8748](https://github.com/getpaseo/paseo/pull/8748), [#8885](https://github.com/getpaseo/paseo/pull/8885), [#8891](https://github.com/getpaseo/paseo/pull/8891), [#8909](https://github.com/getpaseo/paseo/pull/8909), [#9151](https://github.com/getpaseo/paseo/pull/9151), [#9238](https://github.com/getpaseo/paseo/pull/9238), [#8739](https://github.com/getpaseo/paseo/pull/8739) by [@zeyugao](https://github.com/zeyugao))
|
||||
- **Plugin sidebar views:** A plugin can replace the sidebar's content with its own layout of workspaces, agents, projects and terminals across every connected host. Pick the view per device under Settings → Sidebar; Paseo's sidebar header and footer stay in place. ([#8906](https://github.com/getpaseo/paseo/pull/8906))
|
||||
- **Plugin composer controls:** Plugins can add their own controls to the composer, and read and set its prompt, attachments, provider, model, mode and thinking. They can also put the cursor in any agent's composer, add items to New workspace, and store their own metadata on agents and workspaces. ([#8744](https://github.com/getpaseo/paseo/pull/8744), [#9209](https://github.com/getpaseo/paseo/pull/9209), [#9207](https://github.com/getpaseo/paseo/pull/9207))
|
||||
- **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))
|
||||
- **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))
|
||||
- **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
|
||||
|
||||
|
||||
+4
-4
@@ -3,15 +3,15 @@
|
||||
## 0.16.0-beta.1 - 2026-11-10
|
||||
|
||||
- **后台任务**:Paseo 现在会在 Composer 上方显示 Agent 正在运行的后台任务,例如 shell 和监视器,每项都带“停止”按钮。支持 Claude Code、Codex 和 OpenCode v2。 ([#6220](https://github.com/getpaseo/paseo/pull/6220), [#9162](https://github.com/getpaseo/paseo/pull/9162),贡献者:[@fxlelouarn](https://github.com/fxlelouarn))
|
||||
- **更快的 diff 加载**:打开“更改”时会立即显示文件树,文件内容随后加载。大型 diff 分批到达,加载期间应用的其他部分照常响应,并且 diff 优先于 daemon 的后台 worktree 扫描。 ([#8907](https://github.com/getpaseo/paseo/pull/8907), [#9241](https://github.com/getpaseo/paseo/pull/9241), [#9243](https://github.com/getpaseo/paseo/pull/9243))
|
||||
- **返回时更少重连**:返回应用时会保留仍可用的连接,包括正在传输大型响应的连接,只在必要时重连一次。主机保存了多个连接时,Paseo 会留在可用的那个连接上。 ([#8651](https://github.com/getpaseo/paseo/pull/8651), [#9086](https://github.com/getpaseo/paseo/pull/9086), [#8855](https://github.com/getpaseo/paseo/pull/8855), [#9116](https://github.com/getpaseo/paseo/pull/9116))
|
||||
- **移动端键盘快捷键**:连接键盘后,iPad 和 Android 现在可使用与桌面端相同的快捷键,包括你自定义的绑定。检测到键盘时会显示快捷键提示和“设置 → 快捷键”。在较窄的桌面端和浏览器窗口中快捷键同样可用。 ([#8625](https://github.com/getpaseo/paseo/pull/8625),贡献者:[@jeroenfrenken](https://github.com/jeroenfrenken)、[@lstroud](https://github.com/lstroud))
|
||||
- **适合触控的平板控件**:在 iPad 和其他触屏设备上,宽屏布局现在使用适合触控尺寸的按钮、行和栏。Composer 工具栏在空间不足时会把控件收进模型菜单;在工作区上向右滑动,可将已折叠的侧边栏以浮层方式显示。 ([#8748](https://github.com/getpaseo/paseo/pull/8748), [#8885](https://github.com/getpaseo/paseo/pull/8885), [#8891](https://github.com/getpaseo/paseo/pull/8891), [#8909](https://github.com/getpaseo/paseo/pull/8909), [#9151](https://github.com/getpaseo/paseo/pull/9151), [#9238](https://github.com/getpaseo/paseo/pull/9238), [#8739](https://github.com/getpaseo/paseo/pull/8739),贡献者:[@zeyugao](https://github.com/zeyugao))
|
||||
- **插件侧边栏视图**:插件可以用自己的布局替换侧边栏内容,展示所有已连接主机上的工作区、Agent、项目和终端。在“设置 → 侧边栏”中为每台设备选择视图;Paseo 的侧边栏页眉和页脚保持不变。 ([#8906](https://github.com/getpaseo/paseo/pull/8906))
|
||||
- **插件 Composer 控件**:插件可以向 Composer 添加自己的控件,并读取和设置其中的提示词、附件、Provider、模型、模式和思考强度。插件还可以把光标放到任意 Agent 的 Composer 中、向“新建工作区”添加条目,并在 Agent 和工作区上保存自己的元数据。 ([#8744](https://github.com/getpaseo/paseo/pull/8744), [#9209](https://github.com/getpaseo/paseo/pull/9209), [#9207](https://github.com/getpaseo/paseo/pull/9207))
|
||||
- **更快的 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))
|
||||
- **移动端键盘快捷键**:连接键盘后,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))
|
||||
- **撤销工作区归档**:在应用中归档工作区时,现在会显示“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))
|
||||
|
||||
### 新增
|
||||
|
||||
|
||||
@@ -54,7 +54,6 @@ 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
@@ -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. Resume does not read provider history when the agent already has a timeline;
|
||||
see [owned history](timeline-sync.md#owned-history).
|
||||
same Paseo agent ID. Provider history is not appended again when the canonical timeline is already
|
||||
primed.
|
||||
|
||||
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,8 +239,7 @@ 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. The timeline and the provider children stay, so an archived chat reads them without its
|
||||
provider; deleting the agent removes them.
|
||||
children.
|
||||
|
||||
`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.
|
||||
|
||||
@@ -258,7 +257,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;
|
||||
only the route's explicit Unarchive or Restore action changes the archived workspace.
|
||||
the route exposes explicit Unarchive and Restore actions.
|
||||
|
||||
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
|
||||
@@ -282,9 +281,12 @@ 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
|
||||
**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.
|
||||
**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.
|
||||
|
||||
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.
|
||||
@@ -333,8 +335,6 @@ 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
|
||||
|
||||
@@ -39,6 +39,19 @@ 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
|
||||
|
||||
@@ -94,7 +94,6 @@ 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 |
|
||||
|
||||
@@ -180,14 +180,6 @@ 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`
|
||||
|
||||
+30
-5
@@ -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, biggest first. 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, 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.
|
||||
|
||||
```markdown
|
||||
## 0.16.0 - 2026-11-20
|
||||
@@ -715,7 +715,7 @@ about.
|
||||
| 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.
|
||||
new?" without its description, and each must outrank the one below it.
|
||||
|
||||
## Changelog attribution
|
||||
|
||||
@@ -748,7 +748,32 @@ Rules:
|
||||
|
||||
## Changelog ordering
|
||||
|
||||
Highlights come first, ordered by user impact. Entries within each section (Added, Improved, Fixed) follow the same order:
|
||||
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:
|
||||
|
||||
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.
|
||||
|
||||
+11
-62
@@ -5,9 +5,15 @@ 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.
|
||||
|
||||
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 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.
|
||||
|
||||
The invariants are:
|
||||
|
||||
@@ -26,44 +32,6 @@ 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:
|
||||
@@ -191,7 +159,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 or epoch change atomically replaces stale canonical history with the
|
||||
- A true middle gap, epoch change, or rewind 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.
|
||||
|
||||
@@ -204,24 +172,6 @@ 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
|
||||
@@ -386,7 +336,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 replaces history and
|
||||
rows because a provider may never echo them. A known epoch change or rewind 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.
|
||||
@@ -402,7 +352,6 @@ 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
@@ -1 +1 @@
|
||||
sha256-21qteVXVoL7S/wF0aGxHNEkTX8in6UF6+wWEZi8N3HA=
|
||||
sha256-vG7v6uq2ADtOILEuRq9q3wTDUENAhRBbzI7UGBsWOqo=
|
||||
|
||||
Generated
+28
-28
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.15.4",
|
||||
"version": "0.16.0-beta.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.15.4",
|
||||
"version": "0.16.0-beta.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
@@ -37468,7 +37468,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.15.4",
|
||||
"version": "0.16.0-beta.1",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/language": "6.12.4",
|
||||
@@ -39637,13 +39637,13 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.15.4",
|
||||
"version": "0.16.0-beta.1",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.15.4",
|
||||
"@getpaseo/protocol": "0.15.4",
|
||||
"@getpaseo/server": "0.15.4",
|
||||
"@getpaseo/client": "0.16.0-beta.1",
|
||||
"@getpaseo/protocol": "0.16.0-beta.1",
|
||||
"@getpaseo/server": "0.16.0-beta.1",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -39890,11 +39890,11 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.15.4",
|
||||
"version": "0.16.0-beta.1",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.15.4",
|
||||
"@getpaseo/relay": "0.15.4",
|
||||
"@getpaseo/protocol": "0.16.0-beta.1",
|
||||
"@getpaseo/relay": "0.16.0-beta.1",
|
||||
"ws": "^8.20.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
@@ -39906,7 +39906,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.15.4",
|
||||
"version": "0.16.0-beta.1",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -40149,7 +40149,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.15.4",
|
||||
"version": "0.16.0-beta.1",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -40195,7 +40195,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.15.4",
|
||||
"version": "0.16.0-beta.1",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "6.12.4",
|
||||
@@ -40225,7 +40225,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.15.4",
|
||||
"version": "0.16.0-beta.1",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^1.4.0",
|
||||
@@ -40233,8 +40233,8 @@
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.15.4",
|
||||
"@getpaseo/protocol": "0.15.4",
|
||||
"@getpaseo/client": "0.16.0-beta.1",
|
||||
"@getpaseo/protocol": "0.16.0-beta.1",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "~19.2.0",
|
||||
"react": "19.2.3",
|
||||
@@ -40244,8 +40244,8 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@getpaseo/client": "0.15.4",
|
||||
"@getpaseo/protocol": "0.15.4",
|
||||
"@getpaseo/client": "0.16.0-beta.1",
|
||||
"@getpaseo/protocol": "0.16.0-beta.1",
|
||||
"react": ">=19.1.0 <20",
|
||||
"react-native": ">=0.81.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -40267,7 +40267,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.15.4",
|
||||
"version": "0.16.0-beta.1",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"semver": "^7.8.5",
|
||||
@@ -40295,7 +40295,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.15.4",
|
||||
"version": "0.16.0-beta.1",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@noble/ciphers": "^2.4.0",
|
||||
@@ -40517,17 +40517,17 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.15.4",
|
||||
"version": "0.16.0-beta.1",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.15.4",
|
||||
"@getpaseo/highlight": "0.15.4",
|
||||
"@getpaseo/plugin": "0.15.4",
|
||||
"@getpaseo/protocol": "0.15.4",
|
||||
"@getpaseo/relay": "0.15.4",
|
||||
"@getpaseo/client": "0.16.0-beta.1",
|
||||
"@getpaseo/highlight": "0.16.0-beta.1",
|
||||
"@getpaseo/plugin": "0.16.0-beta.1",
|
||||
"@getpaseo/protocol": "0.16.0-beta.1",
|
||||
"@getpaseo/relay": "0.16.0-beta.1",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -41474,7 +41474,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.15.4",
|
||||
"version": "0.16.0-beta.1",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
@@ -43084,7 +43084,7 @@
|
||||
},
|
||||
"plugins": {
|
||||
"name": "@getpaseo/builtin-plugins",
|
||||
"version": "0.15.4",
|
||||
"version": "0.16.0-beta.1",
|
||||
"dependencies": {
|
||||
"@getpaseo/plugin": "*",
|
||||
"zod": "^4.4.3"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.15.4",
|
||||
"version": "0.16.0-beta.1",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
|
||||
@@ -11,13 +11,6 @@ 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,
|
||||
@@ -53,102 +46,26 @@ 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 prompt in place on every open client and keeps a human draft", async ({
|
||||
test("rewinds a submitted prompt without replaying history and preserves a human draft", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(150_000);
|
||||
test.setTimeout(90_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);
|
||||
const retainedMessage = await completeSubmittedTurn(page, agent, retainedPrompt);
|
||||
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,
|
||||
@@ -156,91 +73,18 @@ test.describe("Agent message rewind", () => {
|
||||
await fillComposerDraft(page, preservedDraft);
|
||||
rewindCompletion.clearTimelineStreamCount();
|
||||
|
||||
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;
|
||||
});
|
||||
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 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]);
|
||||
});
|
||||
await expect(page.getByTestId("user-message").filter({ hasText: rewoundPrompt })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await expectComposerDraft(page, preservedDraft);
|
||||
await expectNearBottom(page);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
|
||||
@@ -125,11 +125,13 @@ async function renameWorkspaceViaSidebar(
|
||||
await expect(renameItem).toBeVisible({ timeout: 10_000 });
|
||||
await renameItem.click();
|
||||
|
||||
const modalPrefix = `sidebar-workspace-rename-modal-${serverId}:${input.workspaceId}`;
|
||||
const renameInput = page.getByTestId(`${modalPrefix}-input`);
|
||||
await expect(renameInput).toBeVisible({ timeout: 10_000 });
|
||||
// Desktop-width sidebars rename in place.
|
||||
const renameInput = page.getByTestId(
|
||||
`sidebar-workspace-rename-input-${serverId}:${input.workspaceId}`,
|
||||
);
|
||||
await expect(renameInput).toBeFocused({ timeout: 10_000 });
|
||||
await renameInput.fill(input.title);
|
||||
await page.getByTestId(`${modalPrefix}-submit`).click();
|
||||
await renameInput.press("Enter");
|
||||
await expect(renameInput).toHaveCount(0, { timeout: 15_000 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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, 0);
|
||||
await expectUserMessageCount(page, 1);
|
||||
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, 1);
|
||||
await expectUserMessageCount(page, 2);
|
||||
|
||||
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, 1);
|
||||
await expectUserMessageCount(page, 2);
|
||||
|
||||
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, 1);
|
||||
await expectUserMessageCount(page, 2);
|
||||
|
||||
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, 0);
|
||||
await expectUserMessageCount(page, 1);
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ 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}`;
|
||||
@@ -12,9 +13,13 @@ function workspaceRenameModalTestId(workspaceId: string, suffix: string): string
|
||||
return `sidebar-workspace-rename-modal-${getServerId()}:${workspaceId}-${suffix}`;
|
||||
}
|
||||
|
||||
async function openRenameModal(page: Page, workspaceId: string) {
|
||||
function workspaceRenameInputTestId(workspaceId: string): string {
|
||||
return `sidebar-workspace-rename-input-${getServerId()}:${workspaceId}`;
|
||||
}
|
||||
|
||||
async function chooseRenameFromKebab(page: Page, workspaceId: string): Promise<void> {
|
||||
const serverId = getServerId();
|
||||
const row = page.getByTestId(`sidebar-workspace-row-${serverId}:${workspaceId}`);
|
||||
const row = page.getByTestId(workspaceRowTestId(workspaceId));
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await row.hover();
|
||||
|
||||
@@ -25,40 +30,87 @@ async function openRenameModal(page: Page, workspaceId: string) {
|
||||
const renameItem = page.getByTestId(`sidebar-workspace-menu-rename-${serverId}:${workspaceId}`);
|
||||
await expect(renameItem).toBeVisible({ timeout: 10_000 });
|
||||
await renameItem.click();
|
||||
}
|
||||
|
||||
const input = page.getByTestId(workspaceRenameModalTestId(workspaceId, "input"));
|
||||
await expect(input).toBeVisible({ timeout: 10_000 });
|
||||
return input;
|
||||
/** 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;
|
||||
}
|
||||
|
||||
// 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 via kebab sets a custom title that survives reload", async ({ page }) => {
|
||||
test("renaming in place sets a custom title without moving the row, and 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);
|
||||
|
||||
const input = await openRenameModal(page, workspace.workspaceId);
|
||||
await chooseRenameFromKebab(page, workspace.workspaceId);
|
||||
const input = page.getByTestId(workspaceRenameInputTestId(workspace.workspaceId));
|
||||
await expect(input).toBeFocused();
|
||||
await expect(input).toBeEditable();
|
||||
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";
|
||||
await input.fill(customTitle);
|
||||
await page.getByTestId(workspaceRenameModalTestId(workspace.workspaceId, "submit")).click();
|
||||
// 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 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
|
||||
@@ -73,6 +125,259 @@ 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> {
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
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,
|
||||
@@ -300,9 +299,7 @@ test.describe("Worktree restore", () => {
|
||||
).toHaveText(switchedBranch, { timeout: 30_000 });
|
||||
});
|
||||
|
||||
test("restores the workspace before explicitly unarchiving each selected agent", async ({
|
||||
page,
|
||||
}) => {
|
||||
test("restores the workspace and all agents closed by its archive", async ({ page }) => {
|
||||
const { agents, worktree } = await createArchivedMissingWorktree("restore-agents", {
|
||||
agentCount: 2,
|
||||
keepAgentsArchived: true,
|
||||
@@ -330,13 +327,11 @@ test.describe("Worktree restore", () => {
|
||||
await expect(
|
||||
page.getByTestId(`workspace-tab-agent_${firstAgent.id}`).filter({ visible: true }).first(),
|
||||
).toHaveAttribute("aria-selected", "true");
|
||||
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.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();
|
||||
await expect(page.getByRole("button", { name: "Unarchive" })).toHaveCount(0, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
@@ -346,13 +341,8 @@ 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" })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await page.getByRole("button", { name: "Unarchive" }).click();
|
||||
await expect
|
||||
.poll(() => fetchAgentArchivedAt(client, secondAgent.id), { timeout: 30_000 })
|
||||
.toBeNull();
|
||||
await expect(page.getByRole("button", { name: "Unarchive" })).toHaveCount(0);
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeEditable();
|
||||
});
|
||||
|
||||
test("restores a branchless workspace so its archived agent can be used again", async ({
|
||||
@@ -410,12 +400,7 @@ test.describe("Worktree restore", () => {
|
||||
await expect
|
||||
.poll(() => existsSync(backingWorktree.workspaceDirectory), { timeout: 30_000 })
|
||||
.toBe(true);
|
||||
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(page.getByRole("button", { name: "Unarchive", exact: true })).toHaveCount(0);
|
||||
await expect.poll(() => fetchAgentArchivedAt(client, agent.id)).toBeNull();
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeEditable();
|
||||
await captureBranchlessRestoreStep(page, "03-agent-ready.png");
|
||||
@@ -506,7 +491,11 @@ test.describe("Worktree restore", () => {
|
||||
});
|
||||
|
||||
async function captureBranchlessRestoreStep(page: Page, filename: string): Promise<void> {
|
||||
const screenshotDirectory = path.join(tmpdir(), "paseo-branchless-restore-evidence");
|
||||
const screenshotDirectory = path.join(
|
||||
process.cwd(),
|
||||
".artifacts",
|
||||
"paseo-branchless-restore-evidence",
|
||||
);
|
||||
mkdirSync(screenshotDirectory, { recursive: true });
|
||||
await page.screenshot({ path: path.join(screenshotDirectory, filename), fullPage: true });
|
||||
}
|
||||
|
||||
@@ -32,8 +32,6 @@ export interface RewindCompletionGate {
|
||||
clearTimelineStreamCount(): void;
|
||||
release(): void;
|
||||
timelineStreamCount(): number;
|
||||
/** Timeline reads this page sent since the last clear. */
|
||||
timelineFetchCount(): number;
|
||||
waitForDelayedResponse(): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -189,20 +187,12 @@ 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) => {
|
||||
@@ -211,16 +201,7 @@ export async function holdRewindCompletion(
|
||||
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
ws.onMessage((message) => {
|
||||
const sessionMessage = getSessionMessage(message);
|
||||
if (
|
||||
sessionMessage?.type === "fetch_agent_timeline_request" &&
|
||||
sessionMessage.agentId === agentId
|
||||
) {
|
||||
timelineFetchCount += 1;
|
||||
}
|
||||
server.send(message);
|
||||
});
|
||||
ws.onMessage((message) => server.send(message));
|
||||
server.onMessage((message) => {
|
||||
const sessionMessage = getSessionMessage(message);
|
||||
const payload = sessionMessage ? getPayload(sessionMessage) : null;
|
||||
@@ -236,11 +217,11 @@ export async function holdRewindCompletion(
|
||||
}
|
||||
if (
|
||||
!released &&
|
||||
sessionMessage &&
|
||||
REWIND_COMPLETION_MESSAGES.has(String(sessionMessage.type)) &&
|
||||
(sessionMessage?.type === "agent.rewind.response" ||
|
||||
sessionMessage?.type === "agent.timeline.replacement") &&
|
||||
payload?.agentId === agentId
|
||||
) {
|
||||
// The rewind also signals completion and removes the rewound row/menu.
|
||||
// The replacement 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?.();
|
||||
@@ -253,7 +234,6 @@ export async function holdRewindCompletion(
|
||||
return {
|
||||
clearTimelineStreamCount() {
|
||||
timelineStreamCount = 0;
|
||||
timelineFetchCount = 0;
|
||||
},
|
||||
release() {
|
||||
released = true;
|
||||
@@ -262,7 +242,6 @@ export async function holdRewindCompletion(
|
||||
}
|
||||
},
|
||||
timelineStreamCount: () => timelineStreamCount,
|
||||
timelineFetchCount: () => timelineFetchCount,
|
||||
waitForDelayedResponse: () => delayedResponse,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -31,10 +31,13 @@ async function expectContextMenuAtPointer(
|
||||
menuTestID: string,
|
||||
anchor: ContextMenuAnchor,
|
||||
): Promise<void> {
|
||||
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);
|
||||
// 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 });
|
||||
}
|
||||
|
||||
export async function openWorkspaceContextMenu(page: Page, workspaceId: string): Promise<void> {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.15.4",
|
||||
"version": "0.16.0-beta.1",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -56,7 +56,6 @@ export function ChatFind({
|
||||
agentId,
|
||||
serverId,
|
||||
epoch,
|
||||
rewindSeq,
|
||||
items,
|
||||
viewportRef,
|
||||
revealLoadedMessage,
|
||||
@@ -96,8 +95,8 @@ export function ChatFind({
|
||||
);
|
||||
const state = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot);
|
||||
useEffect(() => {
|
||||
model.updateHistory(epoch, items, rewindSeq);
|
||||
}, [model, epoch, items, rewindSeq]);
|
||||
model.updateHistory(epoch, items);
|
||||
}, [model, epoch, items]);
|
||||
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, anchorSeq: seq },
|
||||
timelineCursor: { epoch: "epoch", 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, anchorSeq: index + 1 },
|
||||
timelineCursor: { epoch: "epoch", seq: 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, null);
|
||||
model.updateHistory("epoch", history);
|
||||
},
|
||||
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", [], null);
|
||||
model.updateHistory("epoch", []);
|
||||
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)], null);
|
||||
model.updateHistory("epoch", [message(1), message(2)]);
|
||||
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)], null);
|
||||
model.updateHistory("epoch", [message(1)]);
|
||||
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", [], null);
|
||||
model.updateHistory("epoch", []);
|
||||
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", [], null);
|
||||
model.updateHistory("epoch", []);
|
||||
model.open();
|
||||
model.setQuery("target");
|
||||
await expect.poll(() => model.getSnapshot().phase).toBe("error");
|
||||
@@ -207,160 +207,24 @@ it("reports a search result that outlived its epoch as a changed history", async
|
||||
clear() {},
|
||||
});
|
||||
try {
|
||||
model.updateHistory("epoch", [message(1)], null);
|
||||
model.updateHistory("epoch", [message(1)]);
|
||||
model.open();
|
||||
model.setQuery("target");
|
||||
await expect.poll(() => model.getSnapshot().phase).toBe("ready");
|
||||
model.updateHistory("epoch-2", [message(1)], null);
|
||||
model.updateHistory("epoch-2", [message(1)]);
|
||||
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)], null);
|
||||
model.updateHistory("epoch", [message(1)]);
|
||||
},
|
||||
async reveal() {
|
||||
return { count: 1, occurrence: 0 };
|
||||
@@ -368,7 +232,7 @@ it("can load a search location before the initial chat history has arrived", asy
|
||||
clear() {},
|
||||
});
|
||||
try {
|
||||
model.updateHistory(null, [], null);
|
||||
model.updateHistory(null, []);
|
||||
model.open();
|
||||
model.setQuery("target");
|
||||
await expect.poll(() => model.getSnapshot().phase).toBe("ready");
|
||||
@@ -396,7 +260,7 @@ it("reports the position across the whole chat and corrects it with rendered cou
|
||||
clear() {},
|
||||
});
|
||||
try {
|
||||
model.updateHistory("epoch", [message(1), message(2)], null);
|
||||
model.updateHistory("epoch", [message(1), message(2)]);
|
||||
model.open();
|
||||
model.setQuery("target");
|
||||
await expect.poll(() => model.getSnapshot().phase).toBe("ready");
|
||||
@@ -433,7 +297,7 @@ it("counts one match per message from a host that reports no counts", async () =
|
||||
clear() {},
|
||||
});
|
||||
try {
|
||||
model.updateHistory("epoch", [message(1), message(2)], null);
|
||||
model.updateHistory("epoch", [message(1), message(2)]);
|
||||
model.open();
|
||||
model.setQuery("target");
|
||||
await expect.poll(() => model.getSnapshot().phase).toBe("ready");
|
||||
|
||||
@@ -76,10 +76,6 @@ 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;
|
||||
@@ -117,7 +113,6 @@ 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 };
|
||||
@@ -149,7 +144,6 @@ export class ChatFindModel {
|
||||
});
|
||||
}
|
||||
private async search(signal: AbortSignal) {
|
||||
this.searchedRewindSeq = this.rewindSeq;
|
||||
try {
|
||||
let cursor: number | undefined;
|
||||
do {
|
||||
@@ -174,14 +168,15 @@ export class ChatFindModel {
|
||||
this.fail(error, signal);
|
||||
}
|
||||
}
|
||||
/** `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) {
|
||||
updateHistory(epoch: string | null, items: StreamItem[]) {
|
||||
if (
|
||||
this.resultEpoch !== null &&
|
||||
epoch !== null &&
|
||||
epoch !== this.resultEpoch &&
|
||||
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,8 +9,6 @@ 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, anchorSeq: seq },
|
||||
timelineCursor: { epoch: "epoch-1", 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, anchorSeq: 2 },
|
||||
timelineCursor: { epoch: "epoch-1", seq: 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, anchorSeq: 1 },
|
||||
timelineCursor: { epoch: "epoch-2", seq: 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, anchorSeq: 1 },
|
||||
timelineCursor: { epoch: "epoch-1", seq: 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, anchorSeq: 1 },
|
||||
timelineCursor: { epoch: "epoch-1", seq: 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, anchorSeq: 2 },
|
||||
timelineCursor: { epoch: "epoch-1", seq: 2 },
|
||||
};
|
||||
const revealLoadedMessage = vi.fn();
|
||||
revealLoadedMessage.mockReturnValue(false);
|
||||
|
||||
@@ -4,8 +4,12 @@ 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";
|
||||
|
||||
@@ -72,26 +76,19 @@ const view = () => within(container);
|
||||
|
||||
const preLine = { whiteSpace: "pre-line" } as const;
|
||||
|
||||
async function waitForLayout() {
|
||||
await act(async () => {
|
||||
for (let frame = 0; frame < 2; frame += 1) {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
describe("timeline disclosures hold their row", () => {
|
||||
it("routes user message show more and show less through the row's hold", async () => {
|
||||
it("routes user message show more and show less through the row's hold", () => {
|
||||
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,
|
||||
<MessagePreview>
|
||||
<span style={preLine}>{longText}</span>
|
||||
</MessagePreview>,
|
||||
<UserMessageExpansionContext.Provider value={expansion}>
|
||||
<MessagePreview itemId="message-1">
|
||||
<span style={preLine}>{longText}</span>
|
||||
</MessagePreview>
|
||||
</UserMessageExpansionContext.Provider>,
|
||||
);
|
||||
// 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();
|
||||
@@ -145,3 +142,63 @@ 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, anchorSeq: 42 } };
|
||||
const canonical = { ...live, timelineCursor: { epoch: "epoch-1", seq: 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, anchorSeq: 42 },
|
||||
timelineCursor: { epoch: "epoch-1", seq: 42 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -517,7 +517,7 @@ describe("timeline presentation", () => {
|
||||
messageId: "provider-message",
|
||||
clientMessageId: "client-message",
|
||||
turnId: "turn-1",
|
||||
timelineCursor: { epoch: "epoch", seq: 12, anchorSeq: 12 },
|
||||
timelineCursor: { epoch: "epoch", seq: 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, anchorSeq: 42 },
|
||||
timelineCursor: { epoch: "timeline-1", seq: 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, anchorSeq: 42 },
|
||||
timelineCursor: { epoch: "timeline-1", seq: 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, anchorSeq: 42 },
|
||||
timelineCursor: { epoch: "timeline-1", seq: 42 },
|
||||
};
|
||||
|
||||
expect(
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
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;
|
||||
}
|
||||
import type { StreamItem, TimelinePosition } from "@/types/stream";
|
||||
|
||||
export type AssistantTurnForkBoundary =
|
||||
| { boundaryCursor: TimelineBoundaryCursor; boundaryMessageId?: string }
|
||||
| { boundaryCursor: TimelinePosition; boundaryMessageId?: string }
|
||||
| { boundaryCursor?: undefined; boundaryMessageId: string };
|
||||
|
||||
export function resolveAssistantTurnForkBoundary(input: {
|
||||
@@ -21,7 +15,7 @@ export function resolveAssistantTurnForkBoundary(input: {
|
||||
}
|
||||
if (input.supportsTimelineCursor && item.timelineCursor) {
|
||||
return {
|
||||
boundaryCursor: { epoch: item.timelineCursor.epoch, seq: item.timelineCursor.seq },
|
||||
boundaryCursor: item.timelineCursor,
|
||||
...(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, anchorSeq: 7 };
|
||||
const cursor = { epoch: "epoch-1", seq: 7 };
|
||||
const placed = [{ ...user("u1"), timelineCursor: cursor }, shell("c1"), reply("r1")];
|
||||
const canonical = collapse(placed, { previousSummaryRowsByHostId: live.summaryRowsByHostId });
|
||||
expect(canonical.summaryRowsByHostId.get("u1")).not.toBe(live.summaryRowsByHostId.get("u1"));
|
||||
|
||||
@@ -66,6 +66,10 @@ 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";
|
||||
@@ -382,6 +386,7 @@ 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());
|
||||
|
||||
@@ -406,10 +411,6 @@ 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,
|
||||
);
|
||||
@@ -715,6 +716,7 @@ 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}
|
||||
@@ -1164,7 +1166,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
agentId={agentId}
|
||||
serverId={resolvedServerId}
|
||||
epoch={timelineEpoch}
|
||||
rewindSeq={timelineRewindSeq}
|
||||
items={findItems}
|
||||
viewportRef={viewportRef}
|
||||
revealLoadedMessage={revealLoadedMessage}
|
||||
@@ -1174,32 +1175,34 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
<AssistantSelectionCopySurface style={stylesheet.container}>
|
||||
<MessageOuterSpacingProvider disableOuterSpacing>
|
||||
<ToolCallExpansionContext.Provider value={toolCallExpansion}>
|
||||
{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 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>
|
||||
</ToolCallExpansionContext.Provider>
|
||||
</MessageOuterSpacingProvider>
|
||||
<ChatOutlineRail
|
||||
|
||||
@@ -6,7 +6,6 @@ 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";
|
||||
|
||||
@@ -19,7 +18,6 @@ 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);
|
||||
|
||||
@@ -28,15 +26,12 @@ export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCallout
|
||||
setIsUnarchiving(true);
|
||||
setUnarchiveError(null);
|
||||
try {
|
||||
const refreshed = await client.refreshAgent(agentId);
|
||||
if (refreshed.historyDiverged) {
|
||||
toast.show(t("workspace.tabs.toasts.reloadedAgentHistoryDiverged"), { variant: "warning" });
|
||||
}
|
||||
await client.refreshAgent(agentId);
|
||||
} catch (error) {
|
||||
setUnarchiveError(toErrorMessage(error));
|
||||
setIsUnarchiving(false);
|
||||
}
|
||||
}, [client, isConnected, isUnarchiving, agentId, toast, t]);
|
||||
}, [client, isConnected, isUnarchiving, agentId]);
|
||||
|
||||
return (
|
||||
<KeyboardTranslateView style={styles.container}>
|
||||
|
||||
@@ -92,6 +92,8 @@ 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;
|
||||
@@ -391,6 +393,7 @@ function UserMessageImagePill({ image, onOpen, accessibilityLabel }: UserMessage
|
||||
const MESSAGE_TEXT_DATASET = { messageText: "true" };
|
||||
|
||||
export const UserMessage = memo(function UserMessage({
|
||||
itemId,
|
||||
sender,
|
||||
serverId,
|
||||
agentId,
|
||||
@@ -480,7 +483,7 @@ export const UserMessage = memo(function UserMessage({
|
||||
>
|
||||
{sender ? <MessageSender sender={sender} serverId={serverId} /> : null}
|
||||
<View style={userMessageStylesheet.bubble}>
|
||||
<MessagePreview>
|
||||
<MessagePreview itemId={itemId}>
|
||||
{hasImages ? (
|
||||
<View style={imagePreviewContainerStyle}>
|
||||
{images.map((image) => (
|
||||
@@ -922,13 +925,19 @@ 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",
|
||||
@@ -984,15 +993,12 @@ 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,
|
||||
@@ -1598,7 +1604,6 @@ interface ExpandableBadgeWebShimmerOverlayProps {
|
||||
secondaryLabel?: string;
|
||||
shimmerLabelTextStyle: StyleProp<TextStyle>;
|
||||
shimmerSecondaryTextStyle: StyleProp<TextStyle>;
|
||||
showOpenFileButton: boolean;
|
||||
}
|
||||
|
||||
function ExpandableBadgeWebShimmerOverlay({
|
||||
@@ -1606,7 +1611,6 @@ function ExpandableBadgeWebShimmerOverlay({
|
||||
secondaryLabel,
|
||||
shimmerLabelTextStyle,
|
||||
shimmerSecondaryTextStyle,
|
||||
showOpenFileButton,
|
||||
}: ExpandableBadgeWebShimmerOverlayProps) {
|
||||
return (
|
||||
<View style={expandableBadgeStylesheet.shimmerOverlay} pointerEvents="none">
|
||||
@@ -1618,14 +1622,7 @@ function ExpandableBadgeWebShimmerOverlay({
|
||||
{secondaryLabel}
|
||||
</Text>
|
||||
) : null}
|
||||
{showOpenFileButton ? (
|
||||
<View style={expandableBadgeStylesheet.openFileButton}>
|
||||
<View style={expandableBadgeStylesheet.openFileButtonPlaceholderIcon} />
|
||||
</View>
|
||||
) : null}
|
||||
{!secondaryLabel && !showOpenFileButton ? (
|
||||
<View style={expandableBadgeStylesheet.spacer} />
|
||||
) : null}
|
||||
{!secondaryLabel ? <View style={expandableBadgeStylesheet.spacer} /> : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1649,11 +1646,6 @@ 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({
|
||||
@@ -1675,13 +1667,7 @@ function ExpandableBadgeLabelRow({
|
||||
onLabelRowLayout,
|
||||
onLabelLayout,
|
||||
onSecondaryLayout,
|
||||
showOpenFileButton,
|
||||
isOpenFileHovered,
|
||||
onOpenFilePress,
|
||||
onOpenFileHoverIn,
|
||||
onOpenFileHoverOut,
|
||||
}: ExpandableBadgeLabelRowProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<View
|
||||
style={expandableBadgeStylesheet.labelRow}
|
||||
@@ -1700,30 +1686,12 @@ 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 ? (
|
||||
@@ -1928,6 +1896,7 @@ 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);
|
||||
@@ -2190,13 +2159,13 @@ export const ExpandableBadge = memo(function ExpandableBadge({
|
||||
onPointerEnter={isWeb ? handleHoverIn : undefined}
|
||||
onPointerLeave={isWeb ? handleHoverOut : undefined}
|
||||
>
|
||||
<Pressable
|
||||
{...pressHandlers}
|
||||
disabled={!isInteractive}
|
||||
accessibilityState={accessibilityState}
|
||||
style={pressableStyle}
|
||||
>
|
||||
<View style={expandableBadgeStylesheet.headerRow}>
|
||||
<View style={[pressableStyle, expandableBadgeStylesheet.headerRow]}>
|
||||
<Pressable
|
||||
{...pressHandlers}
|
||||
disabled={!isInteractive}
|
||||
accessibilityState={accessibilityState}
|
||||
style={expandableBadgeStylesheet.disclosureButton}
|
||||
>
|
||||
<View style={expandableBadgeStylesheet.iconBadge}>{iconSlotNode}</View>
|
||||
<ExpandableBadgeLabelRow
|
||||
label={label}
|
||||
@@ -2217,14 +2186,26 @@ export const ExpandableBadge = memo(function ExpandableBadge({
|
||||
onLabelRowLayout={handleLabelRowLayout}
|
||||
onLabelLayout={handleLabelLayout}
|
||||
onSecondaryLayout={handleSecondaryLayout}
|
||||
showOpenFileButton={Boolean(onOpenFile && isHovered)}
|
||||
isOpenFileHovered={isOpenFileHovered}
|
||||
onOpenFilePress={handleOpenFilePress}
|
||||
onOpenFileHoverIn={handleOpenFileHoverIn}
|
||||
onOpenFileHoverOut={handleOpenFileHoverOut}
|
||||
/>
|
||||
</View>
|
||||
</Pressable>
|
||||
</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>
|
||||
{detailContent ? (
|
||||
<Pressable
|
||||
ref={detailWrapperRef}
|
||||
|
||||
@@ -9,7 +9,6 @@ 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;
|
||||
@@ -36,16 +35,7 @@ export function useRewindAgentMutation(input: UseRewindAgentMutationInput): {
|
||||
throw new Error(t("common.errors.daemonClientUnavailable"));
|
||||
}
|
||||
await input.client.rewindAgent(input.agentId, input.messageId, mode);
|
||||
// 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) {
|
||||
if (mode !== "files") {
|
||||
const cursor = input.serverId
|
||||
? useSessionStore
|
||||
.getState()
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
useEffect,
|
||||
useRef,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
type MutableRefObject,
|
||||
type Ref,
|
||||
type ComponentProps,
|
||||
@@ -35,7 +36,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 { WorkspaceRenameModal } from "@/components/workspace-rename-modal";
|
||||
import { useSidebarWorkspaceRename } from "@/workspace-rename";
|
||||
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";
|
||||
@@ -287,6 +288,8 @@ 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;
|
||||
@@ -1075,6 +1078,7 @@ function WorkspaceRowInner({
|
||||
onCopyBranchName,
|
||||
onCopyPath,
|
||||
onRename,
|
||||
titleEditor = null,
|
||||
onMarkAsRead,
|
||||
onMarkAsUnread,
|
||||
archiveShortcutKeys,
|
||||
@@ -1116,9 +1120,14 @@ function WorkspaceRowInner({
|
||||
}, [interaction]);
|
||||
|
||||
const accessibilityState = useMemo(() => ({ selected }), [selected]);
|
||||
const isEditingTitle = titleEditor !== null;
|
||||
|
||||
return (
|
||||
<SidebarWorkspaceRowFrame workspace={workspace} isDragging={isDragging}>
|
||||
<SidebarWorkspaceRowFrame
|
||||
workspace={workspace}
|
||||
isDragging={isDragging}
|
||||
isEditingTitle={isEditingTitle}
|
||||
>
|
||||
{({ isHovered, contextMenuOpen, onContextMenuOpenChange, hoverHandlers }) => {
|
||||
const isDesktop = !isTouchPlatform;
|
||||
const serviceSummary = isDesktop ? selectWorkspaceServiceSummary(workspace.scripts) : null;
|
||||
@@ -1132,7 +1141,7 @@ function WorkspaceRowInner({
|
||||
return (
|
||||
<View
|
||||
{...dragAttributes}
|
||||
{...dragHandleProps?.listeners}
|
||||
{...(isEditingTitle ? null : dragHandleProps?.listeners)}
|
||||
ref={dragHandleProps?.setActivatorNodeRef as unknown as Ref<View>}
|
||||
style={styles.workspaceRowContainer}
|
||||
{...hoverHandlers}
|
||||
@@ -1158,15 +1167,16 @@ function WorkspaceRowInner({
|
||||
onTogglePin={onTogglePin}
|
||||
openInFileManagerPath={workspace.workspaceDirectory}
|
||||
disabled={isArchiving}
|
||||
enabled={!isEditingTitle}
|
||||
aria-selected={selected}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={accessibilityState}
|
||||
style={workspaceRowStyle}
|
||||
highlightStyle={styles.workspaceRowPressed}
|
||||
onPressIn={handleWorkspacePressIn}
|
||||
onTouchMove={interaction.handleTouchMove}
|
||||
onPressOut={handleWorkspacePressOut}
|
||||
onPress={handlePress}
|
||||
onPressIn={isEditingTitle ? undefined : handleWorkspacePressIn}
|
||||
onTouchMove={isEditingTitle ? undefined : interaction.handleTouchMove}
|
||||
onPressOut={isEditingTitle ? undefined : handleWorkspacePressOut}
|
||||
onPress={isEditingTitle ? undefined : handlePress}
|
||||
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
|
||||
>
|
||||
<SidebarWorkspaceRowContent
|
||||
@@ -1182,6 +1192,7 @@ function WorkspaceRowInner({
|
||||
shortcutNumber={shortcutNumber}
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace}
|
||||
titleEditor={titleEditor}
|
||||
>
|
||||
<WorkspaceRowRightGroup
|
||||
workspace={workspace}
|
||||
@@ -1251,7 +1262,7 @@ function WorkspaceRowWithMenu({
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const [isHidingWorkspace, setIsHidingWorkspace] = useState(false);
|
||||
const [isRenameOpen, setIsRenameOpen] = useState(false);
|
||||
const rename = useSidebarWorkspaceRename(workspace);
|
||||
const isArchiving = workspace.archivingAt !== null || isHidingWorkspace;
|
||||
const redirectAfterArchive = useCallback(() => {
|
||||
redirectIfArchivingActiveWorkspace({
|
||||
@@ -1287,14 +1298,6 @@ 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);
|
||||
@@ -1352,7 +1355,8 @@ function WorkspaceRowWithMenu({
|
||||
onArchive={handleArchive}
|
||||
onCopyBranchName={canCopyBranchName ? handleCopyBranchName : undefined}
|
||||
onCopyPath={handleCopyPath}
|
||||
onRename={handleOpenRename}
|
||||
onRename={rename.begin}
|
||||
titleEditor={rename.titleEditor}
|
||||
onMarkAsRead={hasClearableAttention ? handleMarkAsRead : undefined}
|
||||
onMarkAsUnread={canMarkUnread ? handleMarkAsUnread : undefined}
|
||||
archiveShortcutKeys={selected ? archiveShortcutKeys : null}
|
||||
@@ -1360,12 +1364,7 @@ function WorkspaceRowWithMenu({
|
||||
onTogglePin={onTogglePin}
|
||||
reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace}
|
||||
/>
|
||||
<WorkspaceRenameModal
|
||||
visible={isRenameOpen}
|
||||
workspace={workspace}
|
||||
onClose={handleCloseRename}
|
||||
testID={`sidebar-workspace-rename-modal-${workspace.workspaceKey}`}
|
||||
/>
|
||||
{rename.modal}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ import {
|
||||
CircleX,
|
||||
} from "lucide-react-native";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { WorkspaceRenameModal } from "@/components/workspace-rename-modal";
|
||||
import { useSidebarWorkspaceRename } from "@/workspace-rename";
|
||||
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 [isRenameOpen, setIsRenameOpen] = useState(false);
|
||||
const rename = useSidebarWorkspaceRename(workspace);
|
||||
const isArchiving = workspace.archivingAt !== null || isHidingWorkspace;
|
||||
|
||||
const redirectAfterArchive = useCallback(() => {
|
||||
@@ -674,8 +674,6 @@ 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);
|
||||
@@ -728,7 +726,8 @@ function StatusWorkspaceRowWithMenu({
|
||||
onArchive={handleArchive}
|
||||
onCopyBranchName={workspace.projectKind === "git" ? handleCopyBranchName : undefined}
|
||||
onCopyPath={handleCopyPath}
|
||||
onRename={handleOpenRename}
|
||||
onRename={rename.begin}
|
||||
titleEditor={rename.titleEditor}
|
||||
onMarkAsRead={hasClearableAttention ? handleMarkAsRead : undefined}
|
||||
onMarkAsUnread={canMarkUnread ? handleMarkAsUnread : undefined}
|
||||
archiveShortcutKeys={selected ? archiveShortcutKeys : null}
|
||||
@@ -740,12 +739,7 @@ function StatusWorkspaceRowWithMenu({
|
||||
isDragging={isDragging}
|
||||
dragHandleProps={dragHandleProps}
|
||||
/>
|
||||
<WorkspaceRenameModal
|
||||
visible={isRenameOpen}
|
||||
workspace={workspace}
|
||||
onClose={handleCloseRename}
|
||||
testID={`sidebar-workspace-rename-modal-${workspace.workspaceKey}`}
|
||||
/>
|
||||
{rename.modal}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -767,6 +761,8 @@ 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;
|
||||
@@ -814,6 +810,7 @@ function StatusWorkspaceRowInnerContent({
|
||||
onCopyBranchName,
|
||||
onCopyPath,
|
||||
onRename,
|
||||
titleEditor = null,
|
||||
onMarkAsRead,
|
||||
onMarkAsUnread,
|
||||
archiveShortcutKeys,
|
||||
@@ -842,6 +839,7 @@ 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;
|
||||
@@ -866,7 +864,11 @@ function StatusWorkspaceRowInnerContent({
|
||||
}, [endDragPress]);
|
||||
|
||||
return (
|
||||
<SidebarWorkspaceRowFrame workspace={workspace} isDragging={isDragging}>
|
||||
<SidebarWorkspaceRowFrame
|
||||
workspace={workspace}
|
||||
isDragging={isDragging}
|
||||
isEditingTitle={isEditingTitle}
|
||||
>
|
||||
{({ isHovered, contextMenuOpen, onContextMenuOpenChange, hoverHandlers }) => {
|
||||
const showShortcut = showShortcutBadge && shortcutNumber !== null;
|
||||
const {
|
||||
@@ -895,7 +897,7 @@ function StatusWorkspaceRowInnerContent({
|
||||
return (
|
||||
<View
|
||||
{...dragAttributes}
|
||||
{...dragHandleProps?.listeners}
|
||||
{...(isEditingTitle ? null : dragHandleProps?.listeners)}
|
||||
ref={dragHandleProps?.setActivatorNodeRef as unknown as Ref<View>}
|
||||
style={styles.workspaceRowContainer}
|
||||
{...hoverHandlers}
|
||||
@@ -922,14 +924,15 @@ function StatusWorkspaceRowInnerContent({
|
||||
onTogglePin={onTogglePin}
|
||||
openInFileManagerPath={workspace.workspaceDirectory}
|
||||
disabled={isArchiving}
|
||||
enabled={!isEditingTitle}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={accessibilityState}
|
||||
style={workspaceRowStyle}
|
||||
highlightStyle={styles.workspaceRowPressed}
|
||||
onPressIn={handlePressIn}
|
||||
onTouchMove={moveDragPress}
|
||||
onPressOut={handlePressOut}
|
||||
onPress={handlePress}
|
||||
onPressIn={isEditingTitle ? undefined : handlePressIn}
|
||||
onTouchMove={isEditingTitle ? undefined : moveDragPress}
|
||||
onPressOut={isEditingTitle ? undefined : handlePressOut}
|
||||
onPress={isEditingTitle ? undefined : handlePress}
|
||||
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
|
||||
>
|
||||
<SidebarWorkspaceRowContent
|
||||
@@ -944,6 +947,7 @@ function StatusWorkspaceRowInnerContent({
|
||||
shortcutNumber={shortcutNumber}
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace}
|
||||
titleEditor={titleEditor}
|
||||
>
|
||||
{renderSlot ? (
|
||||
<StatusWorkspaceActionSlot
|
||||
|
||||
@@ -46,10 +46,13 @@ 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;
|
||||
@@ -77,10 +80,10 @@ export function SidebarWorkspaceRowFrame({
|
||||
workspace={workspace}
|
||||
prHint={workspace.prHint}
|
||||
isDragging={isDragging}
|
||||
disabled={contextMenuOpen}
|
||||
disabled={contextMenuOpen || isEditingTitle}
|
||||
>
|
||||
{children({
|
||||
isHovered: isHovered && !contextMenuOpen && !isDragging,
|
||||
isHovered: isHovered && !contextMenuOpen && !isDragging && !isEditingTitle,
|
||||
contextMenuOpen,
|
||||
onContextMenuOpenChange: handleContextMenuOpenChange,
|
||||
hoverHandlers,
|
||||
@@ -102,6 +105,7 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
|
||||
shortcutNumber = null,
|
||||
showShortcutBadge = false,
|
||||
reserveIdleStatusIndicatorSpace = true,
|
||||
titleEditor = null,
|
||||
children,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
@@ -119,6 +123,8 @@ 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 {
|
||||
@@ -160,13 +166,17 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
|
||||
)}
|
||||
<View style={styles.workspaceContentColumn}>
|
||||
<View style={styles.workspaceTitleRow}>
|
||||
<Text
|
||||
style={workspaceBranchTextStyle}
|
||||
numberOfLines={1}
|
||||
testID={`sidebar-workspace-title-${workspace.workspaceKey}`}
|
||||
>
|
||||
{workspaceLabel}
|
||||
</Text>
|
||||
{titleEditor ? (
|
||||
<View style={styles.workspaceTitleEditorSlot}>{titleEditor}</View>
|
||||
) : (
|
||||
<Text
|
||||
style={workspaceBranchTextStyle}
|
||||
numberOfLines={1}
|
||||
testID={`sidebar-workspace-title-${workspace.workspaceKey}`}
|
||||
>
|
||||
{workspaceLabel}
|
||||
</Text>
|
||||
)}
|
||||
<View style={sidebarWorkspaceRowStyles.rowRight}>{children}</View>
|
||||
</View>
|
||||
<WorkspaceMetaRow
|
||||
@@ -557,6 +567,11 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
workspaceTitleEditorSlot: {
|
||||
height: TITLE_LINE_HEIGHT,
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
workspaceBranchTextCreating: {
|
||||
opacity: 0.92,
|
||||
},
|
||||
|
||||
@@ -1,524 +0,0 @@
|
||||
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,
|
||||
},
|
||||
}));
|
||||
@@ -165,7 +165,9 @@ export function ContextMenuTrigger({
|
||||
|
||||
const handleContextMenu = useCallback(
|
||||
(event: unknown) => {
|
||||
if (isNative) return;
|
||||
// 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 (typeof event === "object" && event !== null) {
|
||||
const preventDefault = Reflect.get(event, "preventDefault");
|
||||
const stopPropagation = Reflect.get(event, "stopPropagation");
|
||||
@@ -175,7 +177,7 @@ export function ContextMenuTrigger({
|
||||
onContextMenu?.(event);
|
||||
openAtEvent(event);
|
||||
},
|
||||
[onContextMenu, openAtEvent],
|
||||
[onContextMenu, openAtEvent, shouldEnableOnThisPlatform],
|
||||
);
|
||||
|
||||
const resolveDynamicStyle = useCallback(
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
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 };
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
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>;
|
||||
@@ -0,0 +1,81 @@
|
||||
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, useMemo, type ReactNode } from "react";
|
||||
import { useState, useCallback, useLayoutEffect, useMemo, useRef, 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,6 +10,7 @@ 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.
|
||||
@@ -21,24 +22,35 @@ const previewStyle: ViewStyle = {
|
||||
overflow: isNative ? "scroll" : "hidden",
|
||||
};
|
||||
|
||||
export function MessagePreview({ children }: { children: ReactNode }) {
|
||||
/** Caps a long message at the preview height, with a control to read the rest. */
|
||||
export function MessagePreview({ itemId, children }: { itemId: string; children: ReactNode }) {
|
||||
const { t } = useTranslation();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const { expanded, toggle: toggleExpanded } = useUserMessageExpanded(itemId);
|
||||
const bodyRef = useRef<View>(null);
|
||||
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) => setOverflowing(event.nativeEvent.layout.height > PREVIEW_HEIGHT),
|
||||
[],
|
||||
(event: LayoutChangeEvent) => applyBodyHeight(event.nativeEvent.layout.height),
|
||||
[applyBodyHeight],
|
||||
);
|
||||
const hold = useDisclosureHold();
|
||||
const toggle = useCallback(
|
||||
() => hold(() => setExpanded((value) => !value), TRIGGER_BELOW_CONTENT),
|
||||
[hold],
|
||||
() => hold(toggleExpanded, TRIGGER_BELOW_CONTENT),
|
||||
[hold, toggleExpanded],
|
||||
);
|
||||
const accessibilityState = useMemo(() => ({ expanded }), [expanded]);
|
||||
return (
|
||||
<>
|
||||
<View style={!expanded && previewStyle}>
|
||||
<View style={styles.body} onLayout={measure}>
|
||||
<View ref={bodyRef} style={styles.body} onLayout={measure}>
|
||||
{children}
|
||||
</View>
|
||||
{overflowing && !expanded ? (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { WorkspaceRenameModal } from "@/components/workspace-rename-modal";
|
||||
import { WorkspaceRenameModal } from "@/workspace-rename";
|
||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||
import type { KeyboardActionId } from "@/keyboard/keyboard-action-dispatcher";
|
||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
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,16 +385,6 @@ 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,
|
||||
@@ -405,7 +395,6 @@ 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, anchorSeq: 12 },
|
||||
timelineCursor: { epoch: "epoch-1", seq: 12 },
|
||||
timestamp: new Date("2026-07-27T10:00:00.000Z"),
|
||||
},
|
||||
],
|
||||
|
||||
@@ -722,8 +722,6 @@ export const ar: TranslationResources = {
|
||||
resumeCommandUnavailable: "أمر الاستئناف غير متوفر",
|
||||
reloadingAgent: "وكيل إعادة التحميل...",
|
||||
reloadedAgent: "وكيل إعادة تحميل",
|
||||
reloadedAgentHistoryDiverged:
|
||||
"تمت إعادة تحميل الوكيل، لكن سجل المزوّد لا يطابق هذه المحادثة، لذا لم تتم مزامنته",
|
||||
failedToReloadAgent: "فشل في إعادة تحميل الوكيل",
|
||||
failedToCloseAgent: "فشل في إغلاق الوكيل",
|
||||
},
|
||||
|
||||
@@ -719,8 +719,6 @@ 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",
|
||||
},
|
||||
|
||||
@@ -729,8 +729,6 @@ 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",
|
||||
},
|
||||
|
||||
@@ -729,8 +729,6 @@ 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",
|
||||
},
|
||||
|
||||
@@ -729,8 +729,6 @@ export const ja: TranslationResources = {
|
||||
resumeCommandUnavailable: "再開コマンドが利用できません",
|
||||
reloadingAgent: "エージェントを再読み込み中...",
|
||||
reloadedAgent: "エージェントを再読み込みしました",
|
||||
reloadedAgentHistoryDiverged:
|
||||
"エージェントを再読み込みしましたが、プロバイダーの履歴がこのチャットと一致しないため同期しませんでした",
|
||||
failedToReloadAgent: "エージェントの再読み込みに失敗しました",
|
||||
failedToCloseAgent: "エージェントを閉じられませんでした",
|
||||
},
|
||||
|
||||
@@ -724,8 +724,6 @@ export const ko: TranslationResources = {
|
||||
resumeCommandUnavailable: "재개 명령을 사용할 수 없습니다",
|
||||
reloadingAgent: "에이전트 다시 로드 중...",
|
||||
reloadedAgent: "에이전트를 다시 로드했습니다",
|
||||
reloadedAgentHistoryDiverged:
|
||||
"에이전트를 다시 로드했지만 제공자 기록이 이 채팅과 일치하지 않아 동기화하지 않았습니다",
|
||||
failedToReloadAgent: "에이전트를 다시 로드하지 못했습니다",
|
||||
failedToCloseAgent: "에이전트를 닫지 못했습니다",
|
||||
},
|
||||
|
||||
@@ -727,8 +727,6 @@ 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",
|
||||
},
|
||||
|
||||
@@ -729,8 +729,6 @@ export const ru: TranslationResources = {
|
||||
resumeCommandUnavailable: "Команда возобновления недоступна",
|
||||
reloadingAgent: "Перезагрузка агента...",
|
||||
reloadedAgent: "Агент перезагружен",
|
||||
reloadedAgentHistoryDiverged:
|
||||
"Агент перезагружен, но история провайдера не совпадает с этим чатом, поэтому она не синхронизирована",
|
||||
failedToReloadAgent: "Не удалось перезагрузить агента",
|
||||
failedToCloseAgent: "Не удалось закрыть агента",
|
||||
},
|
||||
|
||||
@@ -722,8 +722,6 @@ export const zhCN: TranslationResources = {
|
||||
resumeCommandUnavailable: "恢复命令不可用",
|
||||
reloadingAgent: "正在重新加载 Agent...",
|
||||
reloadedAgent: "已重新加载 Agent",
|
||||
reloadedAgentHistoryDiverged:
|
||||
"已重新加载 Agent,但提供方的历史记录与此对话不一致,因此未同步",
|
||||
failedToReloadAgent: "重新加载 Agent 失败",
|
||||
failedToCloseAgent: "关闭 Agent 失败",
|
||||
},
|
||||
|
||||
@@ -91,7 +91,7 @@ describe("AgentDirectoryReplica", () => {
|
||||
id: "reply",
|
||||
text: "server history",
|
||||
timestamp: new Date(),
|
||||
timelineCursor: { epoch: "e", seq: 1, anchorSeq: 1 },
|
||||
timelineCursor: { epoch: "e", seq: 1 },
|
||||
},
|
||||
],
|
||||
head: [],
|
||||
|
||||
@@ -244,7 +244,6 @@ 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;
|
||||
|
||||
@@ -2858,19 +2857,12 @@ 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;
|
||||
// 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;
|
||||
}
|
||||
const page = await directory.fetchTimeline(agentId, request);
|
||||
if (owner && useSessionStore.getState().sessions[serverId]?.viewedTimelineSync === owner) {
|
||||
owner.flushStreamAgent(agentId);
|
||||
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`);
|
||||
}
|
||||
owner.applyTimelineResponse(page);
|
||||
}
|
||||
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, anchorSeq: 12 },
|
||||
timelineCursor: { epoch: "epoch-1", seq: 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, anchorSeq: 12 },
|
||||
timelineCursor: { epoch: "epoch-1", seq: 12 },
|
||||
};
|
||||
writer.commitTimeline(SERVER_ID, "agent-1", {
|
||||
agentId: "agent-1",
|
||||
@@ -834,32 +834,6 @@ 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);
|
||||
@@ -1223,7 +1197,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, anchorSeq: 12 },
|
||||
timelineCursor: { epoch: "epoch-1", seq: 12 },
|
||||
payload: {
|
||||
source: "agent",
|
||||
data: {
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
isUnreconciledLocalUserMessage,
|
||||
type AgentToolCallData,
|
||||
type StreamItem,
|
||||
type TimelinePosition,
|
||||
} from "@/types/stream";
|
||||
import type { UnsettledTurn } from "@/timeline/turn-liveness";
|
||||
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||
@@ -80,15 +79,7 @@ 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(),
|
||||
@@ -422,10 +413,6 @@ 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),
|
||||
@@ -540,20 +527,11 @@ 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,
|
||||
...anchoredPosition(item.timelineCursor),
|
||||
...(item.timelineCursor ? { timelineCursor: item.timelineCursor } : {}),
|
||||
...(item.turnId ? { turnId: item.turnId } : {}),
|
||||
timestamp: new Date(item.timestamp),
|
||||
kind: item.kind,
|
||||
@@ -572,7 +550,7 @@ function deserializeBuiltinTimelineItem(
|
||||
): StreamItem {
|
||||
const base = {
|
||||
id: item.id,
|
||||
...anchoredPosition(item.timelineCursor),
|
||||
...(item.timelineCursor ? { timelineCursor: item.timelineCursor } : {}),
|
||||
...(item.turnId ? { turnId: item.turnId } : {}),
|
||||
timestamp: new Date(item.timestamp),
|
||||
};
|
||||
|
||||
@@ -2215,10 +2215,11 @@ function WorkspaceScreenContent({
|
||||
|
||||
toast.show(t("workspace.tabs.toasts.reloadingAgent"), { durationMs: null });
|
||||
try {
|
||||
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.
|
||||
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 sessionState = useSessionStore.getState().sessions[normalizedServerId];
|
||||
const currentCursor = sessionState?.agentTimelineCursor.get(agentId);
|
||||
await getHostRuntimeStore().fetchAgentTimeline(normalizedServerId, agentId, {
|
||||
@@ -2227,13 +2228,7 @@ function WorkspaceScreenContent({
|
||||
? { cursor: { epoch: currentCursor.epoch, seq: currentCursor.endSeq } }
|
||||
: {}),
|
||||
});
|
||||
if (refreshed.historyDiverged) {
|
||||
toast.show(t("workspace.tabs.toasts.reloadedAgentHistoryDiverged"), {
|
||||
variant: "warning",
|
||||
});
|
||||
} else {
|
||||
toast.show(t("workspace.tabs.toasts.reloadedAgent"), { variant: "success" });
|
||||
}
|
||||
toast.show(t("workspace.tabs.toasts.reloadedAgent"), { variant: "success" });
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t("workspace.tabs.toasts.failedToReloadAgent"),
|
||||
|
||||
@@ -316,12 +316,6 @@ export interface AgentTimelineCursorState {
|
||||
}>;
|
||||
}
|
||||
|
||||
/** The latest conversation rewind applied to an agent's history, by its sequence number. */
|
||||
export interface AgentTimelineRewind {
|
||||
epoch: string;
|
||||
seq: number;
|
||||
}
|
||||
|
||||
export type AgentTimelineState =
|
||||
| { status: "cold" }
|
||||
| { status: "painted"; items: StreamItem[] }
|
||||
@@ -496,7 +490,6 @@ export interface SessionState {
|
||||
agentTimelineUnsettledTurn: Map<string, UnsettledTurn>;
|
||||
agentTimelineHasOlder: Map<string, boolean>;
|
||||
agentTimelineHasNewer: Map<string, boolean>;
|
||||
agentTimelineLatestRewind: Map<string, AgentTimelineRewind>;
|
||||
agentTimelineOlderFetchInFlight: Map<string, boolean>;
|
||||
historySyncGeneration: number;
|
||||
agentHistorySyncGeneration: Map<string, number>;
|
||||
@@ -616,12 +609,6 @@ interface SessionStoreActions {
|
||||
serverId: string,
|
||||
state: Map<string, boolean> | ((prev: Map<string, boolean>) => Map<string, boolean>),
|
||||
) => void;
|
||||
/** Records a rewind applied to the agent's history; an older one of the same history is ignored. */
|
||||
recordAgentTimelineRewind: (
|
||||
serverId: string,
|
||||
agentId: string,
|
||||
rewind: AgentTimelineRewind,
|
||||
) => void;
|
||||
setAgentTimelineOlderFetchInFlight: (
|
||||
serverId: string,
|
||||
state: Map<string, boolean> | ((prev: Map<string, boolean>) => Map<string, boolean>),
|
||||
@@ -758,7 +745,6 @@ function createInitialSessionState(
|
||||
agentTimelineUnsettledTurn: new Map(),
|
||||
agentTimelineHasOlder: new Map(),
|
||||
agentTimelineHasNewer: new Map(),
|
||||
agentTimelineLatestRewind: new Map(),
|
||||
agentTimelineOlderFetchInFlight: new Map(),
|
||||
historySyncGeneration: 0,
|
||||
agentHistorySyncGeneration: new Map(),
|
||||
@@ -1400,24 +1386,6 @@ export const useSessionStore = create<SessionStore>()(
|
||||
});
|
||||
},
|
||||
|
||||
recordAgentTimelineRewind: (serverId, agentId, rewind) => {
|
||||
set((prev) => {
|
||||
const session = prev.sessions[serverId];
|
||||
if (!session) return prev;
|
||||
const current = session.agentTimelineLatestRewind.get(agentId);
|
||||
if (current && current.epoch === rewind.epoch && current.seq >= rewind.seq) return prev;
|
||||
const next = new Map(session.agentTimelineLatestRewind);
|
||||
next.set(agentId, rewind);
|
||||
return {
|
||||
...prev,
|
||||
sessions: {
|
||||
...prev.sessions,
|
||||
[serverId]: { ...session, agentTimelineLatestRewind: next },
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
setAgentTimelineOlderFetchInFlight: (serverId, state) => {
|
||||
set((prev) => {
|
||||
const session = prev.sessions[serverId];
|
||||
|
||||
@@ -36,7 +36,7 @@ function item(id: string, text: string, seq: number): StreamItem {
|
||||
id,
|
||||
text,
|
||||
timestamp: new Date("2026-08-26T10:00:00.000Z"),
|
||||
timelineCursor: { epoch: "epoch-1", seq, anchorSeq: seq },
|
||||
timelineCursor: { epoch: "epoch-1", seq },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -576,235 +576,6 @@ describe("viewed timeline persistence", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("conversation rewind through the owner", () => {
|
||||
const page = {
|
||||
requestId: "page-1",
|
||||
agentId: AGENT_ID,
|
||||
agent: null,
|
||||
direction: "tail",
|
||||
projection: "projected",
|
||||
reset: false,
|
||||
epoch: "epoch-1",
|
||||
error: null,
|
||||
hasNewer: false,
|
||||
hasOlder: false,
|
||||
staleCursor: false,
|
||||
gap: false,
|
||||
} as const;
|
||||
|
||||
function entry(seq: number, type: "user_message" | "assistant_message", text: string) {
|
||||
return {
|
||||
seqStart: seq,
|
||||
seqEnd: seq,
|
||||
sourceSeqRanges: [{ startSeq: seq, endSeq: seq }],
|
||||
collapsed: [],
|
||||
provider: "codex",
|
||||
item: { type, text },
|
||||
timestamp: `2026-08-26T10:00:0${seq}.000Z`,
|
||||
};
|
||||
}
|
||||
|
||||
function texts(items: readonly StreamItem[]): string[] {
|
||||
return items.flatMap((row) =>
|
||||
row.kind === "user_message" || row.kind === "assistant_message" ? [row.text] : [],
|
||||
);
|
||||
}
|
||||
|
||||
function viewed(): string[] {
|
||||
const session = useSessionStore.getState().sessions[SERVER_ID];
|
||||
const timeline = selectAgentTimelineState(session, AGENT_ID);
|
||||
const tail = timeline.status === "cold" ? [] : timeline.items;
|
||||
return texts([...tail, ...(session?.agentStreamHead.get(AGENT_ID) ?? [])]);
|
||||
}
|
||||
|
||||
function latestRewind() {
|
||||
return useSessionStore.getState().sessions[SERVER_ID]?.agentTimelineLatestRewind.get(AGENT_ID);
|
||||
}
|
||||
|
||||
it("retires B from the viewed and persisted timeline without refetching", () => {
|
||||
useSessionStore.getState().initializeSession(SERVER_ID, null);
|
||||
const commits: CachedTimeline[] = [];
|
||||
const owner = createOwner({
|
||||
readTimeline: async () => undefined,
|
||||
commitTimeline: (_serverId, _agentId, timeline) => commits.push(timeline),
|
||||
});
|
||||
owner.applyTimelineResponse({
|
||||
...page,
|
||||
window: { minSeq: 1, maxSeq: 4, nextSeq: 5 },
|
||||
startCursor: { epoch: "epoch-1", seq: 1 },
|
||||
endCursor: { epoch: "epoch-1", seq: 4 },
|
||||
entries: [
|
||||
entry(1, "user_message", "A"),
|
||||
entry(2, "assistant_message", "reply A"),
|
||||
entry(3, "user_message", "B"),
|
||||
entry(4, "assistant_message", "reply B"),
|
||||
],
|
||||
});
|
||||
|
||||
owner.applyStreamEvent(AGENT_ID, {
|
||||
event: { type: "rewind", retireFromSeq: 3 },
|
||||
seq: 5,
|
||||
epoch: "epoch-1",
|
||||
timestamp: new Date("2026-08-26T10:00:05.000Z"),
|
||||
});
|
||||
owner.applyStreamEvent(AGENT_ID, {
|
||||
event: {
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
item: { type: "user_message", text: "C", messageId: "c" },
|
||||
} as AgentStreamEventPayload,
|
||||
seq: 6,
|
||||
epoch: "epoch-1",
|
||||
timestamp: new Date("2026-08-26T10:00:06.000Z"),
|
||||
});
|
||||
owner.flushStreamAgent(AGENT_ID);
|
||||
|
||||
expect(viewed()).toEqual(["A", "reply A", "C"]);
|
||||
expect(texts(commits.at(-1)?.items ?? [])).toEqual(["A", "reply A", "C"]);
|
||||
expect(commits.at(-1)?.range).toMatchObject({ epoch: "epoch-1", endSeq: 6 });
|
||||
expect(latestRewind()).toEqual({ epoch: "epoch-1", seq: 5 });
|
||||
owner.dispose();
|
||||
});
|
||||
|
||||
it("brings a second client that loads after the rewind to the same conversation", () => {
|
||||
useSessionStore.getState().initializeSession(SERVER_ID, null);
|
||||
const owner = createOwner({ readTimeline: async () => undefined, commitTimeline: () => {} });
|
||||
|
||||
owner.applyTimelineResponse({
|
||||
...page,
|
||||
window: { minSeq: 1, maxSeq: 6, nextSeq: 7 },
|
||||
startCursor: { epoch: "epoch-1", seq: 1 },
|
||||
endCursor: { epoch: "epoch-1", seq: 6 },
|
||||
entries: [
|
||||
entry(1, "user_message", "A"),
|
||||
entry(2, "assistant_message", "reply A"),
|
||||
entry(6, "user_message", "C"),
|
||||
],
|
||||
retirements: [{ seq: 5, retireFromSeq: 3 }],
|
||||
});
|
||||
|
||||
expect(viewed()).toEqual(["A", "reply A", "C"]);
|
||||
expect(latestRewind()).toEqual({ epoch: "epoch-1", seq: 5 });
|
||||
owner.dispose();
|
||||
});
|
||||
|
||||
function liveRewindOfB(owner: ViewedTimelineOwner, retirement: { seq: number }): void {
|
||||
owner.applyStreamEvent(AGENT_ID, {
|
||||
event: { type: "rewind", retireFromSeq: 3 },
|
||||
seq: retirement.seq,
|
||||
epoch: "epoch-1",
|
||||
timestamp: new Date("2026-08-26T10:00:06.000Z"),
|
||||
});
|
||||
owner.applyStreamEvent(AGENT_ID, {
|
||||
event: {
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
item: { type: "user_message", text: "C", messageId: "c" },
|
||||
} as AgentStreamEventPayload,
|
||||
seq: retirement.seq + 1,
|
||||
epoch: "epoch-1",
|
||||
timestamp: new Date("2026-08-26T10:00:07.000Z"),
|
||||
});
|
||||
owner.flushStreamAgent(AGENT_ID);
|
||||
}
|
||||
|
||||
/** A, reply A, B, reply B as a page served before B was rewound. */
|
||||
const pageServedBeforeTheRewind: Parameters<ViewedTimelineOwner["applyTimelineResponse"]>[0] = {
|
||||
...page,
|
||||
direction: "before",
|
||||
hasOlder: false,
|
||||
window: { minSeq: 1, maxSeq: 5, nextSeq: 6 },
|
||||
startCursor: { epoch: "epoch-1", seq: 1 },
|
||||
endCursor: { epoch: "epoch-1", seq: 4 },
|
||||
entries: [
|
||||
entry(1, "user_message", "A"),
|
||||
entry(2, "assistant_message", "reply A"),
|
||||
entry(3, "user_message", "B"),
|
||||
entry(4, "assistant_message", "reply B"),
|
||||
],
|
||||
};
|
||||
|
||||
function loadTheEndOfB(owner: ViewedTimelineOwner): void {
|
||||
owner.applyTimelineResponse({
|
||||
...page,
|
||||
hasOlder: true,
|
||||
window: { minSeq: 1, maxSeq: 5, nextSeq: 6 },
|
||||
startCursor: { epoch: "epoch-1", seq: 5 },
|
||||
endCursor: { epoch: "epoch-1", seq: 5 },
|
||||
entries: [entry(5, "assistant_message", "more of B")],
|
||||
});
|
||||
}
|
||||
|
||||
it("refuses an older page served before the rewind and takes the one served after it", () => {
|
||||
useSessionStore.getState().initializeSession(SERVER_ID, null);
|
||||
const owner = createOwner({ readTimeline: async () => undefined, commitTimeline: () => {} });
|
||||
loadTheEndOfB(owner);
|
||||
liveRewindOfB(owner, { seq: 6 });
|
||||
expect(viewed()).toEqual(["C"]);
|
||||
|
||||
const outcome = owner.applyTimelineResponse(pageServedBeforeTheRewind);
|
||||
expect(viewed()).toEqual(["C"]);
|
||||
expect(outcome).toBe("stale");
|
||||
|
||||
owner.applyTimelineResponse({
|
||||
...pageServedBeforeTheRewind,
|
||||
window: { minSeq: 1, maxSeq: 7, nextSeq: 8 },
|
||||
// Retired rows count as covered, so the page reaches the loaded start.
|
||||
endCursor: { epoch: "epoch-1", seq: 4 },
|
||||
entries: [entry(1, "user_message", "A"), entry(2, "assistant_message", "reply A")],
|
||||
});
|
||||
expect(viewed()).toEqual(["A", "reply A", "C"]);
|
||||
owner.dispose();
|
||||
});
|
||||
|
||||
it("refuses a prompt jump window served before the rewind", () => {
|
||||
useSessionStore.getState().initializeSession(SERVER_ID, null);
|
||||
const owner = createOwner({ readTimeline: async () => undefined, commitTimeline: () => {} });
|
||||
loadTheEndOfB(owner);
|
||||
liveRewindOfB(owner, { seq: 6 });
|
||||
|
||||
const outcome = owner.applyTimelineResponse({
|
||||
...pageServedBeforeTheRewind,
|
||||
mergeWindow: true,
|
||||
});
|
||||
expect(viewed()).toEqual(["C"]);
|
||||
expect(outcome).toBe("stale");
|
||||
owner.dispose();
|
||||
});
|
||||
|
||||
it("refuses a first page served before a rewind that reached the chat while it loaded", () => {
|
||||
useSessionStore.getState().initializeSession(SERVER_ID, null);
|
||||
const owner = createOwner({ readTimeline: async () => undefined, commitTimeline: () => {} });
|
||||
liveRewindOfB(owner, { seq: 5 });
|
||||
|
||||
const outcome = owner.applyTimelineResponse({
|
||||
...pageServedBeforeTheRewind,
|
||||
direction: "tail",
|
||||
window: { minSeq: 1, maxSeq: 4, nextSeq: 5 },
|
||||
});
|
||||
expect(viewed()).toEqual(["C"]);
|
||||
expect(outcome).toBe("stale");
|
||||
|
||||
owner.applyTimelineResponse({
|
||||
...page,
|
||||
window: { minSeq: 1, maxSeq: 6, nextSeq: 7 },
|
||||
startCursor: { epoch: "epoch-1", seq: 1 },
|
||||
endCursor: { epoch: "epoch-1", seq: 6 },
|
||||
entries: [
|
||||
entry(1, "user_message", "A"),
|
||||
entry(2, "assistant_message", "reply A"),
|
||||
{
|
||||
...entry(6, "user_message", "C"),
|
||||
item: { type: "user_message", text: "C", messageId: "c" },
|
||||
},
|
||||
],
|
||||
retirements: [{ seq: 5, retireFromSeq: 3 }],
|
||||
});
|
||||
expect(viewed()).toEqual(["A", "reply A", "C"]);
|
||||
owner.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe("turn boundaries", () => {
|
||||
it("commits a turn's final reply before the turn stops running", () => {
|
||||
useSessionStore.getState().initializeSession(SERVER_ID, null);
|
||||
|
||||
@@ -396,7 +396,7 @@ describe("processTimelineResponse", () => {
|
||||
messageId: "provider-message",
|
||||
text: "local prompt",
|
||||
timestamp: new Date(1000),
|
||||
timelineCursor: { epoch: "epoch-1", seq: 1, anchorSeq: 1 },
|
||||
timelineCursor: { epoch: "epoch-1", seq: 1 },
|
||||
turnId: "turn-1",
|
||||
});
|
||||
const hello = createUserMessage({
|
||||
@@ -404,7 +404,7 @@ describe("processTimelineResponse", () => {
|
||||
clientMessageId: "hello-client",
|
||||
text: "hello",
|
||||
timestamp: new Date(1500),
|
||||
timelineCursor: { epoch: "epoch-1", seq: 2, anchorSeq: 2 },
|
||||
timelineCursor: { epoch: "epoch-1", seq: 2 },
|
||||
turnId: "turn-1",
|
||||
});
|
||||
const currentTail = [canonical, makeAssistantItem("existing tail", "existing-tail"), hello];
|
||||
@@ -454,7 +454,7 @@ describe("processTimelineResponse", () => {
|
||||
id: "stale-history",
|
||||
text: "stale history",
|
||||
timestamp: new Date(1040),
|
||||
timelineCursor: { epoch: "epoch-1", seq: 40, anchorSeq: 40 },
|
||||
timelineCursor: { epoch: "epoch-1", seq: 40 },
|
||||
},
|
||||
],
|
||||
currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 40 },
|
||||
@@ -482,11 +482,11 @@ describe("processTimelineResponse", () => {
|
||||
const unresolved = makeSubmittedUserMessage("unresolved prompt", "client-unresolved");
|
||||
const coveredLive: StreamItem = {
|
||||
...makeAssistantItem("covered live", "covered-live"),
|
||||
timelineCursor: { epoch: "epoch-1", seq: 235, anchorSeq: 235 },
|
||||
timelineCursor: { epoch: "epoch-1", seq: 235 },
|
||||
};
|
||||
const newerLive: StreamItem = {
|
||||
...makeAssistantItem("newer live", "newer-live"),
|
||||
timelineCursor: { epoch: "epoch-1", seq: 245, anchorSeq: 245 },
|
||||
timelineCursor: { epoch: "epoch-1", seq: 245 },
|
||||
};
|
||||
|
||||
const result = processTimelineResponse({
|
||||
@@ -571,7 +571,7 @@ describe("processTimelineResponse", () => {
|
||||
expect.objectContaining({
|
||||
kind: "assistant_message",
|
||||
text: "[System Error] failed",
|
||||
timelineCursor: { epoch: "timeline-1", seq: 42, anchorSeq: 40 },
|
||||
timelineCursor: { epoch: "timeline-1", seq: 42 },
|
||||
}),
|
||||
]);
|
||||
});
|
||||
@@ -743,7 +743,7 @@ describe("processTimelineResponse", () => {
|
||||
}).item,
|
||||
} as AgentStreamEventPayload,
|
||||
timestamp: new Date(2000),
|
||||
timelineCursor: { epoch: "epoch-1", seq: 2, anchorSeq: 2 },
|
||||
timelineCursor: { epoch: "epoch-1", seq: 2 },
|
||||
},
|
||||
],
|
||||
{ source: "canonical" },
|
||||
@@ -773,7 +773,7 @@ describe("processTimelineResponse", () => {
|
||||
|
||||
expect(getAgentToolCalls([...result.tail, ...result.head])).toEqual([
|
||||
expect.objectContaining({
|
||||
timelineCursor: { epoch: "epoch-1", seq: 2, anchorSeq: 1 },
|
||||
timelineCursor: { epoch: "epoch-1", seq: 2 },
|
||||
payload: expect.objectContaining({
|
||||
data: expect.objectContaining({ callId, status: "completed" }),
|
||||
}),
|
||||
@@ -794,7 +794,7 @@ describe("processTimelineResponse", () => {
|
||||
},
|
||||
} as AgentStreamEventPayload,
|
||||
timestamp: new Date(2000),
|
||||
timelineCursor: { epoch: "epoch-1", seq: 2, anchorSeq: 2 },
|
||||
timelineCursor: { epoch: "epoch-1", seq: 2 },
|
||||
},
|
||||
],
|
||||
{ source: "canonical" },
|
||||
@@ -829,7 +829,7 @@ describe("processTimelineResponse", () => {
|
||||
|
||||
expect([...result.tail, ...result.head].filter((item) => item.kind === "todo_list")).toEqual([
|
||||
expect.objectContaining({
|
||||
timelineCursor: { epoch: "epoch-1", seq: 2, anchorSeq: 1 },
|
||||
timelineCursor: { epoch: "epoch-1", seq: 2 },
|
||||
items: [{ text: "Verify hydration", completed: true }],
|
||||
}),
|
||||
]);
|
||||
@@ -845,7 +845,7 @@ describe("processTimelineResponse", () => {
|
||||
item: { type: "compaction", status: "completed", trigger: "auto", preTokens: 1200 },
|
||||
} as AgentStreamEventPayload,
|
||||
timestamp: new Date(2000),
|
||||
timelineCursor: { epoch: "epoch-1", seq: 2, anchorSeq: 2 },
|
||||
timelineCursor: { epoch: "epoch-1", seq: 2 },
|
||||
},
|
||||
],
|
||||
{ source: "canonical" },
|
||||
@@ -877,7 +877,7 @@ describe("processTimelineResponse", () => {
|
||||
|
||||
expect([...result.tail, ...result.head].filter((item) => item.kind === "compaction")).toEqual([
|
||||
expect.objectContaining({
|
||||
timelineCursor: { epoch: "epoch-1", seq: 2, anchorSeq: 1 },
|
||||
timelineCursor: { epoch: "epoch-1", seq: 2 },
|
||||
status: "completed",
|
||||
trigger: "auto",
|
||||
preTokens: 1200,
|
||||
@@ -916,7 +916,7 @@ describe("processTimelineResponse", () => {
|
||||
messageId: "assistant-live",
|
||||
text: " continuation",
|
||||
timestamp: new Date(2001),
|
||||
timelineCursor: { epoch: "epoch-1", seq: 101, anchorSeq: 101 },
|
||||
timelineCursor: { epoch: "epoch-1", seq: 101 },
|
||||
},
|
||||
],
|
||||
isInitializing: true,
|
||||
@@ -944,7 +944,7 @@ describe("processTimelineResponse", () => {
|
||||
);
|
||||
expect(assistants).toHaveLength(1);
|
||||
expect(assistants[0]?.text).toBe("canonical prefix continuation");
|
||||
expect(assistants[0]?.timelineCursor).toEqual({ epoch: "epoch-1", seq: 101, anchorSeq: 100 });
|
||||
expect(assistants[0]?.timelineCursor).toEqual({ epoch: "epoch-1", seq: 101 });
|
||||
});
|
||||
|
||||
it("keeps assistant segments separate when a live tool sits between them", () => {
|
||||
@@ -961,12 +961,12 @@ describe("processTimelineResponse", () => {
|
||||
}).item,
|
||||
} as AgentStreamEventPayload,
|
||||
timestamp: new Date(2101),
|
||||
timelineCursor: { epoch: "epoch-1", seq: 101, anchorSeq: 101 },
|
||||
timelineCursor: { epoch: "epoch-1", seq: 101 },
|
||||
},
|
||||
{
|
||||
event: makeAssistantTimelineEvent("after tool", messageId),
|
||||
timestamp: new Date(2102),
|
||||
timelineCursor: { epoch: "epoch-1", seq: 102, anchorSeq: 102 },
|
||||
timelineCursor: { epoch: "epoch-1", seq: 102 },
|
||||
},
|
||||
],
|
||||
{ source: "canonical" },
|
||||
@@ -1017,7 +1017,7 @@ describe("processTimelineResponse", () => {
|
||||
id: "assistant-live",
|
||||
text: "canonical prefix continuation",
|
||||
timestamp: new Date(2001),
|
||||
timelineCursor: { epoch: "epoch-1", seq: 101, anchorSeq: 101 },
|
||||
timelineCursor: { epoch: "epoch-1", seq: 101 },
|
||||
},
|
||||
],
|
||||
isInitializing: true,
|
||||
@@ -1036,7 +1036,7 @@ describe("processTimelineResponse", () => {
|
||||
);
|
||||
expect(assistants).toHaveLength(1);
|
||||
expect(assistants[0]?.text).toBe("canonical prefix continuation");
|
||||
expect(assistants[0]?.timelineCursor).toEqual({ epoch: "epoch-1", seq: 101, anchorSeq: 100 });
|
||||
expect(assistants[0]?.timelineCursor).toEqual({ epoch: "epoch-1", seq: 101 });
|
||||
});
|
||||
|
||||
it("keeps a canonical live user row newer than the bootstrap page", () => {
|
||||
@@ -1058,7 +1058,7 @@ describe("processTimelineResponse", () => {
|
||||
});
|
||||
expect(live.head[0]).toMatchObject({
|
||||
kind: "user_message",
|
||||
timelineCursor: { epoch: "epoch-1", seq: 101, anchorSeq: 101 },
|
||||
timelineCursor: { epoch: "epoch-1", seq: 101 },
|
||||
});
|
||||
|
||||
const result = processTimelineResponse({
|
||||
@@ -1121,7 +1121,7 @@ describe("processTimelineResponse", () => {
|
||||
kind: "user_message",
|
||||
clientMessageId: "client-message-1",
|
||||
messageId: "provider-message-1",
|
||||
timelineCursor: { epoch: "epoch-1", seq: 51, anchorSeq: 51 },
|
||||
timelineCursor: { epoch: "epoch-1", seq: 51 },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2806,7 +2806,7 @@ describe("processTimelineResponse", () => {
|
||||
id: "newest",
|
||||
text: "newest",
|
||||
timestamp: new Date(1000),
|
||||
timelineCursor: { epoch: "epoch-1", seq: 100, anchorSeq: 100 },
|
||||
timelineCursor: { epoch: "epoch-1", seq: 100 },
|
||||
},
|
||||
makeSubmittedUserMessage("pending submission", "pending-submission"),
|
||||
],
|
||||
@@ -2884,14 +2884,14 @@ describe("processTimelineResponse", () => {
|
||||
id: "oldest",
|
||||
text: "oldest",
|
||||
timestamp: new Date(1000),
|
||||
timelineCursor: { epoch: "epoch-1", seq: 1, anchorSeq: 1 },
|
||||
timelineCursor: { epoch: "epoch-1", seq: 1 },
|
||||
},
|
||||
{
|
||||
kind: "user_message",
|
||||
id: "newest",
|
||||
text: "newest",
|
||||
timestamp: new Date(100_000),
|
||||
timelineCursor: { epoch: "epoch-1", seq: 100, anchorSeq: 100 },
|
||||
timelineCursor: { epoch: "epoch-1", seq: 100 },
|
||||
},
|
||||
];
|
||||
|
||||
@@ -2933,7 +2933,7 @@ describe("processTimelineResponse", () => {
|
||||
id: "newest",
|
||||
text: "newest",
|
||||
timestamp: new Date(100_000),
|
||||
timelineCursor: { epoch: "epoch-1", seq: 100, anchorSeq: 100 },
|
||||
timelineCursor: { epoch: "epoch-1", seq: 100 },
|
||||
},
|
||||
...(lane === "tail" ? [pending] : []),
|
||||
],
|
||||
@@ -2974,13 +2974,9 @@ describe("processTimelineResponse", () => {
|
||||
{
|
||||
text: "local pending prompt",
|
||||
messageId: "canonical-message",
|
||||
timelineCursor: { epoch: "epoch-1", seq: 1, anchorSeq: 1 },
|
||||
},
|
||||
{
|
||||
text: "newest",
|
||||
messageId: undefined,
|
||||
timelineCursor: { epoch: "epoch-1", seq: 100, anchorSeq: 100 },
|
||||
timelineCursor: { epoch: "epoch-1", seq: 1 },
|
||||
},
|
||||
{ text: "newest", messageId: undefined, timelineCursor: { epoch: "epoch-1", seq: 100 } },
|
||||
],
|
||||
head: [],
|
||||
acknowledgedClientMessageIds: [clientMessageId],
|
||||
@@ -2994,7 +2990,7 @@ describe("processTimelineResponse", () => {
|
||||
{
|
||||
event: makeToolCallTimelineEvent("call-1"),
|
||||
timestamp: new Date(500),
|
||||
timelineCursor: { epoch: "epoch-1", seq: 500, anchorSeq: 500 },
|
||||
timelineCursor: { epoch: "epoch-1", seq: 500 },
|
||||
},
|
||||
],
|
||||
{ source: "canonical" },
|
||||
@@ -3040,7 +3036,7 @@ describe("processTimelineResponse", () => {
|
||||
{
|
||||
callId: "call-1",
|
||||
status: "completed",
|
||||
timelineCursor: { epoch: "epoch-1", seq: 500, anchorSeq: 1 },
|
||||
timelineCursor: { epoch: "epoch-1", seq: 500 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -3052,7 +3048,7 @@ describe("processTimelineResponse", () => {
|
||||
...makeAssistantItem("projected response", "loaded-assistant"),
|
||||
messageId: "assistant-message",
|
||||
timestamp,
|
||||
timelineCursor: { epoch: "epoch-1", seq: 500, anchorSeq: 500 },
|
||||
timelineCursor: { epoch: "epoch-1", seq: 500 },
|
||||
},
|
||||
];
|
||||
const result = processTimelineResponse({
|
||||
@@ -3102,7 +3098,7 @@ describe("processTimelineResponse", () => {
|
||||
id: "loaded-assistant",
|
||||
text: "projected response",
|
||||
messageId: "assistant-message",
|
||||
timelineCursor: { epoch: "epoch-1", seq: 500, anchorSeq: 1 },
|
||||
timelineCursor: { epoch: "epoch-1", seq: 500 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -3114,7 +3110,7 @@ describe("processTimelineResponse", () => {
|
||||
id: "current-200",
|
||||
text: "current-200",
|
||||
timestamp: new Date(3200),
|
||||
timelineCursor: { epoch: "epoch-1", seq: 200, anchorSeq: 200 },
|
||||
timelineCursor: { epoch: "epoch-1", seq: 200 },
|
||||
},
|
||||
];
|
||||
const existingCursor: TimelineCursor = {
|
||||
@@ -3221,7 +3217,7 @@ describe("processTimelineResponse", () => {
|
||||
const currentTail = [
|
||||
{
|
||||
...makeAssistantItem("newer chunk", "assistant-newer"),
|
||||
timelineCursor: { epoch: "epoch-1", seq: 3, anchorSeq: 3 },
|
||||
timelineCursor: { epoch: "epoch-1", seq: 3 },
|
||||
},
|
||||
];
|
||||
const existingCursor: TimelineCursor = {
|
||||
@@ -3248,7 +3244,7 @@ describe("processTimelineResponse", () => {
|
||||
expect(result.tail[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
id: "assistant-newer",
|
||||
timelineCursor: { epoch: "epoch-1", seq: 3, anchorSeq: 3 },
|
||||
timelineCursor: { epoch: "epoch-1", seq: 3 },
|
||||
}),
|
||||
);
|
||||
expect(result.cursor).toEqual({
|
||||
@@ -3687,7 +3683,7 @@ describe("processAgentStreamEvent", () => {
|
||||
expect.objectContaining({
|
||||
kind: "assistant_message",
|
||||
text: "[System Error] failed",
|
||||
timelineCursor: { epoch: "timeline-1", seq: 42, anchorSeq: 42 },
|
||||
timelineCursor: { epoch: "timeline-1", seq: 42 },
|
||||
}),
|
||||
]);
|
||||
});
|
||||
@@ -3916,7 +3912,7 @@ describe("processAgentStreamEvent", () => {
|
||||
expect(getUserTexts(result.head)).toEqual(["live before bootstrap"]);
|
||||
expect(result.head[0]).toMatchObject({
|
||||
kind: "user_message",
|
||||
timelineCursor: { epoch: "epoch-1", seq: 51, anchorSeq: 51 },
|
||||
timelineCursor: { epoch: "epoch-1", seq: 51 },
|
||||
});
|
||||
expect(result.cursorChanged).toBe(false);
|
||||
expect(result.cursor).toBeNull();
|
||||
@@ -3951,7 +3947,7 @@ describe("processAgentStreamEvent", () => {
|
||||
clientMessageId: "client-message-1",
|
||||
messageId: "provider-message-1",
|
||||
text: "submitted before bootstrap",
|
||||
timelineCursor: { epoch: "epoch-1", seq: 51, anchorSeq: 51 },
|
||||
timelineCursor: { epoch: "epoch-1", seq: 51 },
|
||||
}),
|
||||
]);
|
||||
});
|
||||
@@ -4540,276 +4536,3 @@ describe("createAgentStreamReducerQueue", () => {
|
||||
expect(scheduler.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Conversation rewind: A, B, rewind B, C
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("conversation rewind", () => {
|
||||
const rewindB = { seq: 5, retireFromSeq: 3 };
|
||||
|
||||
function loadAB(): { tail: StreamItem[]; head: StreamItem[]; cursor: TimelineCursor } {
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
isInitializing: true,
|
||||
hasActiveInitDeferred: true,
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
direction: "tail",
|
||||
window: { minSeq: 1, maxSeq: 4, nextSeq: 5 },
|
||||
startCursor: { seq: 1 },
|
||||
endCursor: { seq: 4 },
|
||||
entries: [
|
||||
makeTimelineEntry(1, "A", "user_message"),
|
||||
makeTimelineEntry(2, "reply A"),
|
||||
makeTimelineEntry(3, "B", "user_message"),
|
||||
makeTimelineEntry(4, "reply B"),
|
||||
],
|
||||
},
|
||||
});
|
||||
if (!result.cursor) throw new Error("expected a loaded range");
|
||||
return { tail: result.tail, head: result.head, cursor: result.cursor };
|
||||
}
|
||||
|
||||
function live(
|
||||
state: { tail: StreamItem[]; head: StreamItem[]; cursor: TimelineCursor },
|
||||
events: AgentStreamReducerEvent[],
|
||||
) {
|
||||
const result = processAgentStreamEvents({
|
||||
events,
|
||||
currentTail: state.tail,
|
||||
currentHead: state.head,
|
||||
currentCursor: state.cursor,
|
||||
hasAuthoritativeBaseline: true,
|
||||
});
|
||||
return {
|
||||
tail: result.tail,
|
||||
head: result.head,
|
||||
cursor: result.cursor ?? state.cursor,
|
||||
sideEffects: result.sideEffects,
|
||||
};
|
||||
}
|
||||
|
||||
function rewindEvent(retirement: { seq: number; retireFromSeq: number }) {
|
||||
return {
|
||||
event: { type: "rewind" as const, retireFromSeq: retirement.retireFromSeq },
|
||||
seq: retirement.seq,
|
||||
epoch: "epoch-1",
|
||||
timestamp: new Date(1000 + retirement.seq),
|
||||
};
|
||||
}
|
||||
|
||||
const promptC = [
|
||||
makeStreamReducerEvent(makeTimelineEvent("C", "user_message"), 6),
|
||||
makeStreamReducerEvent(makeTimelineEvent("reply C"), 7),
|
||||
];
|
||||
|
||||
function conversation(state: { tail: StreamItem[]; head: StreamItem[] }) {
|
||||
return [...state.tail, ...state.head].map((item) =>
|
||||
item.kind === "user_message" || item.kind === "assistant_message" ? item.text : item.kind,
|
||||
);
|
||||
}
|
||||
|
||||
it("retires B in place, keeps A mounted, and shows C once", () => {
|
||||
const loaded = loadAB();
|
||||
const [promptA, replyA] = loaded.tail;
|
||||
|
||||
const rewound = live(loaded, [rewindEvent(rewindB), ...promptC]);
|
||||
|
||||
expect(conversation(rewound)).toEqual(["A", "reply A", "C", "reply C"]);
|
||||
expect(rewound.tail[0]).toBe(promptA);
|
||||
expect(rewound.tail[1]).toBe(replyA);
|
||||
expect(rewound.cursor).toMatchObject({ epoch: "epoch-1", startSeq: 1, endSeq: 7 });
|
||||
expect(rewound.sideEffects).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores a duplicate rewind so C is not retired", () => {
|
||||
const rewound = live(loadAB(), [rewindEvent(rewindB), ...promptC]);
|
||||
|
||||
const replayed = live(rewound, [rewindEvent(rewindB)]);
|
||||
|
||||
expect(conversation(replayed)).toEqual(["A", "reply A", "C", "reply C"]);
|
||||
expect(replayed.cursor.endSeq).toBe(7);
|
||||
});
|
||||
|
||||
it("does not let an old page resurrect B after the rewind applied", () => {
|
||||
const rewound = live(loadAB(), [rewindEvent(rewindB), ...promptC]);
|
||||
|
||||
const stale = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentTail: rewound.tail,
|
||||
currentHead: rewound.head,
|
||||
currentCursor: rewound.cursor,
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
direction: "after",
|
||||
window: { minSeq: 1, maxSeq: 4, nextSeq: 5 },
|
||||
startCursor: { seq: 3 },
|
||||
endCursor: { seq: 4 },
|
||||
entries: [makeTimelineEntry(3, "B", "user_message"), makeTimelineEntry(4, "reply B")],
|
||||
},
|
||||
});
|
||||
|
||||
expect(conversation(stale)).toEqual(["A", "reply A", "C", "reply C"]);
|
||||
expect(stale.cursorChanged).toBe(false);
|
||||
});
|
||||
|
||||
it("drops a late live delivery for the retired range", () => {
|
||||
const rewound = live(loadAB(), [rewindEvent(rewindB)]);
|
||||
|
||||
const late = live(rewound, [makeStreamReducerEvent(makeTimelineEvent("late B"), 4)]);
|
||||
|
||||
expect(conversation(late)).toEqual(["A", "reply A"]);
|
||||
});
|
||||
|
||||
it("catches up through a page that only carries the retirement", () => {
|
||||
const loaded = loadAB();
|
||||
|
||||
const caughtUp = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentTail: loaded.tail,
|
||||
currentHead: loaded.head,
|
||||
currentCursor: loaded.cursor,
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
direction: "after",
|
||||
window: { minSeq: 1, maxSeq: 5, nextSeq: 6 },
|
||||
startCursor: { seq: 5 },
|
||||
endCursor: { seq: 5 },
|
||||
entries: [],
|
||||
retirements: [rewindB],
|
||||
},
|
||||
});
|
||||
if (!caughtUp.cursor) throw new Error("expected a loaded range");
|
||||
|
||||
expect(conversation(caughtUp)).toEqual(["A", "reply A"]);
|
||||
expect(caughtUp.cursor.endSeq).toBe(5);
|
||||
expect(caughtUp.sideEffects).not.toContainEqual(expect.objectContaining({ type: "catch_up" }));
|
||||
|
||||
const continued = live({ ...caughtUp, cursor: caughtUp.cursor }, promptC);
|
||||
expect(conversation(continued)).toEqual(["A", "reply A", "C", "reply C"]);
|
||||
});
|
||||
|
||||
it("converges after reconnect from a resume page that crosses the rewind", () => {
|
||||
const loaded = loadAB();
|
||||
|
||||
const resumed = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentTail: loaded.tail,
|
||||
currentHead: loaded.head,
|
||||
currentCursor: loaded.cursor,
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
direction: "tail",
|
||||
window: { minSeq: 1, maxSeq: 7, nextSeq: 8 },
|
||||
startCursor: { seq: 1 },
|
||||
endCursor: { seq: 7 },
|
||||
entries: [
|
||||
makeTimelineEntry(1, "A", "user_message"),
|
||||
makeTimelineEntry(2, "reply A"),
|
||||
makeTimelineEntry(6, "C", "user_message"),
|
||||
makeTimelineEntry(7, "reply C"),
|
||||
],
|
||||
retirements: [rewindB],
|
||||
},
|
||||
});
|
||||
|
||||
expect(conversation(resumed)).toEqual(["A", "reply A", "C", "reply C"]);
|
||||
expect(resumed.tail[0]).toBe(loaded.tail[0]);
|
||||
expect(resumed.cursor).toMatchObject({ endSeq: 7 });
|
||||
});
|
||||
|
||||
it("recovers a rewind that skips past the loaded end", () => {
|
||||
const loaded = loadAB();
|
||||
|
||||
const gapped = live(loaded, [rewindEvent({ seq: 9, retireFromSeq: 3 })]);
|
||||
|
||||
expect(conversation(gapped)).toEqual(["A", "reply A", "B", "reply B"]);
|
||||
expect(gapped.sideEffects).toEqual([
|
||||
{ type: "catch_up", cursor: { epoch: "epoch-1", endSeq: 4 } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps an A row whose late update carries a seq inside B's range", () => {
|
||||
const loaded = loadAB();
|
||||
const [promptA, replyA, ...rest] = loaded.tail;
|
||||
const lateTool = hydrateStreamState(
|
||||
[
|
||||
{
|
||||
event: makeToolCallTimelineEvent("call-a"),
|
||||
timestamp: new Date(1002),
|
||||
timelineCursor: { epoch: "epoch-1", seq: 4, anchorSeq: 2 },
|
||||
},
|
||||
],
|
||||
{ source: "canonical" },
|
||||
);
|
||||
const state = { ...loaded, tail: [promptA, replyA, ...lateTool, ...rest] };
|
||||
|
||||
const rewound = live(state, [rewindEvent(rewindB)]);
|
||||
|
||||
expect(conversation(rewound)).toEqual(["A", "reply A", "tool_call"]);
|
||||
});
|
||||
|
||||
it("keeps a tool that started before B when B's prompt is not loaded and the tool updated after B", () => {
|
||||
const toolA = {
|
||||
...makeToolCallTimelineEntry(2, "call-a", "completed", {
|
||||
type: "read",
|
||||
filePath: "/tmp/a.ts",
|
||||
}),
|
||||
seqEnd: 6,
|
||||
sourceSeqRanges: [
|
||||
{ startSeq: 2, endSeq: 2 },
|
||||
{ startSeq: 6, endSeq: 6 },
|
||||
],
|
||||
};
|
||||
const loaded = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
isInitializing: true,
|
||||
hasActiveInitDeferred: true,
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
direction: "tail",
|
||||
window: { minSeq: 1, maxSeq: 6, nextSeq: 7 },
|
||||
startCursor: { seq: 1 },
|
||||
endCursor: { seq: 6 },
|
||||
entries: [makeTimelineEntry(1, "A", "user_message"), toolA],
|
||||
},
|
||||
});
|
||||
if (!loaded.cursor) throw new Error("expected a loaded range");
|
||||
|
||||
const rewound = live({ tail: loaded.tail, head: loaded.head, cursor: loaded.cursor }, [
|
||||
rewindEvent({ seq: 7, retireFromSeq: 3 }),
|
||||
]);
|
||||
|
||||
expect(conversation(rewound)).toEqual(["A", "tool_call"]);
|
||||
});
|
||||
|
||||
it("keeps C from the live overlay when the first page brings the retirement C followed", () => {
|
||||
const overlay = processAgentStreamEvents({
|
||||
events: [rewindEvent(rewindB), ...promptC],
|
||||
currentTail: [],
|
||||
currentHead: [],
|
||||
currentCursor: undefined,
|
||||
hasAuthoritativeBaseline: false,
|
||||
});
|
||||
|
||||
const initialized = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
isInitializing: true,
|
||||
hasActiveInitDeferred: true,
|
||||
currentTail: overlay.tail,
|
||||
currentHead: overlay.head,
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
direction: "tail",
|
||||
window: { minSeq: 1, maxSeq: 5, nextSeq: 6 },
|
||||
startCursor: { seq: 1 },
|
||||
endCursor: { seq: 5 },
|
||||
entries: [makeTimelineEntry(1, "A", "user_message"), makeTimelineEntry(2, "reply A")],
|
||||
retirements: [rewindB],
|
||||
},
|
||||
});
|
||||
|
||||
expect(conversation(initialized)).toEqual(["A", "reply A", "C", "reply C"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types";
|
||||
import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages";
|
||||
import { selectAgentTimelineState, useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
advanceTimelinePosition,
|
||||
type AssistantMessageItem,
|
||||
type StreamItem,
|
||||
type TimelinePosition,
|
||||
type TodoEntry,
|
||||
} from "@/types/stream";
|
||||
import type { AssistantMessageItem, StreamItem, TodoEntry } from "@/types/stream";
|
||||
import type { TurnLivenessTransition } from "@/timeline/turn-liveness";
|
||||
import {
|
||||
applyStreamEvent,
|
||||
@@ -77,64 +71,6 @@ interface TimelineSeqRange {
|
||||
endSeq: number;
|
||||
}
|
||||
|
||||
/** A conversation rewind took the rows anchored in `[retireFromSeq, seq)` out of the conversation. */
|
||||
export interface TimelineRetirement {
|
||||
seq: number;
|
||||
retireFromSeq: number;
|
||||
}
|
||||
|
||||
/** The live form of a retirement; it is sequenced with the stream events around it. */
|
||||
export interface TimelineRewindEvent {
|
||||
type: "rewind";
|
||||
retireFromSeq: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops the rows a retirement took out of the conversation: those anchored in
|
||||
* `[retireFromSeq, seq)`. Anchors do not move on update, so an earlier row updated after the
|
||||
* rewound prompt stays, and a row that started after the retirement stays. Items without a
|
||||
* timeline position are local sends the daemon has not echoed and stay.
|
||||
*/
|
||||
function retireRewoundStreamItems(input: {
|
||||
tail: StreamItem[];
|
||||
head: StreamItem[];
|
||||
epoch: string;
|
||||
retirement: TimelineRetirement;
|
||||
}): { tail: StreamItem[]; head: StreamItem[] } {
|
||||
const { tail, head, epoch, retirement } = input;
|
||||
const isRetired = (item: StreamItem) => {
|
||||
const position = item.timelineCursor;
|
||||
return (
|
||||
position?.epoch === epoch &&
|
||||
position.anchorSeq >= retirement.retireFromSeq &&
|
||||
position.anchorSeq < retirement.seq
|
||||
);
|
||||
};
|
||||
const nextTail = tail.filter((item) => !isRetired(item));
|
||||
const nextHead = head.filter((item) => !isRetired(item));
|
||||
return {
|
||||
tail: nextTail.length === tail.length ? tail : nextTail,
|
||||
head: nextHead.length === head.length ? head : nextHead,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a page's retirements to what this client holds. The page's own entries are already
|
||||
* active. Applying a retirement twice changes nothing, since its range is bounded.
|
||||
*/
|
||||
function retireForPage(input: {
|
||||
payload: ProcessTimelineResponseInput["payload"];
|
||||
tail: StreamItem[];
|
||||
head: StreamItem[];
|
||||
}): { tail: StreamItem[]; head: StreamItem[] } {
|
||||
const { payload } = input;
|
||||
let held = { tail: input.tail, head: input.head };
|
||||
for (const retirement of payload.retirements ?? []) {
|
||||
held = retireRewoundStreamItems({ ...held, epoch: payload.epoch, retirement });
|
||||
}
|
||||
return held;
|
||||
}
|
||||
|
||||
function mergeTimelineCoverage(
|
||||
current: TimelineCursor,
|
||||
added: TimelineLoadedRange,
|
||||
@@ -214,7 +150,6 @@ export interface ProcessTimelineResponseInput {
|
||||
hasNewer: boolean;
|
||||
hasOlder: boolean;
|
||||
mergeWindow?: boolean;
|
||||
retirements?: TimelineRetirement[];
|
||||
};
|
||||
currentTail: StreamItem[];
|
||||
currentHead: StreamItem[];
|
||||
@@ -456,7 +391,7 @@ function mergeTimelineWindow(args: {
|
||||
toHydratedEvents: (units: TimelineUnit[]) => Array<{
|
||||
event: AgentStreamEventPayload;
|
||||
timestamp: Date;
|
||||
timelineCursor: TimelinePosition;
|
||||
timelineCursor: { epoch: string; seq: number };
|
||||
}>;
|
||||
}): TimelinePathResult {
|
||||
const { timelineUnits, payload, currentTail, currentHead, currentCursor, toHydratedEvents } =
|
||||
@@ -820,7 +755,7 @@ function replaceLiveAssistantWithProjectedText(params: {
|
||||
head: StreamItem[];
|
||||
event: AgentStreamEventPayload;
|
||||
timestamp: Date;
|
||||
timelineCursor: TimelinePosition;
|
||||
timelineCursor: { epoch: string; seq: number };
|
||||
}): StreamItem[] | null {
|
||||
const { head, event, timestamp, timelineCursor } = params;
|
||||
if (event.type !== "timeline" || event.item.type !== "assistant_message") {
|
||||
@@ -839,7 +774,7 @@ function replaceLiveAssistantWithProjectedText(params: {
|
||||
...current,
|
||||
text: event.item.text,
|
||||
timestamp,
|
||||
...advanceTimelinePosition(current.timelineCursor, timelineCursor),
|
||||
timelineCursor,
|
||||
};
|
||||
return next;
|
||||
}
|
||||
@@ -889,7 +824,7 @@ function reconcileOverlappingProjectedAssistant(params: {
|
||||
...(messageId !== undefined ? { messageId } : {}),
|
||||
text: projectedText,
|
||||
timestamp: unit.timestamp,
|
||||
timelineCursor: { epoch: params.epoch, seq: unit.seqEnd, anchorSeq: unit.seq },
|
||||
timelineCursor: { epoch: params.epoch, seq: unit.seqEnd },
|
||||
};
|
||||
const replaceMatch = (items: StreamItem[], index: number) => {
|
||||
const next = [...items];
|
||||
@@ -1006,8 +941,8 @@ function applyCanonicalForwardUnit(params: {
|
||||
unit: TimelineUnit;
|
||||
epoch: string;
|
||||
}): { tail: StreamItem[]; head: StreamItem[]; acknowledgedClientMessageIds: string[] } {
|
||||
const { event, timestamp, seq, seqEnd } = params.unit;
|
||||
const timelineCursor: TimelinePosition = { epoch: params.epoch, seq: seqEnd, anchorSeq: seq };
|
||||
const { event, timestamp, seqEnd } = params.unit;
|
||||
const timelineCursor = { epoch: params.epoch, seq: seqEnd };
|
||||
if (event.type === "timeline" && event.item.type === "user_message") {
|
||||
const applied = applyStreamEvent({
|
||||
tail: params.tail,
|
||||
@@ -1206,10 +1141,10 @@ function applyAcceptedTimelinePage(input: {
|
||||
});
|
||||
}
|
||||
const olderTail = hydrateStreamState(
|
||||
acceptedUnits.map(({ event, timestamp, seq, seqEnd }) => ({
|
||||
acceptedUnits.map(({ event, timestamp, seqEnd }) => ({
|
||||
event,
|
||||
timestamp,
|
||||
timelineCursor: { epoch: payload.epoch, seq: seqEnd, anchorSeq: seq },
|
||||
timelineCursor: { epoch: payload.epoch, seq: seqEnd },
|
||||
})),
|
||||
{
|
||||
source: "canonical",
|
||||
@@ -1241,12 +1176,7 @@ function applyTimelineIncrementalPath(args: {
|
||||
let cursorChanged = false;
|
||||
const sideEffects: TimelineReducerSideEffect[] = [];
|
||||
|
||||
// A forward page of retirements alone still advances the loaded end.
|
||||
if (
|
||||
timelineUnits.length === 0 &&
|
||||
payload.direction !== "before" &&
|
||||
(payload.retirements?.length ?? 0) === 0
|
||||
) {
|
||||
if (timelineUnits.length === 0 && payload.direction !== "before") {
|
||||
return {
|
||||
tail: currentTail,
|
||||
head: currentHead,
|
||||
@@ -1304,6 +1234,8 @@ export function processTimelineResponse(
|
||||
): ProcessTimelineResponseOutput {
|
||||
const {
|
||||
payload,
|
||||
currentTail,
|
||||
currentHead,
|
||||
currentCursor,
|
||||
isInitializing,
|
||||
hasActiveInitDeferred,
|
||||
@@ -1317,8 +1249,8 @@ export function processTimelineResponse(
|
||||
if (payload.error) {
|
||||
return {
|
||||
commit: "apply",
|
||||
tail: input.currentTail,
|
||||
head: input.currentHead,
|
||||
tail: currentTail,
|
||||
head: currentHead,
|
||||
cursor: currentCursor,
|
||||
cursorChanged: false,
|
||||
older: "unchanged",
|
||||
@@ -1330,13 +1262,6 @@ export function processTimelineResponse(
|
||||
};
|
||||
}
|
||||
|
||||
// Entries are already free of retired rows; the retirements apply to what this client holds.
|
||||
const { tail: currentTail, head: currentHead } = retireForPage({
|
||||
payload,
|
||||
tail: input.currentTail,
|
||||
head: input.currentHead,
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Convert entries to timeline units
|
||||
// ------------------------------------------------------------------
|
||||
@@ -1361,12 +1286,12 @@ export function processTimelineResponse(
|
||||
): Array<{
|
||||
event: AgentStreamEventPayload;
|
||||
timestamp: Date;
|
||||
timelineCursor: TimelinePosition;
|
||||
timelineCursor: { epoch: string; seq: number };
|
||||
}> =>
|
||||
units.map(({ event, timestamp, seq, seqEnd }) => ({
|
||||
units.map(({ event, timestamp, seqEnd }) => ({
|
||||
event,
|
||||
timestamp,
|
||||
timelineCursor: { epoch: payload.epoch, seq: seqEnd, anchorSeq: seq },
|
||||
timelineCursor: { epoch: payload.epoch, seq: seqEnd },
|
||||
}));
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
@@ -1501,7 +1426,7 @@ export function processTimelineResponse(
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ProcessAgentStreamEventInput {
|
||||
event: AgentStreamEventPayload | TimelineRewindEvent;
|
||||
event: AgentStreamEventPayload;
|
||||
seq: number | undefined;
|
||||
epoch: string | undefined;
|
||||
currentTail: StreamItem[];
|
||||
@@ -1524,7 +1449,7 @@ export interface ProcessAgentStreamEventOutput {
|
||||
}
|
||||
|
||||
export interface AgentStreamReducerEvent {
|
||||
event: AgentStreamEventPayload | TimelineRewindEvent;
|
||||
event: AgentStreamEventPayload;
|
||||
seq: number | undefined;
|
||||
epoch: string | undefined;
|
||||
timestamp: Date;
|
||||
@@ -1642,70 +1567,11 @@ function processTimelineSequencingGate(input: {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a live rewind in sequence with the stream. One that does not follow the loaded end is
|
||||
* stale (already applied, or older than a page this client took) or leaves a gap to catch up.
|
||||
*/
|
||||
function processTimelineRewind(
|
||||
input: ProcessAgentStreamEventInput & { event: TimelineRewindEvent },
|
||||
): ProcessAgentStreamEventOutput {
|
||||
const { event, seq, epoch, currentTail, currentHead, currentCursor } = input;
|
||||
const unchanged: ProcessAgentStreamEventOutput = {
|
||||
tail: currentTail,
|
||||
head: currentHead,
|
||||
changedTail: false,
|
||||
changedHead: false,
|
||||
cursor: null,
|
||||
cursorChanged: false,
|
||||
acknowledgedClientMessageIds: [],
|
||||
sideEffects: [],
|
||||
};
|
||||
if (seq === undefined || epoch === undefined) return unchanged;
|
||||
const decision =
|
||||
input.hasAuthoritativeBaseline === false
|
||||
? "overlay"
|
||||
: classifySessionTimelineSeq({
|
||||
cursor: currentCursor
|
||||
? { epoch: currentCursor.epoch, endSeq: currentCursor.endSeq }
|
||||
: null,
|
||||
epoch,
|
||||
seq,
|
||||
});
|
||||
if (decision === "gap" && currentCursor) {
|
||||
return {
|
||||
...unchanged,
|
||||
sideEffects: [
|
||||
{ type: "catch_up", cursor: { epoch: currentCursor.epoch, endSeq: currentCursor.endSeq } },
|
||||
],
|
||||
};
|
||||
}
|
||||
if (decision !== "accept" && decision !== "init" && decision !== "overlay") return unchanged;
|
||||
const retired = retireRewoundStreamItems({
|
||||
tail: currentTail,
|
||||
head: currentHead,
|
||||
epoch,
|
||||
retirement: { seq, retireFromSeq: event.retireFromSeq },
|
||||
});
|
||||
let cursor: TimelineCursor | null = null;
|
||||
if (decision === "accept" && currentCursor) cursor = { ...currentCursor, endSeq: seq };
|
||||
if (decision === "init") cursor = { epoch, startSeq: seq, endSeq: seq };
|
||||
return {
|
||||
...unchanged,
|
||||
tail: retired.tail,
|
||||
head: retired.head,
|
||||
changedTail: retired.tail !== currentTail,
|
||||
changedHead: retired.head !== currentHead,
|
||||
cursor,
|
||||
cursorChanged: cursor !== null,
|
||||
};
|
||||
}
|
||||
|
||||
export function processAgentStreamEvent(
|
||||
input: ProcessAgentStreamEventInput,
|
||||
): ProcessAgentStreamEventOutput {
|
||||
const event = input.event;
|
||||
if (event.type === "rewind") return processTimelineRewind({ ...input, event });
|
||||
const {
|
||||
event,
|
||||
seq,
|
||||
epoch,
|
||||
currentTail,
|
||||
@@ -1724,7 +1590,7 @@ export function processAgentStreamEvent(
|
||||
});
|
||||
const timelineCursor =
|
||||
event.type === "timeline" && seq !== undefined && epoch !== undefined
|
||||
? { epoch, seq, anchorSeq: seq }
|
||||
? { epoch, seq }
|
||||
: undefined;
|
||||
// ------------------------------------------------------------------
|
||||
// Apply stream event to tail/head
|
||||
@@ -2038,10 +1904,6 @@ export function createSessionAgentStreamReducerQueue(
|
||||
};
|
||||
},
|
||||
commit: (agentId, result, events) => {
|
||||
for (const { event, seq, epoch } of events) {
|
||||
if (event.type !== "rewind" || seq === undefined || epoch === undefined) continue;
|
||||
useSessionStore.getState().recordAgentTimelineRewind(serverId, agentId, { epoch, seq });
|
||||
}
|
||||
if (
|
||||
result.changedTail ||
|
||||
result.changedHead ||
|
||||
|
||||
@@ -236,13 +236,6 @@ function commitProcessedTimeline(input: {
|
||||
const agentId = payload.agentId;
|
||||
const store = useSessionStore.getState();
|
||||
if (result.commit !== "discard") {
|
||||
const latestRewindSeq = Math.max(...(payload.retirements ?? []).map(({ seq }) => seq));
|
||||
if (Number.isFinite(latestRewindSeq)) {
|
||||
store.recordAgentTimelineRewind(serverId, agentId, {
|
||||
epoch: payload.epoch,
|
||||
seq: latestRewindSeq,
|
||||
});
|
||||
}
|
||||
store.applyAgentTimelineResponseState(serverId, agentId, {
|
||||
items: result.tail,
|
||||
head: result.head,
|
||||
@@ -463,14 +456,8 @@ export type ViewedTimelineOwnerPorts = Omit<
|
||||
| "applyCurrentTimeline"
|
||||
>;
|
||||
|
||||
/**
|
||||
* `stale`: the page was served before a rewind this client already applied, so it can hold rows
|
||||
* that rewind retired. Nothing was applied; request the page again.
|
||||
*/
|
||||
export type TimelineResponseOutcome = "applied" | "stale";
|
||||
|
||||
export interface ViewedTimelineOwner extends ViewedTimelineSync {
|
||||
applyTimelineResponse(payload: TimelineResponsePayload): TimelineResponseOutcome;
|
||||
applyTimelineResponse(payload: TimelineResponsePayload): void;
|
||||
/**
|
||||
* Accepts one live stream event. Content is coalesced to the next frame; a turn boundary
|
||||
* commits the turn's queued content before its liveness changes, so the view never shows
|
||||
@@ -480,37 +467,6 @@ export interface ViewedTimelineOwner extends ViewedTimelineSync {
|
||||
flushStreamAgent(agentId: string): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The latest retirement each chat has seen, live or on a page. Tail, older and prompt-jump pages
|
||||
* are served from active rows at one moment; one served before that retirement can still hold the
|
||||
* rows it retired, and has no retirement to drop them with. Forward pages are sequenced against
|
||||
* the loaded end instead, so they are never stale here.
|
||||
*/
|
||||
function createRetirementHighWater() {
|
||||
const latest = new Map<string, { epoch: string; seq: number }>();
|
||||
function note(agentId: string, retirement: { epoch: string; seq: number }): void {
|
||||
const current = latest.get(agentId);
|
||||
if (current?.epoch === retirement.epoch && current.seq >= retirement.seq) return;
|
||||
latest.set(agentId, retirement);
|
||||
}
|
||||
return {
|
||||
note,
|
||||
notePage(payload: TimelineResponsePayload): void {
|
||||
for (const retirement of payload.retirements ?? []) {
|
||||
note(payload.agentId, { epoch: payload.epoch, seq: retirement.seq });
|
||||
}
|
||||
},
|
||||
servedBefore(payload: TimelineResponsePayload): boolean {
|
||||
const known = latest.get(payload.agentId);
|
||||
return (
|
||||
payload.direction !== "after" &&
|
||||
known?.epoch === payload.epoch &&
|
||||
payload.window.maxSeq < known.seq
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createViewedTimelineOwner(input: {
|
||||
serverId: string;
|
||||
replica: TimelineReplica;
|
||||
@@ -520,7 +476,6 @@ export function createViewedTimelineOwner(input: {
|
||||
ports: ViewedTimelineOwnerPorts;
|
||||
}): ViewedTimelineOwner {
|
||||
const forcedTailReplacements = new Set<string>();
|
||||
const retirements = createRetirementHighWater();
|
||||
const sync = createViewedTimelineSync({
|
||||
...input.ports,
|
||||
fetchLatestTail: async (agentId) => {
|
||||
@@ -561,8 +516,6 @@ export function createViewedTimelineOwner(input: {
|
||||
return {
|
||||
...sync,
|
||||
applyTimelineResponse(receivedPayload) {
|
||||
if (retirements.servedBefore(receivedPayload)) return "stale";
|
||||
retirements.notePage(receivedPayload);
|
||||
const payload = consumeForcedTimelineTailReplacement(receivedPayload, forcedTailReplacements);
|
||||
const accepted = applyAuthoritativeTimelineResponse({
|
||||
serverId: input.serverId,
|
||||
@@ -572,12 +525,8 @@ export function createViewedTimelineOwner(input: {
|
||||
drainQueuedAgentMessage: input.drainQueuedAgentMessage,
|
||||
});
|
||||
if (accepted) input.replica.timelineUpdated(payload.agentId);
|
||||
return "applied";
|
||||
},
|
||||
applyStreamEvent(agentId, event) {
|
||||
if (event.event.type === "rewind" && event.epoch !== undefined && event.seq !== undefined) {
|
||||
retirements.note(agentId, { epoch: event.epoch, seq: event.seq });
|
||||
}
|
||||
streamQueue.enqueue(agentId, event);
|
||||
const turnLiveness = deriveAgentStreamTurnLiveness([event]);
|
||||
if (turnLiveness.length === 0) return;
|
||||
|
||||
@@ -1009,7 +1009,7 @@ describe("stream reducer canonical tool calls", () => {
|
||||
});
|
||||
|
||||
it("keeps the timeline position on the whole assistant message", () => {
|
||||
const timelineCursor = { epoch: "epoch-1", seq: 42, anchorSeq: 42 };
|
||||
const timelineCursor = { epoch: "epoch-1", seq: 42 };
|
||||
const result = applyStreamEvent({
|
||||
tail: [],
|
||||
head: [],
|
||||
@@ -1890,7 +1890,7 @@ describe("turn lifecycle events", () => {
|
||||
new Date("2025-01-01T15:03:11Z"),
|
||||
{
|
||||
source: "canonical",
|
||||
timelineCursor: { epoch: "epoch-1", seq: 42, anchorSeq: 42 },
|
||||
timelineCursor: { epoch: "epoch-1", seq: 42 },
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1902,11 +1902,7 @@ describe("turn lifecycle events", () => {
|
||||
assert.strictEqual(userMessage.messageId, "provider-owned-canonical");
|
||||
assert.strictEqual(userMessage.text, "Analyze this");
|
||||
assert.strictEqual(userMessage.timestamp.getTime(), submittedTimestamp.getTime());
|
||||
assert.deepStrictEqual(userMessage.timelineCursor, {
|
||||
epoch: "epoch-1",
|
||||
seq: 42,
|
||||
anchorSeq: 42,
|
||||
});
|
||||
assert.deepStrictEqual(userMessage.timelineCursor, { epoch: "epoch-1", seq: 42 });
|
||||
assert.deepStrictEqual(userMessage.images, [image]);
|
||||
assert.deepStrictEqual(userMessage.attachments, [attachment]);
|
||||
});
|
||||
|
||||
@@ -264,7 +264,7 @@ function produceUserMessage(
|
||||
...presentation,
|
||||
clientMessageId: incoming.clientMessageId ?? existing.clientMessageId,
|
||||
messageId: incoming.messageId ?? existing.messageId,
|
||||
...advanceTimelinePosition(existing.timelineCursor, incoming.timelineCursor),
|
||||
timelineCursor: incoming.timelineCursor ?? existing.timelineCursor,
|
||||
});
|
||||
if (
|
||||
existing.id === merged.id &&
|
||||
@@ -480,7 +480,7 @@ function mergeRetainedLifecycleItem(tail: StreamItem[], retained: StreamItem): S
|
||||
const next = [...tail];
|
||||
next[tailIndex] = {
|
||||
...existing,
|
||||
...advanceTimelinePosition(existing.timelineCursor, retained.timelineCursor),
|
||||
timelineCursor: retained.timelineCursor,
|
||||
timestamp: retained.timestamp,
|
||||
items: retained.items,
|
||||
};
|
||||
@@ -497,7 +497,7 @@ function mergeRetainedLifecycleItem(tail: StreamItem[], retained: StreamItem): S
|
||||
const next = [...tail];
|
||||
next[tailIndex] = {
|
||||
...existing,
|
||||
...advanceTimelinePosition(existing.timelineCursor, retained.timelineCursor),
|
||||
timelineCursor: retained.timelineCursor,
|
||||
status: "completed",
|
||||
trigger: retained.trigger ?? existing.trigger,
|
||||
preTokens: retained.preTokens ?? existing.preTokens,
|
||||
@@ -517,10 +517,7 @@ function replaceTimelineIdentityItem(
|
||||
const existing = items[index];
|
||||
if (index < 0 || !existing || existing.kind !== "plugin") return null;
|
||||
const next = [...items];
|
||||
next[index] = {
|
||||
...retained,
|
||||
...advanceTimelinePosition(existing.timelineCursor, retained.timelineCursor),
|
||||
};
|
||||
next[index] = retained;
|
||||
return next;
|
||||
}
|
||||
|
||||
@@ -611,11 +608,7 @@ function preserveReplacementHead(
|
||||
: `${tailAssistant.text}${liveAssistant.text}`;
|
||||
const head = [
|
||||
...unreconciledHead.slice(0, liveAssistantIndex),
|
||||
{
|
||||
...liveAssistant,
|
||||
text,
|
||||
...advanceTimelinePosition(tailAssistant.timelineCursor, liveAssistant.timelineCursor),
|
||||
},
|
||||
{ ...liveAssistant, text },
|
||||
...unreconciledHead.slice(liveAssistantIndex + 1),
|
||||
];
|
||||
return {
|
||||
@@ -630,13 +623,7 @@ function preserveReplacementHead(
|
||||
|
||||
const head = [
|
||||
...unreconciledHead.slice(0, liveAssistantIndex),
|
||||
{
|
||||
...liveAssistant,
|
||||
text: tailAssistant.text,
|
||||
...(liveAssistant.timelineCursor
|
||||
? advanceTimelinePosition(tailAssistant.timelineCursor, liveAssistant.timelineCursor)
|
||||
: {}),
|
||||
},
|
||||
{ ...liveAssistant, text: tailAssistant.text },
|
||||
...unreconciledHead.slice(liveAssistantIndex + 1),
|
||||
];
|
||||
return {
|
||||
@@ -733,27 +720,7 @@ export interface AssistantMessageItem {
|
||||
|
||||
export interface TimelinePosition {
|
||||
epoch: string;
|
||||
/** The latest seq that changed this row. */
|
||||
seq: number;
|
||||
/** The seq the row started at. Updates never move it; rewinds retire rows by it. */
|
||||
anchorSeq: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The position of a row after an update: it moves to the update's seq and stays anchored where it
|
||||
* started. Without an incoming position the row keeps the one it has.
|
||||
*/
|
||||
export function advanceTimelinePosition(
|
||||
existing: TimelinePosition | undefined,
|
||||
incoming: TimelinePosition | undefined,
|
||||
): { timelineCursor?: TimelinePosition } {
|
||||
if (!incoming) return existing ? { timelineCursor: existing } : {};
|
||||
if (!existing || existing.epoch !== incoming.epoch) return { timelineCursor: incoming };
|
||||
const anchorSeq = Math.min(existing.anchorSeq, incoming.anchorSeq);
|
||||
if (existing.seq === incoming.seq && existing.anchorSeq === anchorSeq) {
|
||||
return { timelineCursor: existing };
|
||||
}
|
||||
return { timelineCursor: { ...incoming, anchorSeq } };
|
||||
}
|
||||
|
||||
export type ThoughtStatus = "loading" | "ready";
|
||||
@@ -999,7 +966,7 @@ function appendAssistantMessage(
|
||||
...last,
|
||||
text: `${last.text}${chunk}`,
|
||||
timestamp,
|
||||
...advanceTimelinePosition(last.timelineCursor, timelineCursor),
|
||||
...(timelineCursor ? { timelineCursor } : {}),
|
||||
};
|
||||
return [...state.slice(0, -1), updated];
|
||||
}
|
||||
@@ -1017,7 +984,7 @@ function appendAssistantMessage(
|
||||
...secondLast,
|
||||
text: `${secondLast.text}${chunk}`,
|
||||
timestamp,
|
||||
...advanceTimelinePosition(secondLast.timelineCursor, timelineCursor),
|
||||
...(timelineCursor ? { timelineCursor } : {}),
|
||||
};
|
||||
return [...state.slice(0, -2), updated, last];
|
||||
}
|
||||
@@ -1055,7 +1022,7 @@ function appendThought(
|
||||
if (last && last.kind === "thought" && last.turnId === turnId) {
|
||||
const updated: ThoughtItem = {
|
||||
...last,
|
||||
...advanceTimelinePosition(last.timelineCursor, timelineCursor),
|
||||
...(timelineCursor ? { timelineCursor } : {}),
|
||||
text: `${last.text}${chunk}`,
|
||||
timestamp,
|
||||
status: "loading",
|
||||
@@ -1235,7 +1202,7 @@ export function mergeAgentToolCallItem(
|
||||
|
||||
return {
|
||||
...existing,
|
||||
...advanceTimelinePosition(existing.timelineCursor, timelineCursor),
|
||||
...(timelineCursor ? { timelineCursor } : {}),
|
||||
timestamp,
|
||||
payload: {
|
||||
source: "agent",
|
||||
@@ -1341,11 +1308,7 @@ function appendPluginTimelineItem(
|
||||
const existing = state[existingIndex];
|
||||
if (!existing || existing.kind !== "plugin") return state;
|
||||
const next = [...state];
|
||||
next[existingIndex] = {
|
||||
...nextItem,
|
||||
id: existing.id,
|
||||
...advanceTimelinePosition(existing.timelineCursor, timelineCursor),
|
||||
};
|
||||
next[existingIndex] = { ...nextItem, id: existing.id };
|
||||
return next;
|
||||
}
|
||||
|
||||
@@ -1376,7 +1339,7 @@ function appendTodoList(
|
||||
const next = [...state];
|
||||
next[previousIndex] = {
|
||||
...previous,
|
||||
...advanceTimelinePosition(previous.timelineCursor, timelineCursor),
|
||||
...(timelineCursor ? { timelineCursor } : {}),
|
||||
items: normalizedItems,
|
||||
timestamp,
|
||||
};
|
||||
@@ -1395,7 +1358,7 @@ function appendTodoList(
|
||||
const next = [...state];
|
||||
next[next.length - 1] = {
|
||||
...lastItem,
|
||||
...advanceTimelinePosition(lastItem.timelineCursor, timelineCursor),
|
||||
...(timelineCursor ? { timelineCursor } : {}),
|
||||
items: normalizedItems,
|
||||
activity: { type: "created", count: normalizedItems.length },
|
||||
timestamp,
|
||||
@@ -1540,7 +1503,7 @@ function reduceTimelineCompaction(
|
||||
if (loadingIdx >= 0 && existing && existing.kind === "compaction") {
|
||||
const updated: CompactionItem = {
|
||||
...existing,
|
||||
...advanceTimelinePosition(existing.timelineCursor, timelineCursor),
|
||||
...(timelineCursor ? { timelineCursor } : {}),
|
||||
status: "completed",
|
||||
trigger: item.trigger ?? existing.trigger,
|
||||
preTokens: item.preTokens ?? existing.preTokens,
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export { WorkspaceRenameModal } from "./internal/modal";
|
||||
export {
|
||||
useSidebarWorkspaceRename,
|
||||
type SidebarRenamableWorkspace,
|
||||
type SidebarWorkspaceRename,
|
||||
} from "./internal/use-sidebar-workspace-rename";
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import type { NativeSyntheticEvent, TextInputKeyPressEventData } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { EditingTextInput, type EditingTextInputHandle } from "@/components/ui/text-input";
|
||||
|
||||
const ThemedTextInput = withUnistyles(EditingTextInput, (theme) => ({
|
||||
placeholderTextColor: theme.colors.foregroundMuted,
|
||||
}));
|
||||
|
||||
export interface InlineTitleFieldProps {
|
||||
initialValue: string;
|
||||
placeholder: string;
|
||||
/** Holds the submitted text on screen while the daemon accepts it. */
|
||||
readOnly: boolean;
|
||||
onCommit: (text: string) => void;
|
||||
onCancel: () => void;
|
||||
testID: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A single-line input that stands in for the row's title text. It reports exactly one outcome:
|
||||
* Enter and blur commit, Escape cancels. Enter blurs the input too, so without the settled latch
|
||||
* one Enter would commit twice.
|
||||
*
|
||||
* Its box is the slot it is rendered in (100% of the title line), and the outline that marks it
|
||||
* as editable paints outside that box, so swapping it in for the title never moves the row.
|
||||
*/
|
||||
export function InlineTitleField({
|
||||
initialValue,
|
||||
placeholder,
|
||||
readOnly,
|
||||
onCommit,
|
||||
onCancel,
|
||||
testID,
|
||||
}: InlineTitleFieldProps) {
|
||||
const inputRef = useRef<EditingTextInputHandle>(null);
|
||||
const settledRef = useRef(false);
|
||||
const focusedRef = useRef(false);
|
||||
|
||||
// Rename starts from a menu item. The menu restores focus to its trigger as it closes, so
|
||||
// focusing in the same commit would lose to it and the blur would commit an unchanged title.
|
||||
useEffect(() => {
|
||||
const frame = requestAnimationFrame(() => {
|
||||
const input = inputRef.current;
|
||||
if (!input) return;
|
||||
input.focus();
|
||||
input.setSelection({ start: 0, end: initialValue.length });
|
||||
});
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [initialValue]);
|
||||
|
||||
const settle = useCallback((outcome: () => void) => {
|
||||
if (settledRef.current) return;
|
||||
settledRef.current = true;
|
||||
outcome();
|
||||
}, []);
|
||||
|
||||
const commit = useCallback(() => {
|
||||
settle(() => onCommit(inputRef.current?.getText() ?? initialValue));
|
||||
}, [initialValue, onCommit, settle]);
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
focusedRef.current = true;
|
||||
}, []);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
// A blur before the first focus is the menu handing focus back, not the user leaving.
|
||||
if (!focusedRef.current) return;
|
||||
commit();
|
||||
}, [commit]);
|
||||
|
||||
const handleKeyPress = useCallback(
|
||||
(event: NativeSyntheticEvent<TextInputKeyPressEventData>) => {
|
||||
if (event.nativeEvent.key !== "Escape") return;
|
||||
settle(onCancel);
|
||||
},
|
||||
[onCancel, settle],
|
||||
);
|
||||
|
||||
return (
|
||||
<ThemedTextInput
|
||||
ref={inputRef}
|
||||
initialValue={initialValue}
|
||||
placeholder={placeholder}
|
||||
editable={!readOnly}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
spellCheck={false}
|
||||
selectTextOnFocus={false}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onSubmitEditing={commit}
|
||||
onKeyPress={handleKeyPress}
|
||||
style={styles.input}
|
||||
testID={testID}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
// Typography matches the sidebar title it replaces; the box comes from the slot.
|
||||
input: {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
borderWidth: 0,
|
||||
backgroundColor: "transparent",
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: "400",
|
||||
includeFontPadding: false,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
outlineStyle: "solid",
|
||||
outlineWidth: 1,
|
||||
outlineOffset: 2,
|
||||
outlineColor: theme.colors.accent,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AdaptiveRenameModal } from "@/components/rename-modal";
|
||||
import {
|
||||
type RenamableWorkspace,
|
||||
useSetWorkspaceTitle,
|
||||
workspaceRenameInitialValue,
|
||||
} from "./set-workspace-title";
|
||||
|
||||
export interface WorkspaceRenameModalProps {
|
||||
visible: boolean;
|
||||
workspace: RenamableWorkspace;
|
||||
onClose: () => void;
|
||||
/**
|
||||
* Prefix for the modal's testIDs. Sidebar rows use
|
||||
* `sidebar-workspace-rename-modal-${workspaceKey}` — the browser and native-wide e2e flows build
|
||||
* their locators from exactly that prefix.
|
||||
*/
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns 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 setWorkspaceTitle = useSetWorkspaceTitle(workspace);
|
||||
const submit = useCallback(
|
||||
async (title: string) => {
|
||||
await setWorkspaceTitle(title);
|
||||
},
|
||||
[setWorkspaceTitle],
|
||||
);
|
||||
|
||||
return (
|
||||
<AdaptiveRenameModal
|
||||
visible={visible}
|
||||
title={t("sidebar.workspace.rename.title")}
|
||||
initialValue={workspaceRenameInitialValue(workspace)}
|
||||
placeholder={workspace.name}
|
||||
submitLabel={t("sidebar.workspace.rename.submit")}
|
||||
onClose={onClose}
|
||||
onSubmit={submit}
|
||||
testID={testID}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { UnistylesRuntime } from "react-native-unistyles";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
export type WorkspaceRenameSurface = "inline" | "modal";
|
||||
|
||||
/**
|
||||
* Read when a rename starts rather than subscribed to: the surface is chosen once per rename, and
|
||||
* a subscription would re-render every sidebar row on each breakpoint change.
|
||||
*
|
||||
* Native wide layouts keep the modal. Their sidebar rows are touch targets with a permanent
|
||||
* kebab, and the iPad overlay-dismissal flows drive the modal.
|
||||
*/
|
||||
export function resolveWorkspaceRenameSurface(): WorkspaceRenameSurface {
|
||||
if (!isWeb) return "modal";
|
||||
const breakpoint = UnistylesRuntime.breakpoint;
|
||||
return breakpoint === "lg" || breakpoint === "xl" ? "inline" : "modal";
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
|
||||
// The subset of a workspace a rename 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;
|
||||
}
|
||||
|
||||
/** What the rename field starts with: the custom title, else the derived name it overrides. */
|
||||
export function workspaceRenameInitialValue(workspace: RenamableWorkspace): string {
|
||||
return workspace.title ?? workspace.name;
|
||||
}
|
||||
|
||||
/** Resolves to the title the daemon accepted; rejects with a displayable message. */
|
||||
export function useSetWorkspaceTitle(
|
||||
workspace: Pick<RenamableWorkspace, "serverId" | "workspaceId">,
|
||||
): (title: string) => Promise<string | null> {
|
||||
const { t } = useTranslation();
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (title: string) => {
|
||||
const client = getHostRuntimeStore().getClient(workspace.serverId);
|
||||
if (!client) {
|
||||
throw new Error(t("sidebar.workspace.toasts.hostDisconnected"));
|
||||
}
|
||||
const trimmed = title.trim();
|
||||
const accepted = await client.setWorkspaceTitle(
|
||||
workspace.workspaceId,
|
||||
trimmed.length === 0 ? null : trimmed,
|
||||
);
|
||||
return accepted.title;
|
||||
},
|
||||
});
|
||||
return mutation.mutateAsync;
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { type ReactNode, useCallback, useState } from "react";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { InlineTitleField } from "./inline-title-field";
|
||||
import { WorkspaceRenameModal } from "./modal";
|
||||
import { resolveWorkspaceRenameSurface } from "./rename-surface";
|
||||
import {
|
||||
type RenamableWorkspace,
|
||||
useSetWorkspaceTitle,
|
||||
workspaceRenameInitialValue,
|
||||
} from "./set-workspace-title";
|
||||
|
||||
export interface SidebarRenamableWorkspace extends RenamableWorkspace {
|
||||
workspaceKey: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* An inline session owns the text editing started from. The field keeps that text while it is
|
||||
* open, so a rename arriving from elsewhere must not make an untouched field read as an edit.
|
||||
*
|
||||
* `saving` and `saved` keep the field on screen holding the submitted text. The daemon answers
|
||||
* the request before it pushes the updated workspace, so closing on the answer would show the
|
||||
* old title for a beat. `saved` ends on the first title the row shows after the answer: ours, or
|
||||
* a later rename the daemon published in its place. The daemon builds each update from its
|
||||
* latest record, so a later rename can mean ours never appears. `titleSeen` is the row's title as
|
||||
* of the last render, so a rename delivered before the answer does not end `saved`.
|
||||
*/
|
||||
type InlineRenameSession = { kind: "inline"; initialValue: string } & (
|
||||
| { phase: "editing" }
|
||||
| { phase: "saving"; titleSeen: string | null }
|
||||
| { phase: "saved"; titleSeen: string | null; acceptedTitle: string | null }
|
||||
);
|
||||
|
||||
type RenameSession = { kind: "closed" } | { kind: "modal" } | InlineRenameSession;
|
||||
|
||||
const CLOSED: RenameSession = { kind: "closed" };
|
||||
|
||||
export interface SidebarWorkspaceRename {
|
||||
/** Starts a rename: in place on wide pointer layouts, in the rename modal otherwise. */
|
||||
begin: () => void;
|
||||
/** Rendered in place of the row's title while renaming in place; null otherwise. */
|
||||
titleEditor: ReactNode | null;
|
||||
/** Always render this; it is the modal for layouts that rename there. */
|
||||
modal: ReactNode;
|
||||
}
|
||||
|
||||
export function useSidebarWorkspaceRename(
|
||||
workspace: SidebarRenamableWorkspace,
|
||||
): SidebarWorkspaceRename {
|
||||
const toast = useToast();
|
||||
const setWorkspaceTitle = useSetWorkspaceTitle(workspace);
|
||||
const [session, setSession] = useState<RenameSession>(CLOSED);
|
||||
const currentTitle = workspace.title ?? null;
|
||||
|
||||
if (
|
||||
session.kind === "inline" &&
|
||||
session.phase === "saving" &&
|
||||
currentTitle !== session.titleSeen
|
||||
) {
|
||||
setSession({ ...session, titleSeen: currentTitle });
|
||||
}
|
||||
if (
|
||||
session.kind === "inline" &&
|
||||
session.phase === "saved" &&
|
||||
(currentTitle === session.acceptedTitle || currentTitle !== session.titleSeen)
|
||||
) {
|
||||
setSession(CLOSED);
|
||||
}
|
||||
|
||||
const initialValue = workspaceRenameInitialValue(workspace);
|
||||
const begin = useCallback(() => {
|
||||
setSession(
|
||||
resolveWorkspaceRenameSurface() === "inline"
|
||||
? { kind: "inline", phase: "editing", initialValue }
|
||||
: { kind: "modal" },
|
||||
);
|
||||
}, [initialValue]);
|
||||
|
||||
const close = useCallback(() => setSession(CLOSED), []);
|
||||
|
||||
const commit = useCallback(
|
||||
(text: string) => {
|
||||
if (session.kind !== "inline") return;
|
||||
const title = text.trim();
|
||||
if (title.length === 0 || title === session.initialValue) {
|
||||
setSession(CLOSED);
|
||||
return;
|
||||
}
|
||||
setSession({
|
||||
kind: "inline",
|
||||
initialValue: session.initialValue,
|
||||
phase: "saving",
|
||||
titleSeen: currentTitle,
|
||||
});
|
||||
setWorkspaceTitle(title).then(
|
||||
(acceptedTitle) =>
|
||||
setSession((previous) =>
|
||||
previous.kind === "inline" && previous.phase === "saving"
|
||||
? { ...previous, phase: "saved", acceptedTitle }
|
||||
: previous,
|
||||
),
|
||||
(error: unknown) => {
|
||||
setSession(CLOSED);
|
||||
toast.error(error instanceof Error ? error.message : String(error));
|
||||
},
|
||||
);
|
||||
},
|
||||
[currentTitle, session, setWorkspaceTitle, toast],
|
||||
);
|
||||
|
||||
const titleEditor =
|
||||
session.kind === "inline" ? (
|
||||
<InlineTitleField
|
||||
initialValue={session.initialValue}
|
||||
placeholder={workspace.name}
|
||||
readOnly={session.phase !== "editing"}
|
||||
onCommit={commit}
|
||||
onCancel={close}
|
||||
testID={`sidebar-workspace-rename-input-${workspace.workspaceKey}`}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
const modal = (
|
||||
<WorkspaceRenameModal
|
||||
visible={session.kind === "modal"}
|
||||
workspace={workspace}
|
||||
onClose={close}
|
||||
testID={`sidebar-workspace-rename-modal-${workspace.workspaceKey}`}
|
||||
/>
|
||||
);
|
||||
|
||||
return { begin, titleEditor, modal };
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.15.4",
|
||||
"version": "0.16.0-beta.1",
|
||||
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -29,9 +29,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.15.4",
|
||||
"@getpaseo/protocol": "0.15.4",
|
||||
"@getpaseo/server": "0.15.4",
|
||||
"@getpaseo/client": "0.16.0-beta.1",
|
||||
"@getpaseo/protocol": "0.16.0-beta.1",
|
||||
"@getpaseo/server": "0.16.0-beta.1",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
|
||||
@@ -146,10 +146,6 @@ export async function runAttachCommand(
|
||||
const unsubscribe = client.subscribeAgentTimeline(resolvedId, (message) => {
|
||||
if (message.type === "agent.timeline.replacement") {
|
||||
console.log("\n[Timeline replaced; earlier output is no longer current]");
|
||||
} else if (message.type === "agent.timeline.rewind") {
|
||||
console.log(
|
||||
"\n[Conversation rewound; output since the rewound message is no longer current]",
|
||||
);
|
||||
} else if (message.type === "agent.timeline.subscription_restored") {
|
||||
console.log("\n[Reconnected; live output resumed. Events may have been missed.]");
|
||||
} else if (message.type === "agent.timeline.error") {
|
||||
|
||||
@@ -408,6 +408,7 @@ async function runFollowMode({
|
||||
} catch (error) {
|
||||
console.warn("Warning: failed to fetch existing timeline", error);
|
||||
}
|
||||
|
||||
// Apply filter to existing items
|
||||
if (options.filter) {
|
||||
existingItems = existingItems.filter((item) => matchesFilter(item, options.filter));
|
||||
@@ -438,11 +439,6 @@ async function runFollowMode({
|
||||
notice("\n[Timeline replaced; earlier output is no longer current]");
|
||||
return;
|
||||
}
|
||||
if (message.type === "agent.timeline.rewind") {
|
||||
writer.end();
|
||||
notice("\n[Conversation rewound; output since the rewound message is no longer current]");
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === "agent.timeline.error") {
|
||||
writer.end();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.15.4",
|
||||
"version": "0.16.0-beta.1",
|
||||
"description": "Paseo client SDK package",
|
||||
"license": "Apache-2.0",
|
||||
"files": [
|
||||
@@ -49,8 +49,8 @@
|
||||
"measure:diff-materialization": "tsx scripts/measure-diff-materialization.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.15.4",
|
||||
"@getpaseo/relay": "0.15.4",
|
||||
"@getpaseo/protocol": "0.16.0-beta.1",
|
||||
"@getpaseo/relay": "0.16.0-beta.1",
|
||||
"ws": "^8.20.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
|
||||
@@ -211,37 +211,6 @@ test("SDK subscribers receive timeline replacement instead of silently losing hi
|
||||
}
|
||||
});
|
||||
|
||||
test("SDK subscribers receive a conversation rewind as a retired range", async () => {
|
||||
const { createPaseoApi } = await import("./index");
|
||||
const h = connection();
|
||||
const received: unknown[] = [];
|
||||
try {
|
||||
const ready = h.client.connect();
|
||||
h.open();
|
||||
await ready;
|
||||
const off = createPaseoApi(h.client)
|
||||
.agents.ref("agent")
|
||||
.timeline.subscribe((event) => received.push(event));
|
||||
await off.ready;
|
||||
h.receive({
|
||||
type: "agent.timeline.rewind",
|
||||
payload: {
|
||||
agentId: "agent",
|
||||
epoch: "epoch-1",
|
||||
seq: 12,
|
||||
retireFromSeq: 7,
|
||||
subscriptionId: off.subscriptionId,
|
||||
},
|
||||
});
|
||||
expect(received).toEqual([
|
||||
{ agentId: "agent", event: { type: "rewind", epoch: "epoch-1", seq: 12, retireFromSeq: 7 } },
|
||||
]);
|
||||
off();
|
||||
} finally {
|
||||
await h.client.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("local listeners create no demand before or after reconnect", async () => {
|
||||
const h = connection();
|
||||
try {
|
||||
|
||||
@@ -140,7 +140,6 @@ export const DEFAULT_CLIENT_CAPABILITIES = {
|
||||
[CLIENT_CAPS.compactProviderSnapshots]: true,
|
||||
[CLIENT_CAPS.providerSnapshotReferences]: true,
|
||||
[CLIENT_CAPS.timelineReplacementInvalidation]: true,
|
||||
[CLIENT_CAPS.timelineRewindOperations]: true,
|
||||
[CLIENT_CAPS.timelineNotifications]: true,
|
||||
[CLIENT_CAPS.pluginTimelineItems]: true,
|
||||
[CLIENT_CAPS.workspaceSetupBlocked]: true,
|
||||
|
||||
@@ -246,9 +246,7 @@ export class LegacySubscriptions {
|
||||
);
|
||||
case "agent.timeline.set_subscription.request":
|
||||
return (
|
||||
(message.type === "agent_stream" ||
|
||||
message.type === "agent.timeline.replacement" ||
|
||||
message.type === "agent.timeline.rewind") &&
|
||||
(message.type === "agent_stream" || message.type === "agent.timeline.replacement") &&
|
||||
(query.agentIds as string[]).includes(message.payload.agentId)
|
||||
);
|
||||
case "session.events.set_subscription.request":
|
||||
|
||||
@@ -753,13 +753,14 @@ class DaemonClientSession {
|
||||
private readonly daemon = new FakeDaemon();
|
||||
private readonly client: DaemonClient;
|
||||
|
||||
constructor() {
|
||||
constructor(options: { livenessHeartbeat?: boolean } = {}) {
|
||||
this.client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "clsk_heartbeat_test",
|
||||
logger: noopLogger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => this.daemon.transport,
|
||||
...options,
|
||||
});
|
||||
clients.push(this.client);
|
||||
}
|
||||
@@ -1129,7 +1130,6 @@ test("advertises client capabilities in hello", async () => {
|
||||
all_providers: true,
|
||||
selective_agent_timeline: true,
|
||||
timeline_replacement_invalidation: true,
|
||||
timeline_rewind_operations: true,
|
||||
provider_snapshot_references: true,
|
||||
explicit_event_subscriptions: true,
|
||||
owned_subscriptions: true,
|
||||
@@ -3258,6 +3258,18 @@ test("starts pinging one interval after connecting and holds the cadence", async
|
||||
expect(session.pingTimestamps()).toEqual(["10s", "20s", "30s"]);
|
||||
});
|
||||
|
||||
test("a client with the liveness heartbeat off never pings on its own", async () => {
|
||||
useHeartbeatClock();
|
||||
const session = new DaemonClientSession({ livenessHeartbeat: false });
|
||||
session.daemonAnswersPingsAfter("fast");
|
||||
|
||||
await session.connect();
|
||||
await session.advance(60_000);
|
||||
|
||||
expect(session.state()).toEqual({ status: "connected" });
|
||||
expect(session.pingTimestamps()).toEqual([]);
|
||||
});
|
||||
|
||||
test("stops pinging once the connection is gone", async () => {
|
||||
useHeartbeatClock();
|
||||
const session = new DaemonClientSession();
|
||||
|
||||
@@ -469,6 +469,13 @@ export interface DaemonClientConfig {
|
||||
runtimeMetricsWindowMs?: number;
|
||||
trace?: DaemonClientTrace;
|
||||
capabilities?: Partial<Record<ClientCapability, unknown>>;
|
||||
/**
|
||||
* Ping the daemon every 10 s and reconnect after missed replies (default true). Turn it off
|
||||
* only where the transport reports its own loss (a plugin's IPC channel, an in-process
|
||||
* channel): there a ping cannot find a dead link the channel would not, and it costs both
|
||||
* processes a wakeup per client every 10 s.
|
||||
*/
|
||||
livenessHeartbeat?: boolean;
|
||||
}
|
||||
|
||||
export interface DaemonClientTrace {
|
||||
@@ -2720,7 +2727,11 @@ export class DaemonClient {
|
||||
}
|
||||
|
||||
private scheduleNextLivenessHeartbeat(): void {
|
||||
if (this.connectionState.status !== "connected" || this.livenessHeartbeatTimer) {
|
||||
if (
|
||||
this.config.livenessHeartbeat === false ||
|
||||
this.connectionState.status !== "connected" ||
|
||||
this.livenessHeartbeatTimer
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.livenessHeartbeatTimer = setTimeout(() => {
|
||||
|
||||
@@ -341,14 +341,6 @@ export type PaseoAgentTimelineEvent =
|
||||
agentId: string;
|
||||
event: { type: "replacement"; epoch: string };
|
||||
}
|
||||
| {
|
||||
/**
|
||||
* A conversation rewind removed the rows anchored in `[retireFromSeq, seq)` from the active
|
||||
* history. Rows after `seq` belong to the rewound conversation.
|
||||
*/
|
||||
agentId: string;
|
||||
event: { type: "rewind"; epoch: string; seq: number; retireFromSeq: number };
|
||||
}
|
||||
| {
|
||||
agentId: string;
|
||||
subscriptionId: string;
|
||||
@@ -370,9 +362,7 @@ export interface PaseoAgentTimelineHandle {
|
||||
* Delivers live events only. After reconnect, subscription_restored precedes
|
||||
* subsequent updates. History may have been missed; use refetch() to request
|
||||
* the range you need. No history is fetched automatically. A replacement event
|
||||
* invalidates the previous epoch. A rewind event removes `[retireFromSeq, seq)`
|
||||
* from the active history without changing the epoch. Subscription errors
|
||||
* release this observation.
|
||||
* invalidates the previous epoch. Subscription errors release this observation.
|
||||
* Await the returned unsubscribe function's `ready` promise before starting
|
||||
* work that must be observed. It rejects if establishment fails.
|
||||
*/
|
||||
@@ -1052,16 +1042,6 @@ function createAgentHandleFactory(
|
||||
agentId: id,
|
||||
event: { type: "replacement", epoch: message.payload.epoch },
|
||||
});
|
||||
case "agent.timeline.rewind":
|
||||
return handler({
|
||||
agentId: id,
|
||||
event: {
|
||||
type: "rewind",
|
||||
epoch: message.payload.epoch,
|
||||
seq: message.payload.seq,
|
||||
retireFromSeq: message.payload.retireFromSeq,
|
||||
},
|
||||
});
|
||||
}
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { OwnedSubscription, TimelineSubscription } from "../connection/inde
|
||||
|
||||
type TimelineUpdate = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "agent_stream" | "agent.timeline.replacement" | "agent.timeline.rewind" }
|
||||
{ type: "agent_stream" | "agent.timeline.replacement" }
|
||||
>;
|
||||
|
||||
/** Local SDK lifecycle messages; these are not additional wire RPCs. */
|
||||
@@ -59,11 +59,7 @@ export function subscribeTimeline(
|
||||
});
|
||||
},
|
||||
update: (message) => {
|
||||
if (
|
||||
message.type === "agent_stream" ||
|
||||
message.type === "agent.timeline.replacement" ||
|
||||
message.type === "agent.timeline.rewind"
|
||||
)
|
||||
if (message.type === "agent_stream" || message.type === "agent.timeline.replacement")
|
||||
notify(message);
|
||||
},
|
||||
error: fail,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.15.4",
|
||||
"version": "0.16.0-beta.1",
|
||||
"private": true,
|
||||
"description": "Paseo desktop app (Electron wrapper)",
|
||||
"homepage": "https://paseo.sh",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.15.4",
|
||||
"version": "0.16.0-beta.1",
|
||||
"description": "Native module for two way audio streaming",
|
||||
"keywords": [
|
||||
"ExpoTwoWayAudio",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.15.4",
|
||||
"version": "0.16.0-beta.1",
|
||||
"license": "Apache-2.0",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.15.4",
|
||||
"version": "0.16.0-beta.1",
|
||||
"description": "Paseo plugin SDK package",
|
||||
"license": "Apache-2.0",
|
||||
"files": [
|
||||
@@ -78,8 +78,8 @@
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.15.4",
|
||||
"@getpaseo/protocol": "0.15.4",
|
||||
"@getpaseo/client": "0.16.0-beta.1",
|
||||
"@getpaseo/protocol": "0.16.0-beta.1",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "~19.2.0",
|
||||
"react": "19.2.3",
|
||||
@@ -89,8 +89,8 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@getpaseo/client": "0.15.4",
|
||||
"@getpaseo/protocol": "0.15.4",
|
||||
"@getpaseo/client": "0.16.0-beta.1",
|
||||
"@getpaseo/protocol": "0.16.0-beta.1",
|
||||
"react": ">=19.1.0 <20",
|
||||
"react-native": ">=0.81.5",
|
||||
"zod": "^4.4.3"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.15.4",
|
||||
"version": "0.16.0-beta.1",
|
||||
"description": "Paseo shared protocol schemas and wire types",
|
||||
"license": "Apache-2.0",
|
||||
"files": [
|
||||
|
||||
@@ -39,9 +39,6 @@ export const CLIENT_CAPS = {
|
||||
// COMPAT(timelineReplacementInvalidation): added in v0.5.0, remove legacy
|
||||
// reconstructed timeline replay after 2027-02-21 once the client floor supports invalidation.
|
||||
timelineReplacementInvalidation: "timeline_replacement_invalidation",
|
||||
// COMPAT(timelineRewindOperations): added in v0.14.1, remove after 2027-05-10 once the client
|
||||
// floor applies rewinds. Clients without it see a rewound history under a derived epoch.
|
||||
timelineRewindOperations: "timeline_rewind_operations",
|
||||
// COMPAT(timelineNotifications): added in v0.7.2. The daemon omits notification
|
||||
// timeline items for older clients whose strict timeline union rejects them.
|
||||
// Remove after 2027-03-03 once the supported client floor is >= v0.7.2.
|
||||
|
||||
@@ -3997,9 +3997,6 @@ export const ServerInfoStatusPayloadSchema = z
|
||||
"terminal-grapheme-widths": z.boolean().optional(),
|
||||
// COMPAT(rewind): added in v0.1.X, drop the gate when floor >= v0.1.X.
|
||||
rewind: z.boolean().optional(),
|
||||
// COMPAT(timelineRewindOperations): added in v0.14.1, remove gate after 2027-05-10.
|
||||
// A conversation rewind arrives as `agent.timeline.rewind`, not a replacement.
|
||||
timelineRewindOperations: z.boolean().optional(),
|
||||
// COMPAT(agentTimelinePromptIndex): added in v0.2.X, drop the gate when floor >= v0.2.X.
|
||||
agentTimelinePromptIndex: z.boolean().optional(),
|
||||
// COMPAT(agentHistorySearch): added in v0.3.0, remove gate after 2027-02-07.
|
||||
@@ -4197,8 +4194,6 @@ export const AgentResumedStatusPayloadSchema = z
|
||||
export const AgentRefreshedStatusPayloadSchema = z
|
||||
.object({
|
||||
status: z.literal("agent_refreshed"),
|
||||
/** The provider's history differs from the kept history, which Reload agent did not change. */
|
||||
historyDiverged: z.boolean().optional(),
|
||||
})
|
||||
.extend(AgentStatusWithTimelineSchema.shape);
|
||||
|
||||
@@ -5039,12 +5034,6 @@ export const AgentTimelineEntryPayloadSchema = z.object({
|
||||
collapsed: z.array(z.enum(["assistant_merge", "reasoning_merge", "tool_lifecycle", "identity"])),
|
||||
});
|
||||
|
||||
/** Active rows anchored in `[retireFromSeq, seq)` left the conversation. `seq` is the retirement's own. */
|
||||
export const AgentTimelineRetirementSchema = z.object({
|
||||
seq: z.number().int().nonnegative(),
|
||||
retireFromSeq: z.number().int().nonnegative(),
|
||||
});
|
||||
|
||||
export const FetchAgentTimelineResponseMessageSchema = z.object({
|
||||
type: z.literal("fetch_agent_timeline_response"),
|
||||
payload: z.object({
|
||||
@@ -5068,9 +5057,6 @@ export const FetchAgentTimelineResponseMessageSchema = z.object({
|
||||
hasNewer: z.boolean(),
|
||||
mergeWindow: z.boolean().optional(),
|
||||
entries: z.array(AgentTimelineEntryPayloadSchema),
|
||||
// COMPAT(timelineRewindOperations): added in v0.14.1. Retirements whose seq lies in the page's
|
||||
// span; apply them before the entries. Sent only to clients with the capability.
|
||||
retirements: z.array(AgentTimelineRetirementSchema).optional(),
|
||||
error: z.string().nullable(),
|
||||
}),
|
||||
});
|
||||
@@ -5084,18 +5070,6 @@ export const AgentTimelineReplacementMessageSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
/** A conversation rewind retired active rows. Apply it when `seq` follows the loaded end. */
|
||||
export const AgentTimelineRewindMessageSchema = z.object({
|
||||
type: z.literal("agent.timeline.rewind"),
|
||||
payload: z.object({
|
||||
subscriptionId: z.string().optional(),
|
||||
agentId: z.string(),
|
||||
epoch: z.string(),
|
||||
seq: z.number().int().nonnegative(),
|
||||
retireFromSeq: z.number().int().nonnegative(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const AgentTimelineSearchResponseMessageSchema = z.object({
|
||||
type: z.literal("agent.timeline.search.response"),
|
||||
payload: z.object({
|
||||
@@ -7485,7 +7459,6 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
FetchAgentResponseMessageSchema,
|
||||
FetchAgentTimelineResponseMessageSchema,
|
||||
AgentTimelineReplacementMessageSchema,
|
||||
AgentTimelineRewindMessageSchema,
|
||||
AgentTimelineSearchResponseMessageSchema,
|
||||
AgentTimelineListPromptsResponseMessageSchema,
|
||||
ProviderSubagentListResponseMessageSchema,
|
||||
@@ -8156,7 +8129,6 @@ export const WSHelloMessageSchema = z.object({
|
||||
[CLIENT_CAPS.compactProviderSnapshots]: z.boolean().optional(),
|
||||
[CLIENT_CAPS.providerSnapshotReferences]: z.boolean().optional(),
|
||||
[CLIENT_CAPS.timelineReplacementInvalidation]: z.boolean().optional(),
|
||||
[CLIENT_CAPS.timelineRewindOperations]: z.boolean().optional(),
|
||||
[CLIENT_CAPS.timelineNotifications]: z.boolean().optional(),
|
||||
[CLIENT_CAPS.backgroundAgents]: z.boolean().optional(),
|
||||
[CLIENT_CAPS.browserHost]: BrowserAutomationHostCapabilitySchema.optional(),
|
||||
|
||||
@@ -181,64 +181,6 @@ describe("wire schema compatibility", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("rewind operations are opt-in and older fetch responses still parse", () => {
|
||||
expect(
|
||||
WSHelloMessageSchema.parse({
|
||||
type: "hello",
|
||||
clientId: "capable-client",
|
||||
clientType: "mobile",
|
||||
protocolVersion: 1,
|
||||
capabilities: { timeline_rewind_operations: true },
|
||||
}).capabilities,
|
||||
).toEqual({ timeline_rewind_operations: true });
|
||||
|
||||
const rewind = {
|
||||
type: "agent.timeline.rewind",
|
||||
payload: {
|
||||
subscriptionId: "sub-1",
|
||||
agentId: "agent-1",
|
||||
epoch: "epoch-1",
|
||||
seq: 12,
|
||||
retireFromSeq: 7,
|
||||
},
|
||||
};
|
||||
expect(SessionOutboundMessageSchema.parse(rewind)).toEqual(rewind);
|
||||
|
||||
const page = {
|
||||
requestId: "fetch-1",
|
||||
agentId: "agent-1",
|
||||
agent: null,
|
||||
direction: "after",
|
||||
projection: "projected",
|
||||
epoch: "epoch-1",
|
||||
reset: false,
|
||||
staleCursor: false,
|
||||
gap: false,
|
||||
window: { minSeq: 1, maxSeq: 14, nextSeq: 15 },
|
||||
startCursor: { epoch: "epoch-1", seq: 13 },
|
||||
endCursor: { epoch: "epoch-1", seq: 14 },
|
||||
hasOlder: true,
|
||||
hasNewer: false,
|
||||
entries: [],
|
||||
error: null,
|
||||
};
|
||||
const withRetirements = { ...page, retirements: [{ seq: 12, retireFromSeq: 7 }] };
|
||||
expect(
|
||||
SessionOutboundMessageSchema.parse({
|
||||
type: "fetch_agent_timeline_response",
|
||||
payload: withRetirements,
|
||||
}),
|
||||
).toMatchObject({ payload: { retirements: [{ seq: 12, retireFromSeq: 7 }] } });
|
||||
// A daemon before rewind operations sends no retirements.
|
||||
const older = SessionOutboundMessageSchema.parse({
|
||||
type: "fetch_agent_timeline_response",
|
||||
payload: page,
|
||||
});
|
||||
expect(older.type === "fetch_agent_timeline_response" && older.payload.retirements).toBe(
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test("server info strips unknown legacy features while accepting former turn identity", () => {
|
||||
const parsed = ServerInfoStatusPayloadSchema.parse({
|
||||
status: "server_info",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.15.4",
|
||||
"version": "0.16.0-beta.1",
|
||||
"description": "Paseo relay for bridging daemon and client connections",
|
||||
"license": "Apache-2.0",
|
||||
"files": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.15.4",
|
||||
"version": "0.16.0-beta.1",
|
||||
"description": "Paseo backend server",
|
||||
"license": "Apache-2.0",
|
||||
"files": [
|
||||
@@ -109,11 +109,11 @@
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||
"@anthropic-ai/sdk": "^0.104.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",
|
||||
"@getpaseo/client": "0.16.0-beta.1",
|
||||
"@getpaseo/highlight": "0.16.0-beta.1",
|
||||
"@getpaseo/plugin": "0.16.0-beta.1",
|
||||
"@getpaseo/protocol": "0.16.0-beta.1",
|
||||
"@getpaseo/relay": "0.16.0-beta.1",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, test, vi } from "vitest";
|
||||
import { expect, test } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||
import { AgentManager } from "./agent-manager.js";
|
||||
import { AgentNotFoundError, ensureAgentLoaded, loadAgentHistory } from "./agent-loading.js";
|
||||
import { ensureAgentLoaded } from "./agent-loading.js";
|
||||
import { startAgentRun } from "./agent-prompt.js";
|
||||
import { AgentStorage } from "./agent-storage.js";
|
||||
import type {
|
||||
@@ -227,109 +227,6 @@ test("reopening an agent with no resumable session keeps a metadata write that o
|
||||
}
|
||||
});
|
||||
|
||||
test("reads stored history without starting or requiring the provider, and imports history that is not stored", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "agent-loading-history-"));
|
||||
const logger = createTestLogger();
|
||||
const storage = new AgentStorage(path.join(root, "agents"), logger);
|
||||
const baseClient = createTestAgentClients().codex;
|
||||
if (!baseClient) {
|
||||
throw new Error("expected Codex test client");
|
||||
}
|
||||
let resumes = 0;
|
||||
const client: AgentClient = {
|
||||
provider: baseClient.provider,
|
||||
capabilities: baseClient.capabilities,
|
||||
createSession: async (config: AgentSessionConfig, launchContext?: AgentLaunchContext) =>
|
||||
await baseClient.createSession(config, launchContext),
|
||||
resumeSession: async (
|
||||
handle: AgentPersistenceHandle,
|
||||
overrides?: Partial<AgentSessionConfig>,
|
||||
launchContext?: AgentLaunchContext,
|
||||
) => {
|
||||
resumes += 1;
|
||||
return await baseClient.resumeSession(handle, overrides, launchContext);
|
||||
},
|
||||
fetchCatalog: async (options) => await baseClient.fetchCatalog(options),
|
||||
isAvailable: async () => await baseClient.isAvailable(),
|
||||
};
|
||||
const agentId = "00000000-0000-4000-8000-000000000601";
|
||||
const managers: AgentManager[] = [];
|
||||
function startDaemon(): AgentManager {
|
||||
const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
|
||||
managers.push(manager);
|
||||
return manager;
|
||||
}
|
||||
|
||||
try {
|
||||
const manager = startDaemon();
|
||||
const agent = await manager.createAgent({ provider: "codex", cwd: root }, agentId, {
|
||||
workspaceId: "workspace-a",
|
||||
});
|
||||
await manager.runAgent(agent.id, "hello");
|
||||
const transcript = await manager.getTimeline(agent.id);
|
||||
expect(transcript.map((item) => item.type)).toContain("assistant_message");
|
||||
await manager.closeAgent(agent.id);
|
||||
await manager.flush();
|
||||
await storage.flush();
|
||||
|
||||
const stored = await loadAgentHistory(agentId, {
|
||||
agentManager: manager,
|
||||
agentStorage: storage,
|
||||
logger,
|
||||
});
|
||||
expect(stored).toMatchObject({ kind: "stored", record: { id: agentId } });
|
||||
expect(await manager.getTimeline(agentId)).toEqual(transcript);
|
||||
expect(manager.getAgent(agentId)).toBeNull();
|
||||
// Stored history needs no provider, so it still reads once the provider is removed.
|
||||
await expect(
|
||||
loadAgentHistory(agentId, {
|
||||
agentManager: manager,
|
||||
agentStorage: storage,
|
||||
logger,
|
||||
validProviders: [],
|
||||
}),
|
||||
).resolves.toMatchObject({ kind: "stored", record: { id: agentId } });
|
||||
expect(resumes).toBe(0);
|
||||
|
||||
// A restarted daemon has no history in memory; the provider's own history is imported,
|
||||
// which needs the provider.
|
||||
const restarted = startDaemon();
|
||||
await expect(
|
||||
loadAgentHistory(agentId, {
|
||||
agentManager: restarted,
|
||||
agentStorage: storage,
|
||||
logger,
|
||||
validProviders: [],
|
||||
}),
|
||||
).rejects.toThrow("references unavailable provider 'codex'");
|
||||
const loaded = await loadAgentHistory(agentId, {
|
||||
agentManager: restarted,
|
||||
agentStorage: storage,
|
||||
logger,
|
||||
});
|
||||
expect(loaded).toMatchObject({ kind: "live", agent: { id: agentId } });
|
||||
expect(resumes).toBe(1);
|
||||
expect((await restarted.getTimeline(agentId)).map((item) => item.type)).toEqual(
|
||||
transcript.map((item) => item.type),
|
||||
);
|
||||
|
||||
await expect(
|
||||
loadAgentHistory("00000000-0000-4000-8000-000000000602", {
|
||||
agentManager: restarted,
|
||||
agentStorage: storage,
|
||||
logger,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(AgentNotFoundError);
|
||||
} finally {
|
||||
for (const manager of managers) {
|
||||
await manager.closeAgent(agentId).catch(() => undefined);
|
||||
await manager.flush().catch(() => undefined);
|
||||
}
|
||||
await storage.flush().catch(() => undefined);
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test.each([false, true])(
|
||||
"loads history after its working directory is removed (archived: %s)",
|
||||
async (archived) => {
|
||||
@@ -374,11 +271,11 @@ test.each([false, true])(
|
||||
});
|
||||
|
||||
expect(loaded.id).toBe(agentId);
|
||||
// The manager kept the transcript when the agent closed, so the reply the agent gave
|
||||
// before the worktree went away is still readable.
|
||||
const replies = (await manager.getTimeline(agentId)).filter(
|
||||
(item) => item.type === "assistant_message",
|
||||
);
|
||||
// The transcript is replayed from the provider's persisted history, so the reply the
|
||||
// agent gave before the worktree went away is still readable.
|
||||
const replies = manager
|
||||
.getTimeline(agentId)
|
||||
.filter((item) => item.type === "assistant_message");
|
||||
expect(replies.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
replies.every((item) => item.type === "assistant_message" && item.text.length > 0),
|
||||
@@ -421,7 +318,7 @@ test("timeline requests read unarchived stored history with a deleted cwd", asyn
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
test("timeline reads of stored history leave the provider closed until a prompt resumes it interactively", async () => {
|
||||
test("timeline reads keep runnable unarchived agents interactive for the next prompt", async () => {
|
||||
const clients = createTestAgentClients();
|
||||
const base = clients.codex!;
|
||||
const purposes: Array<AgentResumeSessionOptions | undefined> = [];
|
||||
@@ -442,8 +339,7 @@ test("timeline reads of stored history leave the provider closed until a prompt
|
||||
const agent = await ctx.client.createAgent({ provider: "codex", cwd });
|
||||
await ctx.daemon.daemon.agentManager.closeAgent(agent.id);
|
||||
await ctx.client.fetchAgentTimeline(agent.id);
|
||||
// The manager serves the stored timeline itself, so the read resumes no provider session.
|
||||
expect(purposes).toEqual([]);
|
||||
expect(purposes).toEqual([{ purpose: "interactive" }]);
|
||||
await ctx.client.sendMessage(agent.id, "continue after opening history");
|
||||
expect((await ctx.client.waitForFinish(agent.id, 15000)).status).toBe("idle");
|
||||
const timeline = await ctx.client.fetchAgentTimeline(agent.id);
|
||||
@@ -457,9 +353,11 @@ test("timeline reads of stored history leave the provider closed until a prompt
|
||||
manager.tryRunOutOfBand(agent.id, "first prompt"),
|
||||
manager.tryRunOutOfBand(agent.id, "second prompt"),
|
||||
]);
|
||||
// The prompt resumed once, and the concurrent prompts after the removed-cwd read share one
|
||||
// resume. Every resume is interactive, so no history session needs replacing.
|
||||
expect(purposes).toEqual([{ purpose: "interactive" }, { purpose: "interactive" }]);
|
||||
expect(purposes).toEqual([
|
||||
{ purpose: "interactive" },
|
||||
{ purpose: "history" },
|
||||
undefined, // Reload uses the provider's interactive default.
|
||||
]);
|
||||
} finally {
|
||||
await ctx.cleanup();
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
@@ -553,13 +451,9 @@ test.each(["mode", "thinking", "rewind"])(
|
||||
runtime.recordedLaunches.filter((launch) => launch.session === sessionFile),
|
||||
).toHaveLength(1);
|
||||
expect(manager.getAgent(agentId)?.currentModeId).toBe("full");
|
||||
// The run reads its timeline before the provider starts the turn.
|
||||
await vi.waitFor(() =>
|
||||
expect(runtime.latestSession().prompts).toEqual([
|
||||
expect.objectContaining({ message: "continue" }),
|
||||
]),
|
||||
);
|
||||
runtime.latestSession().beginTurn();
|
||||
expect(runtime.latestSession().prompts).toEqual([
|
||||
expect.objectContaining({ message: "continue" }),
|
||||
]);
|
||||
runtime.latestSession().finishTurn();
|
||||
await manager.waitForAgentEvent(agentId);
|
||||
await manager.archiveAgent(agentId);
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Logger } from "pino";
|
||||
|
||||
import type { AgentProvider } from "./agent-sdk-types.js";
|
||||
import type { AgentManager, ManagedAgent } from "./agent-manager.js";
|
||||
import type { AgentStorage, StoredAgentRecord } from "./agent-storage.js";
|
||||
import type { AgentStorage } from "./agent-storage.js";
|
||||
import {
|
||||
buildConfigOverrides,
|
||||
extractAttention,
|
||||
@@ -18,11 +18,20 @@ export class AgentNotFoundError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
const pendingAgentInitializations = new Map<string, Promise<ManagedAgent>>();
|
||||
interface PendingAgentInitialization {
|
||||
promise: Promise<ManagedAgent>;
|
||||
options: { broadcastTimeline: boolean };
|
||||
}
|
||||
|
||||
const pendingAgentInitializations = new Map<string, PendingAgentInitialization>();
|
||||
|
||||
export type AgentLoaderManager = Pick<
|
||||
AgentManager,
|
||||
"createAgentFromRecord" | "getAgent" | "getRegisteredProviderIds" | "resumeAgentFromPersistence"
|
||||
| "createAgentFromRecord"
|
||||
| "getAgent"
|
||||
| "getRegisteredProviderIds"
|
||||
| "hydrateTimelineFromProvider"
|
||||
| "resumeAgentFromPersistence"
|
||||
> &
|
||||
Partial<Pick<AgentManager, "waitForAgentClose">>;
|
||||
|
||||
@@ -30,54 +39,11 @@ export interface EnsureAgentLoadedDeps {
|
||||
agentManager: AgentLoaderManager;
|
||||
agentStorage: AgentStorage;
|
||||
validProviders?: Iterable<AgentProvider>;
|
||||
broadcastTimeline?: boolean;
|
||||
purpose?: "history" | "interactive";
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
/** The agent's history to read: from its runtime, or stored, with no provider session opened. */
|
||||
export type AgentHistorySource =
|
||||
| { kind: "live"; agent: ManagedAgent }
|
||||
| { kind: "stored"; record: StoredAgentRecord };
|
||||
|
||||
export interface LoadAgentHistoryDeps extends EnsureAgentLoadedDeps {
|
||||
agentManager: AgentLoaderManager & Pick<AgentManager, "hasStoredHistory">;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves an agent for reading its history. A loaded agent is read live. A stored agent whose
|
||||
* history the timeline holds is read without resuming its provider, so it reads even when its
|
||||
* provider is no longer available. Otherwise the provider is resumed, which imports its history
|
||||
* and requires the provider.
|
||||
*/
|
||||
export async function loadAgentHistory(
|
||||
agentId: string,
|
||||
deps: LoadAgentHistoryDeps,
|
||||
): Promise<AgentHistorySource> {
|
||||
const live = deps.agentManager.getAgent(agentId);
|
||||
if (live) return { kind: "live", agent: live };
|
||||
const record = await deps.agentStorage.get(agentId);
|
||||
if (!record) throw new AgentNotFoundError(agentId);
|
||||
if (await deps.agentManager.hasStoredHistory(agentId)) {
|
||||
return { kind: "stored", record };
|
||||
}
|
||||
return { kind: "live", agent: await ensureAgentLoaded(agentId, deps) };
|
||||
}
|
||||
|
||||
async function requireLoadableRecord(
|
||||
agentId: string,
|
||||
deps: EnsureAgentLoadedDeps,
|
||||
): Promise<StoredAgentRecord> {
|
||||
const record = await deps.agentStorage.get(agentId);
|
||||
if (!record) {
|
||||
throw new AgentNotFoundError(agentId);
|
||||
}
|
||||
const validProviders = deps.validProviders ?? deps.agentManager.getRegisteredProviderIds();
|
||||
if (!isStoredAgentProviderAvailable(record, validProviders)) {
|
||||
throw new Error(`Agent ${agentId} references unavailable provider '${record.provider}'`);
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
export async function ensureUnarchivedAgentLoaded(
|
||||
agentId: string,
|
||||
deps: EnsureAgentLoadedDeps & {
|
||||
@@ -109,7 +75,8 @@ export async function ensureAgentLoaded(
|
||||
|
||||
const inflight = pendingAgentInitializations.get(agentId);
|
||||
if (inflight) {
|
||||
return inflight;
|
||||
inflight.options.broadcastTimeline ||= deps.broadcastTimeline === true;
|
||||
return inflight.promise;
|
||||
}
|
||||
|
||||
const existing = deps.agentManager.getAgent(agentId);
|
||||
@@ -124,12 +91,24 @@ export async function ensureAgentLoaded(
|
||||
|
||||
const laterInflight = pendingAgentInitializations.get(agentId);
|
||||
if (laterInflight) {
|
||||
return laterInflight;
|
||||
laterInflight.options.broadcastTimeline ||= deps.broadcastTimeline === true;
|
||||
return laterInflight.promise;
|
||||
}
|
||||
|
||||
const pendingOptions = {
|
||||
broadcastTimeline: deps.broadcastTimeline === true,
|
||||
};
|
||||
const initPromise = (async () => {
|
||||
const record = await requireLoadableRecord(agentId, deps);
|
||||
const record = await deps.agentStorage.get(agentId);
|
||||
if (!record) {
|
||||
throw new AgentNotFoundError(agentId);
|
||||
}
|
||||
|
||||
const validProviders = deps.validProviders ?? deps.agentManager.getRegisteredProviderIds();
|
||||
if (!isStoredAgentProviderAvailable(record, validProviders)) {
|
||||
throw new Error(`Agent ${agentId} references unavailable provider '${record.provider}'`);
|
||||
}
|
||||
|
||||
const handle = toAgentPersistenceHandle(validProviders, record.persistence);
|
||||
|
||||
let snapshot: ManagedAgent;
|
||||
@@ -151,16 +130,20 @@ export async function ensureAgentLoaded(
|
||||
deps.logger.info({ agentId, provider: record.provider }, "Agent created from stored config");
|
||||
}
|
||||
|
||||
await deps.agentManager.hydrateTimelineFromProvider(agentId, {
|
||||
broadcast: () => pendingOptions.broadcastTimeline,
|
||||
});
|
||||
return deps.agentManager.getAgent(agentId) ?? snapshot;
|
||||
})();
|
||||
|
||||
pendingAgentInitializations.set(agentId, initPromise);
|
||||
const pending: PendingAgentInitialization = { promise: initPromise, options: pendingOptions };
|
||||
pendingAgentInitializations.set(agentId, pending);
|
||||
|
||||
try {
|
||||
return await initPromise;
|
||||
} finally {
|
||||
const current = pendingAgentInitializations.get(agentId);
|
||||
if (current === initPromise) {
|
||||
if (current === pending) {
|
||||
pendingAgentInitializations.delete(agentId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,16 +3,12 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import pino, { type Logger } from "pino";
|
||||
|
||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||
import { AgentManager, type AgentManagerEvent } from "./agent-manager.js";
|
||||
import { AGENT_STREAM_COALESCE_DEFAULT_WINDOW_MS } from "./agent-stream-coalescer.js";
|
||||
import { limitAgentTimelineItemContent } from "./agent-timeline-content.js";
|
||||
import type { AgentTimelineRow } from "./agent-timeline-store-types.js";
|
||||
import { projectTimelineRows } from "./timeline-projection.js";
|
||||
import { AgentTimeline } from "./timeline/index.js";
|
||||
import { DeferredTimelineBackend } from "./timeline/testing.js";
|
||||
import type {
|
||||
AgentCapabilityFlags,
|
||||
AgentClient,
|
||||
@@ -182,7 +178,6 @@ class TestAgentSession implements AgentSession {
|
||||
class TestAgentClient implements AgentClient {
|
||||
readonly capabilities = TEST_CAPABILITIES;
|
||||
private sessionCounter = 0;
|
||||
private history: AgentStreamEvent[] = [];
|
||||
readonly sessions = new Map<string, TestAgentSession>();
|
||||
|
||||
constructor(readonly provider: AgentProvider = "codex") {}
|
||||
@@ -196,7 +191,6 @@ class TestAgentClient implements AgentClient {
|
||||
config,
|
||||
`${config.provider}-session-${++this.sessionCounter}`,
|
||||
);
|
||||
session.setHistory(this.history);
|
||||
this.sessions.set(config.cwd, session);
|
||||
return session;
|
||||
}
|
||||
@@ -232,11 +226,6 @@ class TestAgentClient implements AgentClient {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** The provider history of the sessions created from now on. */
|
||||
setHistory(events: AgentStreamEvent[]): void {
|
||||
this.history = [...events];
|
||||
}
|
||||
|
||||
getSession(cwd: string): TestAgentSession {
|
||||
const session = this.sessions.get(cwd);
|
||||
if (!session) {
|
||||
@@ -254,18 +243,13 @@ interface Harness {
|
||||
cleanup: () => void;
|
||||
}
|
||||
|
||||
function createHarness(options?: {
|
||||
provider?: AgentProvider;
|
||||
timeline?: AgentTimeline;
|
||||
logger?: Logger;
|
||||
}): Harness {
|
||||
function createHarness(options?: { provider?: AgentProvider }): Harness {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-stream-coalescing-"));
|
||||
const client = new TestAgentClient(options?.provider ?? "codex");
|
||||
const manager = new AgentManager({
|
||||
clients: { [client.provider]: client },
|
||||
idFactory: createIdFactory(),
|
||||
logger: options?.logger ?? createTestLogger(),
|
||||
timeline: options?.timeline,
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
const events: AgentManagerEvent[] = [];
|
||||
manager.subscribe((event) => events.push(event), { replayState: false });
|
||||
@@ -284,20 +268,6 @@ function createIdFactory(): () => string {
|
||||
return () => AGENT_IDS[index++] ?? `00000000-0000-4000-8000-${String(index).padStart(12, "0")}`;
|
||||
}
|
||||
|
||||
/** Resumes an agent whose history Paseo does not hold, so it imports the provider's. */
|
||||
async function resumeWithProviderHistory(
|
||||
harness: Harness,
|
||||
history: AgentStreamEvent[],
|
||||
): Promise<{ agentId: string }> {
|
||||
harness.client.setHistory(history);
|
||||
const agent = await harness.manager.resumeAgentFromPersistence(
|
||||
{ provider: harness.client.provider, sessionId: "provider-history" },
|
||||
{ cwd: harness.workdir },
|
||||
AGENT_IDS[0],
|
||||
);
|
||||
return { agentId: agent.id };
|
||||
}
|
||||
|
||||
async function createManagedSession(
|
||||
harness: Harness,
|
||||
options?: { agentId?: string; provider?: AgentProvider; workdir?: string },
|
||||
@@ -468,15 +438,15 @@ describe("target coalesced behavior", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("bounds tool output while importing provider history", async () => {
|
||||
test("bounds tool output while hydrating provider history", async () => {
|
||||
const harness = createHarness();
|
||||
try {
|
||||
const { agentId, session } = await createManagedSession(harness);
|
||||
const output = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`;
|
||||
const expectedItem = limitAgentTimelineItemContent(toolCall({ status: "completed", output }));
|
||||
session.setHistory([timelineEvent(toolCall({ status: "completed", output }))]);
|
||||
|
||||
const { agentId } = await resumeWithProviderHistory(harness, [
|
||||
timelineEvent(toolCall({ status: "completed", output })),
|
||||
]);
|
||||
await harness.manager.hydrateTimelineFromProvider(agentId, { force: true });
|
||||
|
||||
expect(getTimelineItems(await harness.manager.getTimelineRows(agentId))).toEqual([
|
||||
expectedItem,
|
||||
@@ -1067,81 +1037,6 @@ describe("target coalesced behavior", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test.each(["turn_completed", "turn_failed", "turn_canceled"] as const)(
|
||||
"%s waits for the final chunk an asynchronous timeline is still committing",
|
||||
async (terminalType) => {
|
||||
const harness = createHarness({ timeline: new AgentTimeline(new DeferredTimelineBackend()) });
|
||||
try {
|
||||
const { agentId, session } = await createManagedSession(harness);
|
||||
|
||||
session.pushEvent(assistant("final chunk", "codex", "turn"));
|
||||
session.pushEvent(terminalEvent(terminalType, "turn"));
|
||||
|
||||
const streamTypes = () =>
|
||||
getStreamEvents(harness.events, agentId).map((event) =>
|
||||
event.type === "agent_stream" ? event.event.type : event.type,
|
||||
);
|
||||
await expect.poll(streamTypes).toContain(terminalType);
|
||||
const streamEvents = getStreamEvents(harness.events, agentId);
|
||||
expect(streamEvents[0]).toMatchObject({
|
||||
event: { type: "timeline", item: { type: "assistant_message", text: "final chunk" } },
|
||||
seq: 1,
|
||||
});
|
||||
expect(streamTypes().indexOf(terminalType)).toBeGreaterThan(0);
|
||||
} finally {
|
||||
await harness.manager.flush();
|
||||
harness.cleanup();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test("a final chunk the timeline fails to commit surfaces a history error after the turn", async () => {
|
||||
const backend = new DeferredTimelineBackend();
|
||||
const logs: Array<Record<string, unknown>> = [];
|
||||
const harness = createHarness({
|
||||
timeline: new AgentTimeline(backend),
|
||||
logger: pino({ level: "error" }, { write: (line: string) => logs.push(JSON.parse(line)) }),
|
||||
});
|
||||
try {
|
||||
const { agentId, session } = await createManagedSession(harness);
|
||||
session.pushEvent(assistant("earlier chunk", "codex", "earlier"));
|
||||
session.pushEvent(terminalEvent("turn_completed", "earlier"));
|
||||
await expect.poll(() => getTimelineStreamEvents(harness.events, agentId)).toHaveLength(1);
|
||||
await harness.manager.flush();
|
||||
session.pushEvent({ type: "turn_started", provider: "codex", turnId: "turn" });
|
||||
await expect.poll(() => harness.manager.getAgent(agentId)?.lifecycle).toBe("running");
|
||||
backend.failNextAppend = true;
|
||||
session.pushEvent(assistant("final chunk", "codex", "turn"));
|
||||
session.pushEvent(terminalEvent("turn_completed", "turn"));
|
||||
|
||||
const completions = () =>
|
||||
getStreamEvents(harness.events, agentId).filter(
|
||||
(event) => event.type === "agent_stream" && event.event.type === "turn_completed",
|
||||
);
|
||||
await expect.poll(completions).toHaveLength(2);
|
||||
await harness.manager.flush();
|
||||
expect(harness.manager.getAgent(agentId)).toMatchObject({
|
||||
lifecycle: "error",
|
||||
lastError: "Conversation history could not be saved: disk full",
|
||||
attention: { requiresAttention: true, attentionReason: "error" },
|
||||
});
|
||||
const earlier = { type: "assistant_message", text: "earlier chunk" };
|
||||
expect(getTimelineStreamEvents(harness.events, agentId)).toEqual([
|
||||
expect.objectContaining({ event: expect.objectContaining({ item: earlier }) }),
|
||||
]);
|
||||
expect(getTimelineItems(await harness.manager.getTimelineRows(agentId))).toEqual([earlier]);
|
||||
expect(logs).toContainEqual(
|
||||
expect.objectContaining({
|
||||
msg: "Failed to record timeline item",
|
||||
agentId,
|
||||
err: expect.objectContaining({ message: "disk full" }),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("lifecycle paths are timer-safe", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
@@ -1362,16 +1257,14 @@ describe("target coalesced behavior", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("history import projects immediately without waiting for a streaming window", async () => {
|
||||
test("history hydration projects immediately without waiting for a streaming window", async () => {
|
||||
vi.useFakeTimers();
|
||||
const harness = createHarness();
|
||||
try {
|
||||
const { agentId } = await resumeWithProviderHistory(harness, [
|
||||
assistant("a1"),
|
||||
assistant("a2"),
|
||||
reasoning("r1"),
|
||||
reasoning("r2"),
|
||||
]);
|
||||
const { agentId, session } = await createManagedSession(harness);
|
||||
session.setHistory([assistant("a1"), assistant("a2"), reasoning("r1"), reasoning("r2")]);
|
||||
|
||||
await harness.manager.hydrateTimelineFromProvider(agentId, { force: true });
|
||||
|
||||
const rows = await harness.manager.getTimelineRows(agentId);
|
||||
expect(getTimelineItems(rows)).toEqual([
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user