From e5a1e79e8e4af790d38337cd5b8dd2fc10adadc5 Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Wed, 2 Sep 2026 03:49:40 -0700
Subject: [PATCH 01/26] docs(linux): say which package to install and how
updates arrive (#18123)
* docs(linux): say which package to install and how updates arrive
Closes #5188. Closes #10987.
The install guide's entire Linux section was "AppImage and `.deb` builds are
available. See the Releases page for details." It named two of the three
published packages, gave no basis for choosing between them, and said nothing
about updating -- which is the one thing that actually differs between them.
Separately, nothing human-facing said the Linux CLI is `orca-ide`; only
skills/orca-cli/SKILL.md carried it, which agents read and humans do not.
Install page now picks the package by update behaviour: the AppImage
self-updates, deb/rpm report the new version and hand over the install command,
and a repackaged build is not offered a download it cannot apply. Records that
Orca never escalates privileges for the package install, and points at #18086
for the signed repo as planned, not shipped.
Adds .rpm to the download list. Release CI builds it
(release-cut.yml: `--linux AppImage deb rpm`) and
verify-release-required-assets.mjs requires the artifact, so omitting it was
just wrong.
The CLI command name is now stated where humans hit it -- the CLI reference and
overview -- with the GNOME Orca collision as the reason, plus the two places
bare `orca` does work: inside Orca-managed terminals (PTY PATH shim) and on a
packaged `orca serve` host (the ~/.local/bin dispatcher). The headless guide
gains the same note, which is what makes its `orca skills install` lines
correct rather than a typo.
* docs(linux): fix install ordering, CLI verification, and serve bootstrap
Readiness review found ten defects. Two would have had a reader run the wrong
program, and one would have had them install a .deb over a live app.
Install ordering was reversed. The page said "run it, then quit and reopen
Orca"; the ref this is gated to land with says the opposite in four places
(linux-package-downloaded-status.ts LINUX_PACKAGE_MANUAL_INSTALL_MESSAGE,
"Quit Orca before running the system package install command", plus the
recovery card's title, summary and explainer). That wording came from main's
older run-then-quit card, which the stack deliberately reversed when it
retitled the card to "Manual Install Required". Now: quit first.
CLI verification put the Linux caveat *below* `command -v orca`. That check
succeeds on any GNOME desktop and resolves to the screen reader, so the reader
got a confident hit from the page's own verification step and then invoked the
wrong program. Caveat moved above, and the block now spells `orca-ide`
literally instead of asking the reader to substitute.
The serve bootstrap was circular: the bare-`orca` dispatcher is written *during*
serve startup (main-process-runtime-launch.ts), so it can never be the command
that starts serve. First launch is `orca-ide serve`. Fixed here and in the two
pages this links to.
Accuracy: the install command now matches what the code emits -- absolute paths
resolved from the trusted directories and a POSIX-single-quoted package path,
as pinned by linux-package-install-command.test.ts -- and names the manager
fallbacks (dpkg; zypper/dnf/yum/rpm) rather than presenting apt as the only
form. The pending path honours XDG_CACHE_HOME. rpm arch tokens are x86_64 and
aarch64, not deb's amd64/arm64. arm64 AppImage is linked. Dropped the container
example: isExternallyManagedLinuxInstall() needs a root marker AND no trusted
package manager, and a Debian-based container has apt, so it is not flagged.
---
docs/reference/headless-linux-server.md | 15 ++++++
docs/site/content/docs/cli/overview.mdx | 2 +-
docs/site/content/docs/cli/reference.mdx | 18 +++++++-
docs/site/content/docs/install.mdx | 56 +++++++++++++++++++++--
docs/site/content/docs/remote-servers.mdx | 8 ++++
docs/site/content/docs/ways-to-run.mdx | 4 +-
6 files changed, 96 insertions(+), 7 deletions(-)
diff --git a/docs/reference/headless-linux-server.md b/docs/reference/headless-linux-server.md
index 7368678c2e4..50a38cf446e 100644
--- a/docs/reference/headless-linux-server.md
+++ b/docs/reference/headless-linux-server.md
@@ -357,6 +357,21 @@ the command:
This disables a security boundary. Prefer a dedicated unprivileged service
user, especially when the listener is reachable beyond localhost.
+The Linux CLI is named `orca-ide`, not `orca`, so it never shadows the GNOME
+Orca screen reader at `/usr/bin/orca`. The `.deb` and `.rpm` packages put
+`orca-ide` on `PATH` themselves at install time; with the AppImage it arrives
+as `~/.local/bin/orca-ide` when the CLI is registered.
+
+A packaged `orca serve` start also writes a bare `orca` into `~/.local/bin`
+that execs the same launcher, which is why the skills commands below can be
+typed as `orca`. It writes it while starting, so it is never the command that
+starts the server — the first launch is `orca-ide serve`, or the AppImage
+invoked directly as above. The write is best-effort: it is gated on a packaged
+build, it is skipped when no bundled launcher resolves, and it is skipped when
+a file Orca does not own already holds that name (ownership is a marker on the
+second line of the file). A host that really does run the screen reader keeps
+its own `orca`.
+
## Pairing troubleshooting
- A pairing offer is a capability containing a device credential and E2EE
diff --git a/docs/site/content/docs/cli/overview.mdx b/docs/site/content/docs/cli/overview.mdx
index 3248e89e1eb..1e966c6217c 100644
--- a/docs/site/content/docs/cli/overview.mdx
+++ b/docs/site/content/docs/cli/overview.mdx
@@ -14,7 +14,7 @@ import { Callout } from '@/components/docs/prose'
The Orca CLI is the `orca` command-line interface for scripting a running Orca editor from any shell. Use it to create and inspect worktrees, drive agent terminals, open files and diffs, automate the built-in browser, run scheduled automations, share HTML/Markdown artifacts, and control Orca-native tools from scripts or AI agents.
-It ships with the desktop app; register it under [Settings → General → Orca CLI](/docs/settings).
+It ships with the desktop app; register it under [Settings → General → Orca CLI](/docs/settings). On Linux the command is `orca-ide`, because GNOME Orca's screen reader already owns `/usr/bin/orca` — see [Install → Linux](/docs/install#linux).
Agents can install the matching Orca CLI skill with:
diff --git a/docs/site/content/docs/cli/reference.mdx b/docs/site/content/docs/cli/reference.mdx
index 3df0773a4a7..a13ec0fdde4 100644
--- a/docs/site/content/docs/cli/reference.mdx
+++ b/docs/site/content/docs/cli/reference.mdx
@@ -9,13 +9,29 @@ The `orca` CLI talks to a running Orca runtime. Use it when a shell script or ag
## Verify the runtime
-Register the CLI under [Settings → General → Orca CLI](/docs/settings), then check that it can reach Orca:
+Register the CLI under [Settings → General → Orca CLI](/docs/settings), then check that it can reach Orca.
+
+
+ GNOME Orca — the screen reader that ships with most GNOME desktops — already owns `/usr/bin/orca`,
+ so Orca's Linux CLI installs as `orca-ide`. Do not check for it with `command -v orca`: that
+ succeeds on a GNOME desktop and resolves to the screen reader, not to Orca. This page writes
+ `orca` throughout — read it as `orca-ide` on Linux. See [Install → Linux](/docs/install#linux).
+
+
+On macOS and Windows:
```bash
command -v orca
orca status --json
```
+On Linux:
+
+```bash
+command -v orca-ide
+orca-ide status --json
+```
+
If Orca is not already running:
```bash
diff --git a/docs/site/content/docs/install.mdx b/docs/site/content/docs/install.mdx
index f710644d19e..9341cb0fa0d 100644
--- a/docs/site/content/docs/install.mdx
+++ b/docs/site/content/docs/install.mdx
@@ -31,8 +31,11 @@ import { Callout } from '@/components/docs/prose'
**Linux:**
- [AppImage](https://github.com/stablyai/orca/releases/latest/download/orca-linux.AppImage) ·
- [.deb](https://github.com/stablyai/orca/releases)
+ AppImage
+ [x64](https://github.com/stablyai/orca/releases/latest/download/orca-linux.AppImage) ·
+ [arm64](https://github.com/stablyai/orca/releases/latest/download/orca-linux-arm64.AppImage) ·
+ [.deb](https://github.com/stablyai/orca/releases) ·
+ [.rpm](https://github.com/stablyai/orca/releases) — see [Linux](#linux) for which to pick
@@ -59,6 +62,8 @@ On first launch Orca will:
Orca auto-updates by default, tracking the **stable** channel. Stable releases are vetted; **RC (release candidate)** builds ship new features first, often daily.
+On Linux, whether Orca can apply an update itself depends on which package you installed. See [Linux](#linux) before you pick one.
+
There is no permanent in-app opt-in for the RC channel. Modifier clicks on **Check for Updates** ([Settings → General → Updates](/docs/settings), or the app / Help menu):
| Modifier | Effect |
@@ -87,4 +92,49 @@ The default shell can be set to PowerShell or CMD under [Settings → Terminal](
### Linux
-AppImage and `.deb` builds are available. See the Releases page for details.
+Each published release ships three Linux packages — an **AppImage**, a **`.deb`**, and an **`.rpm`** — for both x64 and arm64. They contain the same app. What differs is how updates reach you, so pick on that.
+
+| Package | Pick it when | Updates |
+| ------------ | --------------------------------------------------------- | ----------------------------------------------------------------- |
+| **AppImage** | You want Orca to update itself, like on macOS and Windows | Orca downloads and applies the update in place |
+| **`.deb`** | You manage software with `apt` on Debian or Ubuntu | Orca tells you a version is out and hands you the install command |
+| **`.rpm`** | You manage software with `dnf`, `yum`, or `zypper` | Same as `.deb` |
+
+The AppImage has a stable download link per architecture — [`orca-linux.AppImage`](https://github.com/stablyai/orca/releases/latest/download/orca-linux.AppImage) for x64 and [`orca-linux-arm64.AppImage`](https://github.com/stablyai/orca/releases/latest/download/orca-linux-arm64.AppImage) for arm64 — and needs `chmod +x` before its first run, because GitHub release assets carry no permission bits. The `.deb` and `.rpm` filenames carry the version and architecture, and the two formats spell architecture differently (`orca-ide__amd64.deb` or `_arm64.deb`; `orca-ide-.x86_64.rpm` or `.aarch64.rpm`), so take those from the [Releases page](https://github.com/stablyai/orca/releases) rather than a fixed URL.
+
+#### How updating works
+
+**The AppImage self-updates.** Choose it if you want automatic updates. Orca checks for a new release, you click **Update**, and it replaces the AppImage in place — the same flow as macOS and Windows.
+
+**The `.deb` and `.rpm` do not self-update.** Orca still notices the new version and downloads the package, then gives you a **Copy Install Command** button. Copy it rather than retyping it: Orca resolves every program to an absolute path in a trusted system directory and single-quotes the package path, so what you paste looks like this:
+
+```
+/usr/bin/sudo /usr/bin/apt install -- '/home/you/.cache/orca-updater/pending/orca-ide_1.4.194_amd64.deb'
+```
+
+Which package manager appears depends on what your system actually has: `apt`, else `dpkg -i`, for a `.deb`; `zypper`, `dnf`, `yum`, then `rpm -Uvh` for an `.rpm`. The download directory follows `XDG_CACHE_HOME` when that is set and falls back to `~/.cache` when it is not.
+
+**Quit Orca before you run the command**, then reopen it once the install finishes. You are replacing the files of a running application, and the package manager cannot swap them safely underneath a live process. Orca deliberately never escalates privileges to do this for you: installing a system package needs root, `orca serve` runs as an unprivileged user, and a headless machine has no authentication agent to prompt. VS Code and Signal make the same call on `.deb`.
+
+**A distro-managed build is left alone.** If you are running a repackaged Orca — an AUR build, a Nix derivation — Orca sees that no package manager it can drive owns this install and stops offering a download it could never apply. It still reports that a new version exists, so you can update the way you normally would.
+
+
+ [#18086](https://github.com/stablyai/orca/issues/18086) tracks publishing a signed repository so
+ your OS package manager owns Orca updates the way it owns everything else. It does not exist yet —
+ today, `.deb` and `.rpm` updates are the manual step described above.
+
+
+#### The CLI command is `orca-ide`
+
+On Linux the [Orca CLI](/docs/cli/reference) installs as **`orca-ide`**, not `orca`. GNOME Orca — the screen reader that ships by default on Ubuntu and other GNOME desktops — already owns `/usr/bin/orca`, and Orca will not shadow it. The `.deb` and `.rpm` packages are named `orca-ide` for the same reason.
+
+- The `.deb` and `.rpm` put `orca-ide` on your `PATH` at install time, as `/usr/bin/orca-ide`.
+- With the AppImage, register the CLI from [Settings → General → Orca CLI](/docs/settings). That installs `~/.local/bin/orca-ide`.
+- Inside Orca's own terminals, bare `orca` works. Orca puts a shim on the `PATH` of the terminals it manages, so agents and scripts running there use the same command as on macOS and Windows.
+- On a headless host, a packaged `orca serve` writes a bare `orca` into `~/.local/bin` as it starts, unless a file it does not own already holds that name. It writes that *during* startup, so it is never what starts the server — the first launch is always [`orca-ide serve`](/docs/remote-servers).
+
+Do not verify with `command -v orca`: on a GNOME desktop that succeeds and resolves to the screen reader. Use `orca-ide` in your own shell and `orca` inside Orca. If you want the short name everywhere and you do not use the screen reader, link it yourself:
+
+```
+ln -s "$(command -v orca-ide)" ~/.local/bin/orca
+```
diff --git a/docs/site/content/docs/remote-servers.mdx b/docs/site/content/docs/remote-servers.mdx
index 63f94e35af8..37f86665d6e 100644
--- a/docs/site/content/docs/remote-servers.mdx
+++ b/docs/site/content/docs/remote-servers.mdx
@@ -126,6 +126,14 @@ Use `orca serve` when the host should run without the desktop window—for examp
Install Orca and its bundled CLI on the server, then run:
+
+ The Linux CLI is named `orca-ide`, because GNOME Orca's screen reader already owns
+ `/usr/bin/orca`. A packaged `orca serve` does write a bare `orca` into `~/.local/bin`, but only
+ while it is starting, so that shim can never be the command that starts the server. Read
+ `orca serve` as `orca-ide serve` throughout this page when the host is Linux. See
+ [Install → Linux](/docs/install#linux).
+
+
```bash
orca serve --pairing-address
```
diff --git a/docs/site/content/docs/ways-to-run.mdx b/docs/site/content/docs/ways-to-run.mdx
index 1a9c44ba1d1..e35b63eae90 100644
--- a/docs/site/content/docs/ways-to-run.mdx
+++ b/docs/site/content/docs/ways-to-run.mdx
@@ -52,10 +52,10 @@ Keep Orca running on a machine you control—an old laptop, Mac mini, home serve
**Easiest setup:** install Orca and Tailscale on both computers. On the server, open **Settings → Remote Orca Servers → Advertise this app as a server → New Link**, choose its Tailscale address, and generate an access link. On the client, choose **Add Server** and paste that link.
-For a headless Linux server or service-managed VM, use `orca serve` as the alternative:
+For a headless Linux server or service-managed VM, use `orca serve` as the alternative. On Linux the CLI is named `orca-ide`, so the first launch is:
```bash
-orca serve --pairing-address
+orca-ide serve --pairing-address
```
Full detail: [Remote Orca Servers](/docs/remote-servers).
From f737f3499f3f9194fc4984b110dd202e5d089856 Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Wed, 2 Sep 2026 05:36:54 -0700
Subject: [PATCH 02/26] fix(relay): stream an oversized fs.listFiles reply
instead of refusing it (#17954)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Opening Orca's own checkout over SSH cannot list its files in one response frame.
22,617 tracked paths average 58 characters, so the 20,001-row page the client asks
for serializes to 1,223,415 bytes — past `DISPATCHER_CONTROL_QUEUE_MAX_BYTES`, so
`sendResponse` demotes it to the `legacy-response` lane, where an unrelated
producer backlog can refuse it as an opaque `ResponseOverCapacity`. Break-even is
around 49 characters of average path; any `packages//src/...` monorepo is
over the line.
Picking a ceiling to refuse at does not fix that, it just moves where it shows up
and refuses listings that would have been delivered. `__streamResponse` already
exists for exactly this on the git methods, and it is its own negotiation in both
directions: an old client never sends it and gets the plain array on the
legacy-response lane as before, and an old relay ignores it and answers plainly,
which the client detects by the sentinel marker being absent. So fs.listFiles opts
into it — no new method, no new opcode, nothing to advertise — and the size of a
listing stops being a correctness question.
The response-stream registry becomes one per relay, shared by FsHandler and
GitHandler. A second registry is not an option and the header of
git-response-stream.ts says why: a client keys reassembly on `streamId` alone, so
two would hand out the same id and cross-feed chunks, and only the handler that
registers `git.responseAck` can credit the window a pump parks on.
Also declares `maxResults` on the runtime-RPC `files.listAll` and forwards it.
The mechanism "the client names its cap, so a full page reads as truncation" was
wired only on the Electron IPC hop; web and mobile were saved incidentally by
`remoteFileContentBudget` defaulting the cap inside `listRuntimeFiles`. A new
optional field is additive in both directions (wire rule 1).
The new Docker-gated spec is claimed by run-ssh-docker-e2e.mjs. The sharded e2e
lanes set no ORCA_E2E_SSH_DOCKER, so a Docker-gated spec that no runner names
self-skips everywhere and still reports green — pr-e2e-gate-contract enforces that.
Closes #12547
---
config/scripts/run-ssh-docker-e2e.mjs | 1 +
.../providers/ssh-filesystem-provider.test.ts | 44 +++---
src/main/providers/ssh-filesystem-provider.ts | 7 +-
.../methods/files-list-all-page-size.test.ts | 53 +++++++
src/main/runtime/rpc/methods/files.ts | 8 +-
src/relay/fs-handler.ts | 25 +++-
...t-files-large-response.integration.test.ts | 130 +++++++++++++++++
src/relay/git-handler.ts | 22 ++-
src/relay/git-response-stream.ts | 47 +++++-
src/relay/relay-runtime-services.ts | 9 +-
tests/e2e/helpers/docker-ssh-relay-target.ts | 10 ++
...sh-docker-quick-open-large-listing.spec.ts | 134 ++++++++++++++++++
12 files changed, 441 insertions(+), 49 deletions(-)
create mode 100644 src/main/runtime/rpc/methods/files-list-all-page-size.test.ts
create mode 100644 src/relay/fs-list-files-large-response.integration.test.ts
create mode 100644 tests/e2e/ssh-docker-quick-open-large-listing.spec.ts
diff --git a/config/scripts/run-ssh-docker-e2e.mjs b/config/scripts/run-ssh-docker-e2e.mjs
index 195811f7312..dddfa3e0148 100644
--- a/config/scripts/run-ssh-docker-e2e.mjs
+++ b/config/scripts/run-ssh-docker-e2e.mjs
@@ -71,6 +71,7 @@ const result = spawnSync(
'tests/e2e/ssh-ai-vault-session-history.spec.ts',
'tests/e2e/ssh-cold-activation-restore.spec.ts',
'tests/e2e/ssh-cold-hydration-gap-tab-seeding.spec.ts',
+ 'tests/e2e/ssh-docker-quick-open-large-listing.spec.ts',
'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts',
'tests/e2e/ssh-docker-transport-drop-recovery.spec.ts',
'tests/e2e/ssh-external-image-preview.spec.ts',
diff --git a/src/main/providers/ssh-filesystem-provider.test.ts b/src/main/providers/ssh-filesystem-provider.test.ts
index 65913f0c7e9..b9cb21c3354 100644
--- a/src/main/providers/ssh-filesystem-provider.test.ts
+++ b/src/main/providers/ssh-filesystem-provider.test.ts
@@ -486,14 +486,16 @@ describe('SshFilesystemProvider', () => {
expect(result).toEqual(searchResult)
})
- it('listFiles sends fs.listFiles request', async () => {
+ // Why #12547: a monorepo listing does not fit one control-lane frame, so the request opts into
+ // response streaming. An old relay ignores `__streamResponse` and answers plainly, which is the
+ // plain-array case each of these asserts.
+ it('listFiles sends a streamable fs.listFiles request', async () => {
mux.request.mockResolvedValue(['src/index.ts', 'package.json'])
const result = await provider.listFiles('/home/user/project')
- expect(mux.request).toHaveBeenCalledWith(
- 'fs.listFiles',
- { rootPath: '/home/user/project' },
- { signal: undefined }
- )
+ expect(mux.request).toHaveBeenCalledWith('fs.listFiles', {
+ rootPath: '/home/user/project',
+ __streamResponse: true
+ })
expect(result).toEqual(['src/index.ts', 'package.json'])
})
@@ -503,26 +505,22 @@ describe('SshFilesystemProvider', () => {
maxResults: 20_000,
searchQuery: 'target'
})
- expect(mux.request).toHaveBeenCalledWith(
- 'fs.listFiles',
- {
- rootPath: '/home/user/project',
- excludePaths: ['/home/user/project/worktrees/b'],
- maxResults: 20_000,
- searchQuery: 'target'
- },
- { signal: undefined }
- )
+ expect(mux.request).toHaveBeenCalledWith('fs.listFiles', {
+ rootPath: '/home/user/project',
+ excludePaths: ['/home/user/project/worktrees/b'],
+ maxResults: 20_000,
+ searchQuery: 'target',
+ __streamResponse: true
+ })
})
it('listFiles omits excludePaths when empty', async () => {
mux.request.mockResolvedValue([])
await provider.listFiles('/home/user/project', { excludePaths: [] })
- expect(mux.request).toHaveBeenCalledWith(
- 'fs.listFiles',
- { rootPath: '/home/user/project' },
- { signal: undefined }
- )
+ expect(mux.request).toHaveBeenCalledWith('fs.listFiles', {
+ rootPath: '/home/user/project',
+ __streamResponse: true
+ })
})
it('listFiles forwards the cancellation signal to the mux request (#7721)', async () => {
@@ -531,8 +529,8 @@ describe('SshFilesystemProvider', () => {
await provider.listFiles('/home/user/project', { signal: controller.signal })
expect(mux.request).toHaveBeenCalledWith(
'fs.listFiles',
- { rootPath: '/home/user/project' },
- { signal: controller.signal }
+ { rootPath: '/home/user/project', __streamResponse: true },
+ { signal: controller.signal, timeoutMs: undefined }
)
})
diff --git a/src/main/providers/ssh-filesystem-provider.ts b/src/main/providers/ssh-filesystem-provider.ts
index 70bb06730f8..f6208ea00e9 100644
--- a/src/main/providers/ssh-filesystem-provider.ts
+++ b/src/main/providers/ssh-filesystem-provider.ts
@@ -1,6 +1,7 @@
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
import { isMethodNotFoundError, readFileViaStream } from '../ssh/ssh-filesystem-stream-reader'
import { uploadBuffer } from '../ssh/sftp-upload'
+import { requestGitStreamable } from '../ssh/ssh-git-response-stream-reader'
import { lstatViaSftp } from './ssh-filesystem-provider-sftp'
import {
downloadFileViaSftp,
@@ -314,7 +315,11 @@ export class SshFilesystemProvider implements IFilesystemProvider {
// Why #7721: the signal lets a workspace switch send rpc.cancel so the
// relay aborts the full-tree scan instead of stacking abandoned scans
// that starve interactive fs.readDir/fs.stat on the shared SSH channel.
- return (await this.mux.request('fs.listFiles', params, {
+ // Why streamable: a monorepo listing serializes past the relay's 1 MiB control lane, and the
+ // lane it demotes to is refused under unrelated producer load. Opting in moves it to the bulk
+ // lane in chunks; an old relay ignores the flag and answers plainly, which the reader detects
+ // by the sentinel marker being absent.
+ return (await requestGitStreamable(this.mux, 'fs.listFiles', params, {
signal: options?.signal
})) as string[]
}
diff --git a/src/main/runtime/rpc/methods/files-list-all-page-size.test.ts b/src/main/runtime/rpc/methods/files-list-all-page-size.test.ts
new file mode 100644
index 00000000000..826d0c0f3f0
--- /dev/null
+++ b/src/main/runtime/rpc/methods/files-list-all-page-size.test.ts
@@ -0,0 +1,53 @@
+/**
+ * #12547: `files.listAll` did not declare `maxResults`, so "the client names its cap and a full page
+ * means there is more" was wired only on the Electron IPC hop. Web and mobile were saved incidentally,
+ * by `remoteFileContentBudget` defaulting the cap inside `listRuntimeFiles`.
+ */
+import { describe, expect, it, vi } from 'vitest'
+import { RpcDispatcher } from '../dispatcher'
+import type { RpcRequest } from '../core'
+import type { OrcaRuntimeService } from '../../orca-runtime'
+import { FILE_METHODS } from './files'
+
+function makeRequest(method: string, params?: unknown): RpcRequest {
+ return { id: 'req-1', authToken: 'tok', method, params }
+}
+
+describe('files.listAll page size', () => {
+ // Why #12547: `maxResults` was wired only on the Electron IPC hop, so "a full page means there is
+ // more" was true for a desktop client and incidental for web/mobile. Declaring it here is a new
+ // optional field (wire rule 1): an older host strips it and keeps its own default.
+ it('forwards a client-named page size for a selected worktree', async () => {
+ const runtime = {
+ getRuntimeId: () => 'test-runtime',
+ listRuntimeFiles: vi.fn().mockResolvedValue(['src/index.ts'])
+ } as unknown as OrcaRuntimeService
+ const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS })
+
+ const response = await dispatcher.dispatch(
+ makeRequest('files.listAll', { worktree: 'id:wt-1', maxResults: 20_001 })
+ )
+
+ expect(runtime.listRuntimeFiles).toHaveBeenCalledWith('id:wt-1', {
+ excludePaths: undefined,
+ maxResults: 20_001
+ })
+ expect(response).toMatchObject({ ok: true, result: ['src/index.ts'] })
+ })
+
+ // Why refuse rather than fall back: no released client sends this field, so a malformed value is a
+ // bug in the caller, not skew — the same call `files.search` already makes for its own maxResults.
+ it('refuses a malformed page size instead of silently picking one', async () => {
+ const runtime = {
+ getRuntimeId: () => 'test-runtime',
+ listRuntimeFiles: vi.fn().mockResolvedValue(['src/index.ts'])
+ } as unknown as OrcaRuntimeService
+ const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS })
+
+ const response = await dispatcher.dispatch(
+ makeRequest('files.listAll', { worktree: 'id:wt-1', maxResults: -3 })
+ )
+
+ expect(response).toMatchObject({ ok: false })
+ })
+})
diff --git a/src/main/runtime/rpc/methods/files.ts b/src/main/runtime/rpc/methods/files.ts
index d4aaae455bc..ef349a22f84 100644
--- a/src/main/runtime/rpc/methods/files.ts
+++ b/src/main/runtime/rpc/methods/files.ts
@@ -93,8 +93,13 @@ const FileSearch = WorktreeSelector.extend({
maxResults: z.number().int().positive().optional()
})
+// Why: `maxResults` is a new optional field (wire rule 1) — an older host strips it and keeps its
+// own default. It existed only on the Electron IPC hop, so "the client names its cap and a full page
+// means there is more" was true for desktop and merely incidental for web and mobile, which were
+// saved by `remoteFileContentBudget` defaulting the cap inside `listRuntimeFiles`.
const FileListAll = WorktreeSelector.extend({
- excludePaths: z.array(z.string()).optional()
+ excludePaths: z.array(z.string()).optional(),
+ maxResults: z.number().int().positive().optional()
})
const FileUnwatch = z.object({
@@ -236,6 +241,7 @@ export const FILE_METHODS: RpcAnyMethod[] = [
const maxContentBytes = remoteFileContentBudget(clientKind, requestId)
return runtime.listRuntimeFiles(params.worktree, {
excludePaths: params.excludePaths,
+ ...(params.maxResults === undefined ? {} : { maxResults: params.maxResults }),
...(signal === undefined ? {} : { signal }),
...(maxContentBytes === undefined ? {} : { maxContentBytes })
})
diff --git a/src/relay/fs-handler.ts b/src/relay/fs-handler.ts
index f8514a47191..ec11a806bb8 100644
--- a/src/relay/fs-handler.ts
+++ b/src/relay/fs-handler.ts
@@ -25,6 +25,7 @@ import {
writeRelayFile
} from './fs-path-mutation-requests'
import { buildExcludePathPrefixes } from '../shared/quick-open-filter'
+import { maybeStreamRpcResponse, type GitResponseStreamRegistry } from './git-response-stream'
import { readRelayFileContent, readRelayFileStreamMetadata } from './fs-handler-file-read'
import { readRelayFileRange } from './fs-handler-file-range'
import { FileRangeReadRequestError } from '../shared/file-range-read'
@@ -47,12 +48,19 @@ export class FsHandler {
private watchRegistry: RelayFilesystemWatchRegistry
private streamRegistry = new RelayStreamRegistry()
private listFilesScans = new ListFilesScanCoordinator()
+ private readonly responseStreams: GitResponseStreamRegistry | undefined
constructor(
dispatcher: RelayDispatcher,
_context: RelayContext,
- watcherPool?: RelayWatcherProcessPool
+ watcherPool?: RelayWatcherProcessPool,
+ // Why passed in rather than owned: GitHandler registers the `git.responseAck` route every pump
+ // is credited through, and a client keys reassembly on `streamId` alone — see the header of
+ // git-response-stream.ts. Without one this handler answers plainly, which is the pre-streaming
+ // behavior rather than a stream nothing can credit.
+ responseStreams?: GitResponseStreamRegistry
) {
+ this.responseStreams = responseStreams
this.dispatcher = dispatcher
this.watchRegistry = new RelayFilesystemWatchRegistry(dispatcher, watcherPool)
this.registerHandlers()
@@ -204,7 +212,10 @@ export class FsHandler {
}
}
- private listFiles(params: Record, context?: RequestContext): Promise {
+ private async listFiles(
+ params: Record,
+ context?: RequestContext
+ ): Promise {
const rootPath = expandTilde(params.rootPath as string)
const maxResults =
typeof params.maxResults === 'number' &&
@@ -224,13 +235,21 @@ export class FsHandler {
// Why #7721: full-tree scans are the relay's most expensive request; the
// coordinator caps them at one per client, coalescing duplicates and
// aborting a stale scan when the workspace changes or the host cancels.
- return this.listFilesScans.run({
+ const files = await this.listFilesScans.run({
clientId: context?.clientId ?? 0,
key: JSON.stringify([rootPath, excludePathPrefixes, maxResults, searchQuery]),
signal: context?.signal,
start: (signal) =>
runListFilesScan(rootPath, excludePathPrefixes, signal, maxResults, searchQuery)
})
+ // Why: a full listing of a real monorepo serializes past the 1 MiB control lane — Orca's own
+ // checkout is 22.6k paths averaging 58 characters, so a 20,001-row page is ~1.2MB — and the
+ // legacy-response lane it demotes to is refused under unrelated producer load. Streaming makes
+ // size stop being a correctness question instead of picking a row or byte ceiling to refuse at.
+ // A client that did not opt in still gets the plain array, exactly as before.
+ return this.responseStreams
+ ? maybeStreamRpcResponse(files, params, context, this.responseStreams, this.dispatcher)
+ : files
}
private async workspaceSpaceScan(params: Record, context: RequestContext) {
diff --git a/src/relay/fs-list-files-large-response.integration.test.ts b/src/relay/fs-list-files-large-response.integration.test.ts
new file mode 100644
index 00000000000..27c7ee7e648
--- /dev/null
+++ b/src/relay/fs-list-files-large-response.integration.test.ts
@@ -0,0 +1,130 @@
+/**
+ * #12547: a full `fs.listFiles` reply for a real monorepo does not fit the relay's control lane.
+ *
+ * Orca's own checkout is ~22.6k tracked paths averaging 58 characters, so a 20,001-row page
+ * serializes to ~1.2MB — past `DISPATCHER_CONTROL_QUEUE_MAX_BYTES`, which demotes it to the
+ * `legacy-response` lane where an unrelated producer backlog can refuse it. Refusing at a fixed row
+ * or byte ceiling only moves where that shows up; streaming removes it, so these run the real
+ * dispatcher, the real FsHandler and the real client multiplexer over an in-memory pipe and assert
+ * an over-budget listing arrives intact — in both wire directions.
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { runListFilesScanMock } = vi.hoisted(() => ({ runListFilesScanMock: vi.fn() }))
+
+vi.mock('./fs-list-files-fallback-chain', () => ({ runListFilesScan: runListFilesScanMock }))
+vi.mock('@parcel/watcher', () => ({ subscribe: vi.fn() }))
+
+import {
+ SshChannelMultiplexer,
+ type MultiplexerTransport
+} from '../main/ssh/ssh-channel-multiplexer'
+import { requestGitStreamable } from '../main/ssh/ssh-git-response-stream-reader'
+import { RelayContext } from './context'
+import { RelayDispatcher } from './dispatcher'
+import { DISPATCHER_CONTROL_QUEUE_MAX_BYTES } from './dispatcher-writer-admission'
+import { FsHandler } from './fs-handler'
+import { GitHandler } from './git-handler'
+import { GitResponseStreamRegistry } from './git-response-stream'
+import { QUICK_OPEN_LISTING_MAX_RESULTS } from '../shared/quick-open-listing-limits'
+
+/** Shaped like this repository: `packages//src/...`, ~58 characters. */
+function monorepoPaths(count: number): string[] {
+ return Array.from(
+ { length: count },
+ (_, index) =>
+ `packages/pkg-${String(index % 64).padStart(2, '0')}/src/renderer/components/entry-${String(index).padStart(6, '0')}.tsx`
+ )
+}
+
+describe('Integration: an over-budget fs.listFiles reply (#12547)', () => {
+ let mux: SshChannelMultiplexer
+ let dispatcher: RelayDispatcher
+ let fsHandler: FsHandler
+ let gitHandler: GitHandler
+ let writtenFrames: number[]
+
+ beforeEach(() => {
+ runListFilesScanMock.mockReset()
+ writtenFrames = []
+
+ let relayFeed: (data: Buffer) => void
+ const clientDataCallbacks: ((data: Buffer) => void)[] = []
+ const clientTransport: MultiplexerTransport = {
+ write: (data: Buffer) => {
+ setImmediate(() => relayFeed?.(data))
+ },
+ onData: (cb) => {
+ clientDataCallbacks.push(cb)
+ },
+ onClose: () => {}
+ }
+ dispatcher = new RelayDispatcher((data: Buffer) => {
+ writtenFrames.push(data.length)
+ setImmediate(() => {
+ for (const cb of clientDataCallbacks) {
+ cb(data)
+ }
+ })
+ return true
+ })
+ relayFeed = (data: Buffer) => dispatcher.feed(data)
+ // Why: the same single registry production wires, so `git.responseAck` — registered by
+ // GitHandler — credits the pump an fs.listFiles stream parks on.
+ const responseStreams = new GitResponseStreamRegistry()
+ const context = new RelayContext()
+ fsHandler = new FsHandler(dispatcher, context, undefined, responseStreams)
+ gitHandler = new GitHandler(dispatcher, context, undefined, responseStreams)
+ mux = new SshChannelMultiplexer(clientTransport)
+ })
+
+ afterEach(() => {
+ mux.dispose()
+ dispatcher.dispose()
+ fsHandler.dispose()
+ gitHandler.dispose()
+ })
+
+ it('delivers a page too large for the control lane, in chunks no frame has to carry', async () => {
+ const files = monorepoPaths(QUICK_OPEN_LISTING_MAX_RESULTS)
+ // Precondition, measured from the payload rather than asserted between two constants: this is
+ // the listing that does not fit, which is what makes the rest of the test mean anything.
+ expect(Buffer.byteLength(JSON.stringify(files), 'utf8')).toBeGreaterThan(
+ DISPATCHER_CONTROL_QUEUE_MAX_BYTES
+ )
+ runListFilesScanMock.mockResolvedValue(files)
+
+ const received = await requestGitStreamable(mux, 'fs.listFiles', {
+ rootPath: '/remote/root',
+ maxResults: QUICK_OPEN_LISTING_MAX_RESULTS
+ })
+
+ expect(received).toEqual(files)
+ expect(Math.max(...writtenFrames)).toBeLessThan(DISPATCHER_CONTROL_QUEUE_MAX_BYTES)
+ })
+
+ it('still answers a client that never opts into streaming, with the whole array', async () => {
+ const files = monorepoPaths(QUICK_OPEN_LISTING_MAX_RESULTS)
+ runListFilesScanMock.mockResolvedValue(files)
+
+ // Why: an old client sends neither `__streamResponse` nor `maxResults`. It gets one plain frame
+ // on the legacy-response lane, as it did before this call ever learned to stream.
+ const received = await mux.request('fs.listFiles', { rootPath: '/remote/root' })
+
+ expect(received).toEqual(files)
+ expect(Math.max(...writtenFrames)).toBeGreaterThan(DISPATCHER_CONTROL_QUEUE_MAX_BYTES)
+ })
+
+ it('leaves a reply that fits on the plain response path', async () => {
+ const files = monorepoPaths(100)
+ runListFilesScanMock.mockResolvedValue(files)
+
+ const received = await requestGitStreamable(mux, 'fs.listFiles', {
+ rootPath: '/remote/root',
+ maxResults: 100
+ })
+
+ expect(received).toEqual(files)
+ expect(Math.max(...writtenFrames)).toBeLessThan(DISPATCHER_CONTROL_QUEUE_MAX_BYTES)
+ })
+})
diff --git a/src/relay/git-handler.ts b/src/relay/git-handler.ts
index 58f47da9fce..66bbd6d3cd1 100644
--- a/src/relay/git-handler.ts
+++ b/src/relay/git-handler.ts
@@ -10,8 +10,7 @@ import {
createSubmodulePathsCache,
type SubmodulePathsCache
} from './git-handler-submodule-ops'
-import { GitResponseStreamRegistry } from './git-response-stream'
-import { GIT_RESPONSE_STREAM_THRESHOLD } from './protocol'
+import { GitResponseStreamRegistry, maybeStreamRpcResponse } from './git-response-stream'
import { clearGitStatusLineStatsCache } from '../shared/git-status-line-stats-cache'
import { invalidateGitBranchLineTotalInFlight } from '../shared/git-branch-line-total'
import { buildRelayGitEnv, buildRelayUnattendedGitEnv } from './relay-command-env'
@@ -68,9 +67,6 @@ export class GitHandler {
private dispatcher: RelayDispatcher
private readonly gitDiffReadDedupe = new InFlightPromiseDedupe()
private readonly gitCapabilities = new GitCapabilityCache()
- // Why: use the bulk lane so large responses do not block interactive PTY echo.
- private readonly responseStreams = new GitResponseStreamRegistry()
-
// Why: cache .gitmodules per instance to avoid SSH reads and test leakage.
private submodulePathsCache: SubmodulePathsCache = createSubmodulePathsCache()
@@ -78,7 +74,12 @@ export class GitHandler {
constructor(
dispatcher: RelayDispatcher,
_context: RelayContext,
- private readonly watcherRegistry?: GitHandlerWatcherRegistry
+ private readonly watcherRegistry?: GitHandlerWatcherRegistry,
+ // Why: use the bulk lane so large responses do not block interactive PTY echo. This handler
+ // registers the `git.responseAck` route below, so in production it takes the relay's single
+ // registry and FsHandler is handed the same one — see the header of git-response-stream.ts for
+ // why a second registry both collides on stream ids and stalls on credit.
+ private readonly responseStreams: GitResponseStreamRegistry = new GitResponseStreamRegistry()
) {
this.dispatcher = dispatcher
const handlers = createGitHandlerOperationSet({
@@ -132,14 +133,7 @@ export class GitHandler {
params: Record,
context: RequestContext | undefined
): unknown {
- if (params.__streamResponse !== true || !context) {
- return result
- }
- const payload = Buffer.from(JSON.stringify(result ?? null), 'utf-8')
- if (payload.length <= GIT_RESPONSE_STREAM_THRESHOLD) {
- return result
- }
- return this.responseStreams.startStream(payload, this.dispatcher, context)
+ return maybeStreamRpcResponse(result, params, context, this.responseStreams, this.dispatcher)
}
private clearGitMutationReadCaches(): void {
diff --git a/src/relay/git-response-stream.ts b/src/relay/git-response-stream.ts
index 3ccbd66ee8d..c9d8d2ce290 100644
--- a/src/relay/git-response-stream.ts
+++ b/src/relay/git-response-stream.ts
@@ -1,11 +1,22 @@
-// Streams large git RPC responses (diff family + exec) onto the bulk lane in
-// chunks instead of one JSON-RPC frame, so a big diff cannot head-of-line-block
-// interactive pty.data echo on the shared SSH channel. Mirrors the fs
-// read-stream credit-window pattern (see fs-handler-file-read.ts) but the
-// payload is an in-memory serialized string rather than a file handle.
+// Streams large RPC responses onto the bulk lane in chunks instead of one
+// JSON-RPC frame, so a big reply cannot head-of-line-block interactive pty.data
+// echo on the shared SSH channel. Mirrors the fs read-stream credit-window
+// pattern (see fs-handler-file-read.ts) but the payload is an in-memory
+// serialized string rather than a file handle.
+//
+// ONE REGISTRY PER RELAY. The `git.*` method names below are the shipped wire
+// spelling and are permanent, the way an opcode number is, so a second handler
+// that needs streaming (`fs.listFiles` is the first) shares this instance rather
+// than minting its own. A second registry is not an option: a client keys
+// reassembly on `streamId` alone, so two would hand out the same id and
+// cross-feed each other's chunks, and only the handler that registers
+// `git.responseAck` can credit the ack window a pump parks on — the other's
+// streams would stall at STREAM_ACK_WINDOW_CHUNKS forever. See
+// `relay-runtime-services.ts` for the wiring.
import type { RelayDispatcher, RequestContext } from './dispatcher'
import {
GIT_RESPONSE_CHUNK_SIZE,
+ GIT_RESPONSE_STREAM_THRESHOLD,
STREAM_ACK_WINDOW_CHUNKS,
STREAM_ACK_STALL_RECHECK_MS,
type GitResponseStreamMarker
@@ -220,3 +231,29 @@ export class GitResponseStreamRegistry {
this.streams.clear()
}
}
+
+/**
+ * Opt-in response streaming, shared by every handler that can answer with a
+ * payload too large for one control-lane frame.
+ *
+ * `__streamResponse` is its own negotiation in both directions: an old client
+ * never sends it and gets the plain result, and an old relay ignores it and
+ * answers plainly, which the client detects by the sentinel marker being absent.
+ * So there is no new method and no capability to advertise.
+ */
+export function maybeStreamRpcResponse(
+ result: unknown,
+ params: Record,
+ context: RequestContext | undefined,
+ registry: GitResponseStreamRegistry,
+ dispatcher: RelayDispatcher
+): unknown {
+ if (params.__streamResponse !== true || !context) {
+ return result
+ }
+ const payload = Buffer.from(JSON.stringify(result ?? null), 'utf-8')
+ if (payload.length <= GIT_RESPONSE_STREAM_THRESHOLD) {
+ return result
+ }
+ return registry.startStream(payload, dispatcher, context)
+}
diff --git a/src/relay/relay-runtime-services.ts b/src/relay/relay-runtime-services.ts
index 36276ed9b70..485c9e787d1 100644
--- a/src/relay/relay-runtime-services.ts
+++ b/src/relay/relay-runtime-services.ts
@@ -6,6 +6,7 @@ import { RelayContext, expandTilde } from './context'
import { PtyHandler } from './pty-handler'
import { FsHandler } from './fs-handler'
import { GitHandler } from './git-handler'
+import { GitResponseStreamRegistry } from './git-response-stream'
import { PreflightHandler } from './preflight-handler'
import { ExternalAutomationsHandler } from './external-automations-handler'
import { PortScanHandler } from './port-scan-handler'
@@ -51,13 +52,17 @@ export class RelayRuntimeServices {
)
this.ptyHandler.setSourcePublication(this.ptySourcePublication)
- this.fsHandler = new FsHandler(dispatcher, context)
+ // Why one instance for both handlers: a client reassembles a streamed reply by `streamId` alone,
+ // so two registries would hand out the same id, and only GitHandler routes the `git.responseAck`
+ // credit every pump waits on. A second registry is not an option — see git-response-stream.ts.
+ const responseStreams = new GitResponseStreamRegistry()
+ this.fsHandler = new FsHandler(dispatcher, context, undefined, responseStreams)
const watchRegistry = this.fsHandler.getWatchRegistry()
this.ptyHandler.setWorktreeRemovalCoordinator(watchRegistry)
watchRegistry.setWorktreePtyTeardown((rootPath) =>
this.ptyHandler.shutdownForWorktreePath(rootPath)
)
- this.gitHandler = new GitHandler(dispatcher, context, watchRegistry)
+ this.gitHandler = new GitHandler(dispatcher, context, watchRegistry, responseStreams)
const preflightHandler = new PreflightHandler(dispatcher)
this.skillInstallHandler = new SkillInstallHandler(dispatcher)
const externalAutomationsHandler = new ExternalAutomationsHandler(dispatcher)
diff --git a/tests/e2e/helpers/docker-ssh-relay-target.ts b/tests/e2e/helpers/docker-ssh-relay-target.ts
index 78534499943..f535b22d073 100644
--- a/tests/e2e/helpers/docker-ssh-relay-target.ts
+++ b/tests/e2e/helpers/docker-ssh-relay-target.ts
@@ -211,6 +211,16 @@ export function writeDockerSshRelayTargetFile(
)
}
+/** Why not `writeDockerSshRelayTargetFile`: that one passes the contents as a shell argument, so a
+ * payload the size of a real repository's path list exceeds ARG_MAX before it reaches the shell. */
+export function copyFileIntoDockerSshRelayTarget(
+ target: DockerSshRelayTarget,
+ localPath: string,
+ remotePath: string
+): void {
+ run('docker', ['cp', localPath, `${target.containerName}:${remotePath}`], { timeoutMs: 120_000 })
+}
+
export function startDockerSshRelayTarget(testInfo: TestInfo): DockerSshRelayTarget {
const host = process.env.ORCA_E2E_SSH_TARGET_HOST?.trim() || '127.0.0.1'
if (host === 'localhost' || host === '::1' || host.startsWith('127.')) {
diff --git a/tests/e2e/ssh-docker-quick-open-large-listing.spec.ts b/tests/e2e/ssh-docker-quick-open-large-listing.spec.ts
new file mode 100644
index 00000000000..ee814ab02db
--- /dev/null
+++ b/tests/e2e/ssh-docker-quick-open-large-listing.spec.ts
@@ -0,0 +1,134 @@
+/**
+ * #12547 acceptance: open a repository the size of Orca's own checkout over SSH and list its files.
+ *
+ * The remote tree is seeded from this repository's real `git ls-files` output, so the payload has
+ * the shape that broke: ~22.6k paths averaging 58 characters, whose 20,001-row page serializes to
+ * ~1.2MB — past `DISPATCHER_CONTROL_QUEUE_MAX_BYTES`. Both wire directions are exercised over the
+ * real relay: a current client, and a client that names no `maxResults` at all.
+ */
+import { execFileSync } from 'node:child_process'
+import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import path from 'node:path'
+
+import { expect, test } from './helpers/orca-app'
+import { connectDockerSshRelayTarget } from './helpers/docker-ssh-relay-connection'
+import {
+ cleanupDockerSshRelayTarget,
+ copyFileIntoDockerSshRelayTarget,
+ execDockerSshRelayTargetCommand,
+ shellQuote,
+ startDockerSshRelayTarget,
+ type DockerSshRelayTarget
+} from './helpers/docker-ssh-relay-target'
+import { ensureDockerSshRelayImage } from './helpers/docker-ssh-relay-image'
+import { waitForSessionReady } from './helpers/store'
+import { shouldIncludeQuickOpenPath } from '../../src/shared/quick-open-filter'
+
+const RUN_DOCKER_SSH = process.env.ORCA_E2E_SSH_DOCKER === '1'
+const REMOTE_REPO_PATH = '/tmp/orca-quick-open-large-listing-repo'
+const REMOTE_PATH_LIST = '/tmp/orca-quick-open-large-listing-paths.txt'
+/** What the desktop client asks for; a full page is what it reads as "there is more". */
+const CLIENT_PAGE_SIZE = 20_001
+
+function thisRepositoryTrackedPaths(): string[] {
+ const root = execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' }).trim()
+ // Why -z: `git ls-files` C-quotes any path with a special character, which would seed a tree that
+ // does not match the one being measured.
+ return execFileSync('git', ['ls-files', '-z'], {
+ cwd: root,
+ encoding: 'utf8',
+ maxBuffer: 1024 * 1024 * 256
+ })
+ .split('\0')
+ .filter(Boolean)
+}
+
+function seedRemoteTree(target: DockerSshRelayTarget, paths: string[]): void {
+ const stagingDir = mkdtempSync(path.join(tmpdir(), 'orca-quick-open-large-listing-'))
+ try {
+ const localList = path.join(stagingDir, 'paths.txt')
+ writeFileSync(localList, `${paths.join('\n')}\n`)
+ copyFileIntoDockerSshRelayTarget(target, localList, REMOTE_PATH_LIST)
+ } finally {
+ rmSync(stagingDir, { recursive: true, force: true })
+ }
+ const seedScript = [
+ "const fs = require('fs'), path = require('path')",
+ `const list = fs.readFileSync(${JSON.stringify(REMOTE_PATH_LIST)}, 'utf8').split('\\n').filter(Boolean)`,
+ 'const seen = new Set()',
+ 'for (const entry of list) {',
+ ' const dir = path.dirname(entry)',
+ ' if (dir !== "." && !seen.has(dir)) { fs.mkdirSync(dir, { recursive: true }); seen.add(dir) }',
+ " fs.writeFileSync(entry, '')",
+ '}'
+ ].join(';')
+ const encoded = Buffer.from(seedScript, 'utf8').toString('base64')
+ execDockerSshRelayTargetCommand(
+ target,
+ [
+ `rm -rf ${shellQuote(REMOTE_REPO_PATH)}`,
+ `mkdir -p ${shellQuote(REMOTE_REPO_PATH)}`,
+ `cd ${shellQuote(REMOTE_REPO_PATH)}`,
+ 'git init -q',
+ 'git config user.email e2e@test.local',
+ 'git config user.name "Orca Docker SSH E2E"',
+ `node -e ${shellQuote(`eval(Buffer.from('${encoded}', 'base64').toString('utf8'))`)}`,
+ 'git add -A',
+ 'git commit -q -m "seed monorepo-shaped tree"'
+ ].join(' && ')
+ )
+}
+
+test.skip(!RUN_DOCKER_SSH, 'Set ORCA_E2E_SSH_DOCKER=1 to run the Docker SSH relay lane')
+
+test('lists a monorepo-sized remote workspace, with and without a client page size (#12547)', async ({
+ orcaPage
+}, testInfo) => {
+ test.setTimeout(420_000)
+ let target: DockerSshRelayTarget | null = null
+ try {
+ const trackedPaths = thisRepositoryTrackedPaths()
+ expect(trackedPaths.length).toBeGreaterThan(CLIENT_PAGE_SIZE)
+ // Why the real predicate rather than a copy of it: Quick Open prunes a few tracked paths on
+ // purpose (`.husky/` among them), and a hand-written expectation would go stale the first time
+ // that list changes and read as a transport bug.
+ const listablePaths = trackedPaths.filter(shouldIncludeQuickOpenPath)
+ // Precondition, measured rather than assumed: the page a current client asks for does not fit
+ // one control-lane frame, which is the listing that used to be refused outright.
+ expect(
+ Buffer.byteLength(JSON.stringify(trackedPaths.slice(0, CLIENT_PAGE_SIZE)), 'utf8')
+ ).toBeGreaterThan(1024 * 1024)
+
+ ensureDockerSshRelayImage(process.cwd())
+ target = startDockerSshRelayTarget(testInfo)
+ seedRemoteTree(target, trackedPaths)
+
+ await waitForSessionReady(orcaPage)
+ const connected = await connectDockerSshRelayTarget(orcaPage, target, {
+ remotePath: REMOTE_REPO_PATH
+ })
+
+ const listFiles = async (maxResults?: number): Promise =>
+ orcaPage.evaluate(
+ ({ connectionId, rootPath, maxResults }) =>
+ window.api.fs.listFiles({
+ rootPath,
+ connectionId,
+ ...(maxResults === undefined ? {} : { maxResults })
+ }),
+ { connectionId: connected.targetId, rootPath: REMOTE_REPO_PATH, maxResults }
+ )
+
+ const currentClient = await listFiles(CLIENT_PAGE_SIZE)
+ expect(currentClient).toHaveLength(CLIENT_PAGE_SIZE)
+
+ // Why: a client that predates `maxResults` on this call sends none at all, and it cannot
+ // reassemble a streamed reply either — it has to be answered on the plain response path.
+ const oldClient = await listFiles()
+ expect(oldClient).toHaveLength(listablePaths.length)
+ expect(new Set(oldClient)).toEqual(new Set(listablePaths))
+ } finally {
+ cleanupDockerSshRelayTarget(target)
+ }
+})
From 8dc3c1dd9787935bd14f9bab03f5ebdbe706a8e5 Mon Sep 17 00:00:00 2001
From: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Date: Wed, 2 Sep 2026 10:21:28 -0700
Subject: [PATCH 03/26] Display favicons for browser website entries (#18099)
* Display favicons for browser website entries
Capture favicons from pages as they load and persist them with browser
history entries. Display favicons in tabs, tab creation search results,
and palette searches to improve visual recognition of websites and help
users identify pages at a glance.
* Fix favicon retry on back navigation after load failure
Reset the favicon failure cache when the favicon URL changes, enabling
retry of a previously failed favicon when navigating back to the same
URL. Distinguish between explicit null (clear cached favicon) and
omitted (don't update history), so stale favicons don't persist
incorrectly.
---
.../src/components/browser-favicon.tsx | 59 +++++++++++++++++++
.../host-guest/attach-browser-page-webview.ts | 4 +-
...rowser-page-webview-navigation-handlers.ts | 6 +-
.../components/tab-bar/BrowserTab.test.tsx | 34 ++++++++++-
.../src/components/tab-bar/BrowserTab.tsx | 53 ++---------------
.../TabBarCreateEntry.history.test.tsx | 13 +++-
.../tab-bar/TabBarCreateEntryRow.tsx | 5 +-
.../tab-bar/open-tab-entry-dedupe.test.ts | 3 +-
.../tab-bar/open-tab-search.test.ts | 10 +++-
.../src/components/tab-bar/open-tab-search.ts | 4 +-
.../open-tab-selection-routing.test.ts | 3 +-
...ee-jump-palette-browser-simulator-rows.tsx | 5 +-
.../src/lib/browser-palette-search.test.ts | 20 +++++++
.../src/lib/browser-palette-search.ts | 2 +
src/renderer/src/store/slices/browser.test.ts | 47 +++++++++++++++
.../slices/browser/browser-history-actions.ts | 11 +++-
.../browser/browser-page-state-actions.ts | 12 ++++
.../slices/browser/browser-slice-contract.ts | 2 +-
src/shared/browser-workspace-types.ts | 1 +
.../workspace-session-browser-schema.ts | 1 +
src/shared/workspace-session-schema.test.ts | 4 ++
21 files changed, 233 insertions(+), 66 deletions(-)
create mode 100644 src/renderer/src/components/browser-favicon.tsx
diff --git a/src/renderer/src/components/browser-favicon.tsx b/src/renderer/src/components/browser-favicon.tsx
new file mode 100644
index 00000000000..ecde4065f03
--- /dev/null
+++ b/src/renderer/src/components/browser-favicon.tsx
@@ -0,0 +1,59 @@
+import { useState } from 'react'
+import { Globe } from 'lucide-react'
+import { cn } from '@/lib/utils'
+
+function displayableFaviconUrl(faviconUrl: string | null | undefined): string | null {
+ const trimmed = faviconUrl?.trim()
+ if (!trimmed) {
+ return null
+ }
+ if (trimmed.startsWith('data:image/')) {
+ return trimmed
+ }
+ try {
+ const url = new URL(trimmed)
+ return url.protocol === 'http:' || url.protocol === 'https:' ? trimmed : null
+ } catch {
+ return null
+ }
+}
+
+export function BrowserFavicon({
+ faviconUrl,
+ className,
+ fallbackClassName
+}: {
+ faviconUrl: string | null | undefined
+ className?: string
+ fallbackClassName?: string
+}): React.JSX.Element {
+ const displayUrl = displayableFaviconUrl(faviconUrl)
+ const [failedUrl, setFailedUrl] = useState(null)
+
+ // Why: reset during render on any favicon identity change — including a clear to null while
+ // a page loads — so navigating back to the same url retries instead of keeping the fallback.
+ if (failedUrl !== null && failedUrl !== displayUrl) {
+ setFailedUrl(null)
+ }
+
+ if (displayUrl && failedUrl !== displayUrl) {
+ return (
+ setFailedUrl(displayUrl)}
+ />
+ )
+ }
+
+ return
+}
diff --git a/src/renderer/src/components/browser-pane/host-guest/attach-browser-page-webview.ts b/src/renderer/src/components/browser-pane/host-guest/attach-browser-page-webview.ts
index 6648fd0dad4..4d690b31f03 100644
--- a/src/renderer/src/components/browser-pane/host-guest/attach-browser-page-webview.ts
+++ b/src/renderer/src/components/browser-pane/host-guest/attach-browser-page-webview.ts
@@ -57,7 +57,9 @@ export type AttachBrowserPageWebviewArgs = {
setPendingAnnotationPayload: Dispatch>
setBrowserOverlayViewport: Dispatch>
setAddressBarValue: Dispatch>
- addBrowserHistoryEntryRef: MutableRefObject<(url: string, title: string) => void>
+ addBrowserHistoryEntryRef: MutableRefObject<
+ (url: string, title: string, faviconUrl?: string | null) => void
+ >
annotationViewportBridgeTokenRef: MutableRefObject
initialBrowserUrlRef: MutableRefObject
validateVisibleGuestRegistrationRef: MutableRefObject<() => void>
diff --git a/src/renderer/src/components/browser-pane/host-guest/browser-page-webview-navigation-handlers.ts b/src/renderer/src/components/browser-pane/host-guest/browser-page-webview-navigation-handlers.ts
index e0836ed5b9a..dbb47ae4acb 100644
--- a/src/renderer/src/components/browser-pane/host-guest/browser-page-webview-navigation-handlers.ts
+++ b/src/renderer/src/components/browser-pane/host-guest/browser-page-webview-navigation-handlers.ts
@@ -30,7 +30,9 @@ export type BrowserPageWebviewNavigationHandlersArgs = {
addressBarInputRef: RefObject
onSetUrlRef: MutableRefObject
onUpdatePageStateRef: MutableRefObject<(tabId: string, updates: BrowserTabPageState) => void>
- addBrowserHistoryEntryRef: MutableRefObject<(url: string, title: string) => void>
+ addBrowserHistoryEntryRef: MutableRefObject<
+ (url: string, title: string, faviconUrl?: string | null) => void
+ >
faviconUrlRef: MutableRefObject
setAddressBarValue: Dispatch>
annotationViewportBridgeTokenRef: MutableRefObject
@@ -127,7 +129,7 @@ export function createBrowserPageWebviewNavigationHandlers({
const browserModelUrl = redactKagiSessionToken(currentUrl)
const title = getBrowserDisplayTitle(event.title, browserModelUrl)
onUpdatePageStateRef.current(browserTabId, { title })
- addBrowserHistoryEntryRef.current(browserModelUrl, title)
+ addBrowserHistoryEntryRef.current(browserModelUrl, title, faviconUrlRef.current)
} catch {
// Why: title-updated can fire before dom-ready, making getURL() throw.
}
diff --git a/src/renderer/src/components/tab-bar/BrowserTab.test.tsx b/src/renderer/src/components/tab-bar/BrowserTab.test.tsx
index fe6b05ef69a..125497284c8 100644
--- a/src/renderer/src/components/tab-bar/BrowserTab.test.tsx
+++ b/src/renderer/src/components/tab-bar/BrowserTab.test.tsx
@@ -256,7 +256,9 @@ describe('BrowserTab favicon', { timeout: 30_000 }, () => {
expect(images[0].props.alt).toBe('')
expect(images[0].props['aria-hidden']).toBe(true)
expect(images[0].props.draggable).toBe(false)
- expect(images[0].props.className).toContain('size-3 mr-1 shrink-0')
+ expect(images[0].props.className).toContain('size-3')
+ expect(images[0].props.className).toContain('mr-1')
+ expect(images[0].props.className).toContain('shrink-0')
expect(images[0].props.className).toContain('object-contain')
expect(images[0].props.className).toContain('drop-shadow-[0_0_1px_var(--foreground)]')
expect(findElementsByType(element, 'Globe')).toHaveLength(0)
@@ -274,7 +276,9 @@ describe('BrowserTab favicon', { timeout: 30_000 }, () => {
expect(findElementsByType(element, 'img')).toHaveLength(0)
const globes = findElementsByType(element, 'Globe')
expect(globes).toHaveLength(1)
- expect(globes[0].props.className).toContain('size-3 mr-1 shrink-0')
+ expect(globes[0].props.className).toContain('size-3')
+ expect(globes[0].props.className).toContain('mr-1')
+ expect(globes[0].props.className).toContain('shrink-0')
expect(globes[0].props.className).toContain('text-blue-500')
})
@@ -309,4 +313,30 @@ describe('BrowserTab favicon', { timeout: 30_000 }, () => {
expect(images[0].props.src).toBe(nextIconUrl)
expect(findElementsByType(resetRender, 'Globe')).toHaveLength(0)
})
+
+ it('retries a failed favicon after a navigation clears and restores the same url', async () => {
+ const iconUrl = 'https://example.com/favicon.ico'
+ const tab = baseBrowserTab({ faviconUrl: iconUrl })
+ const firstRender = await renderExpandedBrowserTab(tab)
+ const image = findElementsByType(firstRender, 'img')[0]
+
+ ;(image.props.onError as () => void)()
+ const failedRender = await renderExpandedBrowserTab(tab)
+ expect(findElementsByType(failedRender, 'Globe')).toHaveLength(1)
+
+ // A load clears the favicon, then the same site reports it again.
+ const loadingRender = await renderExpandedBrowserTab(
+ baseBrowserTab({ id: tab.id, faviconUrl: null })
+ )
+ expect(findElementsByType(loadingRender, 'Globe')).toHaveLength(1)
+
+ const retryRender = await renderExpandedBrowserTab(
+ baseBrowserTab({ id: tab.id, faviconUrl: iconUrl })
+ )
+
+ const images = findElementsByType(retryRender, 'img')
+ expect(images).toHaveLength(1)
+ expect(images[0].props.src).toBe(iconUrl)
+ expect(findElementsByType(retryRender, 'Globe')).toHaveLength(0)
+ })
})
diff --git a/src/renderer/src/components/tab-bar/BrowserTab.tsx b/src/renderer/src/components/tab-bar/BrowserTab.tsx
index 344b7ec6c9e..b72f27287f9 100644
--- a/src/renderer/src/components/tab-bar/BrowserTab.tsx
+++ b/src/renderer/src/components/tab-bar/BrowserTab.tsx
@@ -1,7 +1,6 @@
import { useEffect, useState } from 'react'
import { useSortable } from '@dnd-kit/sortable'
import {
- Globe,
X,
ExternalLink,
Copy,
@@ -39,6 +38,7 @@ import { TabWorkspaceLayoutMenuSection } from './TabWorkspaceLayoutMenuSection'
import { useTabStripPointerActivation } from './tab-strip-pointer-activation'
import { TAB_CONTEXT_MENU_CONTENT_CLASS } from './tab-context-menu-sizing'
import { cn } from '@/lib/utils'
+import { BrowserFavicon } from '@/components/browser-favicon'
export function formatBrowserTabUrlLabel(url: string): string {
if (url === ORCA_BROWSER_BLANK_URL || url === 'about:blank') {
@@ -68,51 +68,6 @@ function isBlankBrowserTab(tab: BrowserTabState): boolean {
return tab.url === ORCA_BROWSER_BLANK_URL || tab.url === 'about:blank'
}
-type FailedFavicon = {
- tabId: string
- faviconUrl: string
-}
-
-function BrowserTabFavicon({
- tabId,
- faviconUrl
-}: {
- tabId: string
- faviconUrl: string | null
-}): React.JSX.Element {
- const displayFaviconUrl = faviconUrl?.trim() ? faviconUrl : null
- const [failedFavicon, setFailedFavicon] = useState(null)
-
- // Why: reset during render so a new favicon identity retries before the tab
- // commits one frame with the stale fallback icon.
- if (
- failedFavicon &&
- (failedFavicon.tabId !== tabId || failedFavicon.faviconUrl !== displayFaviconUrl)
- ) {
- setFailedFavicon(null)
- }
-
- const currentFaviconFailed =
- failedFavicon?.tabId === tabId && failedFavicon.faviconUrl === displayFaviconUrl
-
- if (displayFaviconUrl && !currentFaviconFailed) {
- return (
- setFailedFavicon({ tabId, faviconUrl: displayFaviconUrl })}
- />
- )
- }
-
- return
-}
-
export default function BrowserTab({
tab,
isActive,
@@ -234,7 +189,11 @@ export default function BrowserTab({
browser tabs at a glance even when the strip is saturated. We
keep full color on both active and inactive tabs — dimming to
muted-foreground made the icon read as "disabled" in practice. */}
-
+
{isPinned && }
{tabLabel}
{tab.loading && !tab.loadError && !isBlankBrowserTab(tab) && (
diff --git a/src/renderer/src/components/tab-bar/TabBarCreateEntry.history.test.tsx b/src/renderer/src/components/tab-bar/TabBarCreateEntry.history.test.tsx
index a1ec5fad293..57c24306ed2 100644
--- a/src/renderer/src/components/tab-bar/TabBarCreateEntry.history.test.tsx
+++ b/src/renderer/src/components/tab-bar/TabBarCreateEntry.history.test.tsx
@@ -169,6 +169,16 @@ describe('TabBarCreateEntry browser history rows', () => {
expect(rowTexts().some((text) => text.includes('Open page'))).toBe(false)
})
+ it('uses the favicon captured with a history entry', () => {
+ const faviconUrl = 'https://linear.app/favicon.ico'
+ historyStoreMock.entries = [historyEntry({ ...linear, faviconUrl })]
+ mount()
+
+ setQuery('linear')
+
+ expect(container.querySelector('[role="option"] img')?.src).toBe(faviconUrl)
+ })
+
it('skips history for a path-shaped query and for a forced search', () => {
pathLikeMock.value = true
mount()
@@ -192,7 +202,8 @@ describe('TabBarCreateEntry browser history rows', () => {
contentType: 'browser',
pageId: 'page-1',
workspaceId: 'ws-1',
- url: 'https://linear.app/acme/team/ORC/active'
+ url: 'https://linear.app/acme/team/ORC/active',
+ faviconUrl: null
}
]
mount()
diff --git a/src/renderer/src/components/tab-bar/TabBarCreateEntryRow.tsx b/src/renderer/src/components/tab-bar/TabBarCreateEntryRow.tsx
index a55b9d82974..ea7e32210a8 100644
--- a/src/renderer/src/components/tab-bar/TabBarCreateEntryRow.tsx
+++ b/src/renderer/src/components/tab-bar/TabBarCreateEntryRow.tsx
@@ -16,6 +16,7 @@ import { translate } from '@/i18n/i18n'
import { SEARCH_ENGINE_LABELS } from '../../../../shared/browser-url'
import { formatBrowserHistoryUrl } from '@/lib/browser-history-match'
import type { ActiveOption } from './tab-create-entry-active-option'
+import { BrowserFavicon } from '@/components/browser-favicon'
export const RESULT_LISTBOX_ID = 'tab-create-entry-results'
@@ -138,7 +139,7 @@ function getOpenTabIcon(option: Extract['option']
return
}
if (contentType === 'browser') {
- return
+ return
}
if (contentType === 'simulator') {
return
@@ -191,7 +192,7 @@ function getActionPresentation(option: ActiveOption): {
// Why the title is detail, not label: the label span is shrink-0 whenever a
// detail shows, so a variable-length title there would refuse to truncate.
detail: entry.title ? `${entry.title} · ${url}` : url,
- icon: ,
+ icon: ,
label: translate('auto.components.tab.bar.TabBarCreateEntry.openPage', 'Open page'),
showDetail: true
}
diff --git a/src/renderer/src/components/tab-bar/open-tab-entry-dedupe.test.ts b/src/renderer/src/components/tab-bar/open-tab-entry-dedupe.test.ts
index c1d7adea0a6..57ed635f716 100644
--- a/src/renderer/src/components/tab-bar/open-tab-entry-dedupe.test.ts
+++ b/src/renderer/src/components/tab-bar/open-tab-entry-dedupe.test.ts
@@ -90,7 +90,8 @@ describe('dropFileEntriesCoveredByTabResults', () => {
contentType: 'browser',
pageId: 'page-1',
url: 'https://example.com/zebra',
- workspaceId: 'ws-1'
+ workspaceId: 'ws-1',
+ faviconUrl: null
},
{
executionHostId: 'local',
diff --git a/src/renderer/src/components/tab-bar/open-tab-search.test.ts b/src/renderer/src/components/tab-bar/open-tab-search.test.ts
index e2029811625..665b5a460de 100644
--- a/src/renderer/src/components/tab-bar/open-tab-search.test.ts
+++ b/src/renderer/src/components/tab-bar/open-tab-search.test.ts
@@ -124,12 +124,14 @@ function makeBrowserPage({
id,
title,
url = 'https://example.com/one',
+ faviconUrl = null,
workspaceLabel = null,
isCurrentPage = false
}: {
id: string
title: string
url?: string
+ faviconUrl?: string | null
workspaceLabel?: string | null
isCurrentPage?: boolean
}): SearchableBrowserPage {
@@ -140,7 +142,7 @@ function makeBrowserPage({
url,
title,
loading: false,
- faviconUrl: null,
+ faviconUrl,
canGoBack: false,
canGoForward: false,
loadError: null,
@@ -445,10 +447,11 @@ describe('searchOpenTabs result fields', () => {
})
it('carries the activation identifiers each source needs', () => {
+ const faviconUrl = 'https://example.com/favicon.ico'
const results = search({
query: 'zebra',
workspaceTabs: [makeWorkspaceTab({ id: 'tab-1', title: 'Zebra tab' })],
- browserPages: [makeBrowserPage({ id: 'page-1', title: 'Zebra page' })],
+ browserPages: [makeBrowserPage({ id: 'page-1', title: 'Zebra page', faviconUrl })],
simulatorTabs: [makeSimulatorTab({ id: 'sim-1', label: 'Zebra emulator' })]
})
@@ -467,7 +470,8 @@ describe('searchOpenTabs result fields', () => {
contentType: 'browser',
pageId: 'page-1',
workspaceId: 'page-1-ws',
- worktreeId: 'wt-1'
+ worktreeId: 'wt-1',
+ faviconUrl
},
{
source: 'simulator',
diff --git a/src/renderer/src/components/tab-bar/open-tab-search.ts b/src/renderer/src/components/tab-bar/open-tab-search.ts
index 17353714d5e..05aa6126872 100644
--- a/src/renderer/src/components/tab-bar/open-tab-search.ts
+++ b/src/renderer/src/components/tab-bar/open-tab-search.ts
@@ -58,6 +58,7 @@ export type OpenTabSearchResult =
pageId: string
workspaceId: string
url: string
+ faviconUrl: string | null
})
| (OpenTabSearchResultBase & {
source: 'simulator'
@@ -206,7 +207,8 @@ export function searchOpenTabs({
contentType: 'browser',
pageId: result.pageId,
workspaceId: result.workspaceId,
- url: result.url
+ url: result.url,
+ faviconUrl: result.faviconUrl
})),
...rank('simulator', searchSimulatorTabs([...simulatorTabs], trimmed), (result) => ({
...baseResult('simulator', result.tabId, result, executionHostId),
diff --git a/src/renderer/src/components/tab-bar/open-tab-selection-routing.test.ts b/src/renderer/src/components/tab-bar/open-tab-selection-routing.test.ts
index eba3a89bb03..5ab615702d1 100644
--- a/src/renderer/src/components/tab-bar/open-tab-selection-routing.test.ts
+++ b/src/renderer/src/components/tab-bar/open-tab-selection-routing.test.ts
@@ -63,7 +63,8 @@ const browserResult: OpenTabSearchResult = {
contentType: 'browser',
pageId: 'page-1',
workspaceId: 'ws-1',
- url: 'https://example.com/docs'
+ url: 'https://example.com/docs',
+ faviconUrl: null
}
const simulatorResult: OpenTabSearchResult = {
diff --git a/src/renderer/src/components/worktree-jump-palette-browser-simulator-rows.tsx b/src/renderer/src/components/worktree-jump-palette-browser-simulator-rows.tsx
index e19c735886c..3c88e3daf0c 100644
--- a/src/renderer/src/components/worktree-jump-palette-browser-simulator-rows.tsx
+++ b/src/renderer/src/components/worktree-jump-palette-browser-simulator-rows.tsx
@@ -1,5 +1,5 @@
import type React from 'react'
-import { Globe, Smartphone } from 'lucide-react'
+import { Smartphone } from 'lucide-react'
import { CommandItem } from '@/components/ui/command'
import { RepoBadgeMark } from '@/components/repo/RepoBadgeLabel'
import { getPaletteHostBadge } from '@/components/cmd-j/palette-host-badge'
@@ -15,6 +15,7 @@ import {
} from './worktree-jump-palette-primitives'
import { formatPaletteSessionAge } from '@/components/cmd-j/palette-session-age'
import { resolvePaletteRepoForWorktree } from '@/lib/palette-repo-resolution'
+import { BrowserFavicon } from '@/components/browser-favicon'
export function WorktreeJumpPaletteSimulatorRow({
entry,
@@ -147,7 +148,7 @@ export function WorktreeJumpPaletteBrowserRow({
)}
>
-
+
diff --git a/src/renderer/src/lib/browser-palette-search.test.ts b/src/renderer/src/lib/browser-palette-search.test.ts
index 41cfd58f54d..759e1cc5421 100644
--- a/src/renderer/src/lib/browser-palette-search.test.ts
+++ b/src/renderer/src/lib/browser-palette-search.test.ts
@@ -102,6 +102,26 @@ describe('browser-palette-search', () => {
expect(result.executionHostId).toBe('ssh:box')
})
+ it('carries the page favicon into palette results', () => {
+ const faviconUrl = 'https://example.com/favicon.ico'
+ const [result] = searchBrowserPages(
+ [
+ makeEntry({
+ page: makePage({ faviconUrl }),
+ workspace: makeWorkspace(),
+ worktree: makeWorktree(),
+ repoName: 'repo/one',
+ worktreeSortIndex: 0,
+ isCurrentPage: false,
+ isCurrentWorktree: false
+ })
+ ],
+ ''
+ )
+
+ expect(result.faviconUrl).toBe(faviconUrl)
+ })
+
it('keeps empty-query ordering deterministic and context-first', () => {
const results = searchBrowserPages(
[
diff --git a/src/renderer/src/lib/browser-palette-search.ts b/src/renderer/src/lib/browser-palette-search.ts
index 1529c46860e..0cd9f7e0618 100644
--- a/src/renderer/src/lib/browser-palette-search.ts
+++ b/src/renderer/src/lib/browser-palette-search.ts
@@ -42,6 +42,7 @@ export type BrowserPaletteSearchResult = {
workspaceId: string
worktreeId: string
title: string
+ faviconUrl: string | null
/** Raw page URL, so callers can dedupe a row against another list of destinations. */
url: string
secondaryText: string
@@ -153,6 +154,7 @@ function baseResult(entry: SearchableBrowserPage): BrowserPaletteSearchResult {
workspaceId: entry.workspace.id,
worktreeId: entry.worktree.id,
title: entry.page.title || formattedUrl,
+ faviconUrl: entry.page.faviconUrl,
url: entry.page.url,
secondaryText: formattedUrl,
workspaceLabel: entry.workspace.label ?? null,
diff --git a/src/renderer/src/store/slices/browser.test.ts b/src/renderer/src/store/slices/browser.test.ts
index ba014db3aa3..fc1620871d5 100644
--- a/src/renderer/src/store/slices/browser.test.ts
+++ b/src/renderer/src/store/slices/browser.test.ts
@@ -302,6 +302,53 @@ describe('createBrowserSlice annotations', () => {
expect(store.getState().browserTabsByWorktree).toBe(browserTabsByWorktree)
})
+ it('persists a captured favicon with history and refreshes it with the page state', () => {
+ const store = createTestStore()
+ const tab = store.getState().createBrowserTab('wt-1', 'https://example.com', {
+ title: 'Example'
+ })
+ const pageId = tab.activePageId
+ if (!pageId) {
+ throw new Error('Expected a new browser page')
+ }
+ const initialFavicon = 'https://example.com/favicon.ico'
+ const refreshedFavicon = 'https://cdn.example.com/favicon.png'
+
+ store.getState().addBrowserHistoryEntry('https://example.com', 'Example', initialFavicon)
+ expect(store.getState().browserUrlHistory[0]?.faviconUrl).toBe(initialFavicon)
+
+ store.getState().updateBrowserPageState(pageId, { faviconUrl: refreshedFavicon })
+ expect(store.getState().browserUrlHistory[0]?.faviconUrl).toBe(refreshedFavicon)
+ })
+
+ it('clears a stale history favicon when a page reports none, and keeps it when none is reported', () => {
+ const store = createTestStore()
+ const tab = store.getState().createBrowserTab('wt-1', 'https://example.com', {
+ title: 'Example'
+ })
+ const pageId = tab.activePageId
+ if (!pageId) {
+ throw new Error('Expected a new browser page')
+ }
+ const favicon = 'https://example.com/favicon.ico'
+
+ store.getState().addBrowserHistoryEntry('https://example.com', 'Example', favicon)
+ store.getState().updateBrowserPageState(pageId, { faviconUrl: favicon })
+
+ // An omitted favicon leaves the stored one alone; an explicit null clears it.
+ store.getState().addBrowserHistoryEntry('https://example.com', 'Example')
+ expect(store.getState().browserUrlHistory[0]?.faviconUrl).toBe(favicon)
+
+ store.getState().updateBrowserPageState(pageId, { faviconUrl: null })
+ expect(store.getState().browserUrlHistory[0]?.faviconUrl).toBeNull()
+
+ store.getState().addBrowserHistoryEntry('https://example.com', 'Example', favicon)
+ expect(store.getState().browserUrlHistory[0]?.faviconUrl).toBe(favicon)
+
+ store.getState().addBrowserHistoryEntry('https://example.com', 'Example', null)
+ expect(store.getState().browserUrlHistory[0]?.faviconUrl).toBeNull()
+ })
+
it('repairs a stale active browser unified-tab label on an otherwise unchanged title update', () => {
const store = createTestStore()
const tab = store.getState().createBrowserTab('wt-1', 'https://example.com', {
diff --git a/src/renderer/src/store/slices/browser/browser-history-actions.ts b/src/renderer/src/store/slices/browser/browser-history-actions.ts
index 97ebf8b873b..85c5b4f5479 100644
--- a/src/renderer/src/store/slices/browser/browser-history-actions.ts
+++ b/src/renderer/src/store/slices/browser/browser-history-actions.ts
@@ -60,7 +60,7 @@ export function createBrowserHistoryActions(
})
},
- addBrowserHistoryEntry: (url, title) => {
+ addBrowserHistoryEntry: (url, title, faviconUrl) => {
const safeUrl = redactKagiSessionToken(url)
if (safeUrl === ORCA_BROWSER_BLANK_URL || safeUrl === 'about:blank' || !safeUrl) {
return
@@ -71,7 +71,13 @@ export function createBrowserHistoryActions(
let next: BrowserHistoryEntry[] = existing
? s.browserUrlHistory.map((entry) =>
entry === existing
- ? { ...entry, title, lastVisitedAt: Date.now(), visitCount: entry.visitCount + 1 }
+ ? {
+ ...entry,
+ title,
+ ...(faviconUrl !== undefined ? { faviconUrl } : {}),
+ lastVisitedAt: Date.now(),
+ visitCount: entry.visitCount + 1
+ }
: entry
)
: [
@@ -79,6 +85,7 @@ export function createBrowserHistoryActions(
url: safeUrl,
normalizedUrl: normalized,
title,
+ ...(faviconUrl !== undefined ? { faviconUrl } : {}),
lastVisitedAt: Date.now(),
visitCount: 1
},
diff --git a/src/renderer/src/store/slices/browser/browser-page-state-actions.ts b/src/renderer/src/store/slices/browser/browser-page-state-actions.ts
index 15e4892b351..83aa3c23673 100644
--- a/src/renderer/src/store/slices/browser/browser-page-state-actions.ts
+++ b/src/renderer/src/store/slices/browser/browser-page-state-actions.ts
@@ -9,6 +9,7 @@ import {
normalizeBrowserTitle,
normalizeUrl
} from '../browser-page-records'
+import { normalizeBrowserHistoryUrl } from '../../../../../shared/workspace-session-browser-history'
export function createBrowserPageStateActions(
set: BrowserSliceSet,
@@ -100,6 +101,17 @@ export function createBrowserPageStateActions(
[workspace.id]: nextPages
}
}
+ if (updates.faviconUrl !== undefined && updates.faviconUrl !== page.faviconUrl) {
+ const historyIndex = s.browserUrlHistory.findIndex(
+ (entry) => entry.normalizedUrl === normalizeBrowserHistoryUrl(page.url)
+ )
+ const historyEntry = s.browserUrlHistory[historyIndex]
+ if (historyEntry && historyEntry.faviconUrl !== updates.faviconUrl) {
+ nextState.browserUrlHistory = s.browserUrlHistory.map((entry, index) =>
+ index === historyIndex ? { ...entry, faviconUrl: updates.faviconUrl } : entry
+ )
+ }
+ }
if (!browserWorkspaceMirrorFieldsEqual(workspace, nextWorkspace)) {
nextState.browserTabsByWorktree = {
...s.browserTabsByWorktree,
diff --git a/src/renderer/src/store/slices/browser/browser-slice-contract.ts b/src/renderer/src/store/slices/browser/browser-slice-contract.ts
index c5577aaf0c4..ee50fa1a087 100644
--- a/src/renderer/src/store/slices/browser/browser-slice-contract.ts
+++ b/src/renderer/src/store/slices/browser/browser-slice-contract.ts
@@ -239,7 +239,7 @@ export type BrowserSlice = {
) => Promise
clearDefaultSessionCookies: () => Promise
browserUrlHistory: BrowserHistoryEntry[]
- addBrowserHistoryEntry: (url: string, title: string) => void
+ addBrowserHistoryEntry: (url: string, title: string, faviconUrl?: string | null) => void
workspaceDocHistory: WorkspaceDocHistoryEntry[]
/** A visit bumps recency and count; a title-only refresh (bump: false) renames the row. */
recordWorkspaceDocVisit: (
diff --git a/src/shared/browser-workspace-types.ts b/src/shared/browser-workspace-types.ts
index 5b2a1e7f33d..ecab57c8783 100644
--- a/src/shared/browser-workspace-types.ts
+++ b/src/shared/browser-workspace-types.ts
@@ -2,6 +2,7 @@ export type BrowserHistoryEntry = {
url: string
normalizedUrl: string
title: string
+ faviconUrl?: string | null
lastVisitedAt: number
visitCount: number
}
diff --git a/src/shared/workspace-session-browser-schema.ts b/src/shared/workspace-session-browser-schema.ts
index 71f88fb3b0c..159905e5ab9 100644
--- a/src/shared/workspace-session-browser-schema.ts
+++ b/src/shared/workspace-session-browser-schema.ts
@@ -117,6 +117,7 @@ const browserHistoryEntrySchema = z.object({
url: z.string(),
normalizedUrl: z.string(),
title: z.string(),
+ faviconUrl: z.string().nullable().optional(),
lastVisitedAt: z.number(),
visitCount: z.number()
})
diff --git a/src/shared/workspace-session-schema.test.ts b/src/shared/workspace-session-schema.test.ts
index 66e2ddcc514..81bc37b6b65 100644
--- a/src/shared/workspace-session-schema.test.ts
+++ b/src/shared/workspace-session-schema.test.ts
@@ -504,6 +504,7 @@ describe('parseWorkspaceSession', () => {
url: `https://example.com/${index}`,
normalizedUrl: `https://example.com/${index}`,
title: `Example ${index}`,
+ faviconUrl: index === 0 ? 'https://example.com/favicon.ico' : null,
lastVisitedAt: 1_700_000_000_000 - index,
visitCount: 1
}))
@@ -512,6 +513,9 @@ describe('parseWorkspaceSession', () => {
expect(result.ok).toBe(true)
if (result.ok) {
expect(result.value.browserUrlHistory).toHaveLength(MAX_BROWSER_HISTORY_ENTRIES)
+ expect(result.value.browserUrlHistory?.[0]?.faviconUrl).toBe(
+ 'https://example.com/favicon.ico'
+ )
expect(result.value.browserUrlHistory?.at(-1)?.url).toBe('https://example.com/199')
}
})
From 6062edf2962e1dc33c09d1be6bfd5a9469e969b8 Mon Sep 17 00:00:00 2001
From: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Date: Wed, 2 Sep 2026 10:39:46 -0700
Subject: [PATCH 04/26] test: simplify remote pane link routing to
server-hosted placement (#18219)
Remote-pane links are now explicitly server-hosted regardless of generic
client-hosted preference. Remove client-hosted placement verification,
placement-switching test acts, and related type definitions. Focus the
test on verifying the core invariant: links stay server-hosted on their
owning runtime.
---
...d-remote-browser-link-open-routing.spec.ts | 139 ++++--------------
1 file changed, 29 insertions(+), 110 deletions(-)
diff --git a/tests/e2e/paired-remote-browser-link-open-routing.spec.ts b/tests/e2e/paired-remote-browser-link-open-routing.spec.ts
index 695d39c251b..a8b15d97a03 100644
--- a/tests/e2e/paired-remote-browser-link-open-routing.spec.ts
+++ b/tests/e2e/paired-remote-browser-link-open-routing.spec.ts
@@ -1,7 +1,6 @@
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'
import type { AddressInfo } from 'node:net'
import type { Page } from '@stablyai/playwright-test'
-import { parseBrowserNetworkExecutionHostKey } from '../../src/main/browser/browser-network-execution-route'
import { LOCAL_EXECUTION_HOST_ID } from '../../src/shared/execution-host'
import { readOwnedPageUrls } from './helpers/client-hosted-browser-observer'
import {
@@ -16,11 +15,8 @@ import {
} from './helpers/paired-electron-client'
// The link is a dev-server URL on the pane runtime's network, so a client-local fallback would
-// silently load a *different machine's* server. Which machine renders the pixels no longer answers
-// that: under client-hosted placement the guest paints on this desktop while its network is still
-// pinned to the host at creation. So each act below pins the placement it was written for and reads
-// the host's own record — the page row's placement and executionHostKey — instead of inferring
-// routing from where a appeared.
+// silently load a *different machine's* server. Remote-pane links are explicitly server-hosted, so
+// the acts below read the host's own record instead of inferring routing from a .
const PANE_PATH = '/remote-pane'
const LINK_PATH = '/remote-link-target'
@@ -103,56 +99,6 @@ async function readHostServerPlacedBrowserUrls(
return response.result.tabs.filter((tab) => tab.type === 'browser').map((tab) => tab.url ?? '')
}
-type HostBrowserRow = {
- executionHostKey: string | null
- placementKind: string | null
- url: string
-}
-
-/**
- * The host's rows for one URL, asked through the paired client's connection.
- *
- * Why through the client: an Electron peer advertises the client-host capability, so the host
- * answers it with client-placed pages intact and with the placement and network pin it minted at
- * creation. The host still authors every field; the client is only the transport.
- */
-async function readHostBrowserRows(
- page: Page,
- environmentId: string,
- worktreeId: string,
- urlPrefix: string
-): Promise {
- return page.evaluate(
- async ({ environmentId, urlPrefix, worktreeId }) => {
- const response = await window.api.runtimeEnvironments.call({
- selector: environmentId,
- method: 'session.tabs.list',
- params: { worktree: `id:${worktreeId}` },
- timeoutMs: 15_000
- })
- if (!response.ok) {
- throw new Error('host session tab inventory unavailable')
- }
- const { tabs } = response.result as {
- tabs: {
- type: string
- url?: string
- executionHostKey?: string
- placement?: { kind: string }
- }[]
- }
- return tabs
- .filter((tab) => tab.type === 'browser' && (tab.url ?? '').startsWith(urlPrefix))
- .map((tab) => ({
- executionHostKey: tab.executionHostKey ?? null,
- placementKind: tab.placement?.kind ?? null,
- url: tab.url ?? ''
- }))
- },
- { environmentId, urlPrefix, worktreeId }
- )
-}
-
/** Under server placement the client renders nothing itself, so any is a local fallback. */
async function readLocalBrowserViewUrls(page: Page): Promise {
return page.evaluate(() =>
@@ -183,7 +129,6 @@ async function findMirroredPage(
): Promise<{
handleEnvironmentId: string | null
pageId: string
- placementKind: string | null
} | null> {
return page.evaluate(
({ url, worktreeId }) => {
@@ -194,8 +139,7 @@ async function findMirroredPage(
const handle = state?.remoteBrowserPageHandlesByPageId[browserPage.id]
return {
handleEnvironmentId: handle?.environmentId ?? null,
- pageId: browserPage.id,
- placementKind: handle?.placement?.kind ?? null
+ pageId: browserPage.id
}
}
}
@@ -277,7 +221,7 @@ async function openLinkFromRemotePaneContextMenu(page: Page): Promise {
await openInOrca.click()
}
-test('opens a remote pane link on the pane runtime under either placement and refuses to fall back to the client', async ({
+test('opens a remote pane link on the pane runtime and refuses to fall back to the client', async ({
testRepoPath
}, testInfo) => {
test.setTimeout(300_000)
@@ -286,8 +230,7 @@ test('opens a remote pane link on the pane runtime under either placement and re
let client: PairedElectronClient | null = null
try {
- const hostRuntimeId = (await host.client.call('repo.add', { path: testRepoPath, kind: 'git' }))
- ._meta.runtimeId
+ await host.client.call('repo.add', { path: testRepoPath, kind: 'git' })
client = await launchPairedElectronClient(host.offer, testInfo, 'Remote browser link routing')
const page = client.page
const environmentId = client.environmentId
@@ -382,89 +325,65 @@ test('opens a remote pane link on the pane runtime under either placement and re
)
.toBe(0)
await focusMirroredPage(page, worktreeId, pane.pageId)
- const linkLoadsBeforeClientAct = fixture.linkLoadCount()
+ const linkLoadsBeforeSecondAct = fixture.linkLoadCount()
- // Act 2: client-hosted placement, the default. The page is hosted by this desktop, so the
- // proof of correct routing is the host's record of it, not where it painted.
+ // Act 2 still stays server-hosted even when the generic client-hosted preference is enabled:
+ // the remote pane explicitly pins links to its owning runtime.
await pinClientHostedPlacement(page, true)
await openLinkFromRemotePaneContextMenu(page)
- await expect
- .poll(
- async () => (await findMirroredPage(page, worktreeId, fixture.linkUrl))?.placementKind,
- {
- timeout: 60_000,
- message: 'the link never became a client-hosted browser page on this desktop'
- }
- )
- .toBe('client')
- // The host's own connection, unprojected: browser.tabList reads the page registry, which is
- // where a client-hosted page lives.
await expect
.poll(
async () =>
- (await readHostBrowserPageUrls(host.client, worktreeSelector)).filter((url) =>
+ (await readHostServerPlacedBrowserUrls(host, worktreeId)).filter((url) =>
url.startsWith(fixture.linkUrl)
).length,
- { timeout: 60_000, message: 'the link never became a browser page on the host runtime' }
+ {
+ timeout: 60_000,
+ message: 'the owner-pinned link did not stay server-hosted'
+ }
)
.toBe(1)
- const hostRows = await readHostBrowserRows(page, environmentId, worktreeId, fixture.linkUrl)
- expect(hostRows).toHaveLength(1)
- expect(hostRows[0]?.placementKind).toBe('client')
- // The routing invariant, structurally: the host pinned this page's network to its own runtime
- // when it created it, so the dev server was reached through the runtime and not through this
- // machine — which CI cannot tell apart by watching the fixture, since both ends are loopback.
- const executionHostKey = hostRows[0]?.executionHostKey
- if (!executionHostKey) {
- // Narrowed before parsing: an unpinned page would otherwise surface as a parse crash rather
- // than as the missing network pin it is.
- throw new Error('the host minted no network pin for the client-hosted page')
- }
- expect(parseBrowserNetworkExecutionHostKey(executionHostKey)).toMatchObject({
- runtimeId: hostRuntimeId
- })
- expect(fixture.linkLoadCount()).toBeGreaterThan(linkLoadsBeforeClientAct)
- // Hosted here, streamed from nowhere: this desktop holds the page and the runtime holds none.
+ expect(fixture.linkLoadCount()).toBeGreaterThan(linkLoadsBeforeSecondAct)
await expect
- .poll(() => readOwnedPageUrls(client!.app, fixture.linkUrl), {
+ .poll(() => readOwnedPageUrls(host.app, fixture.linkUrl), {
timeout: 60_000,
- message: 'the client-hosted guest never loaded the link on this desktop'
+ message: 'the server-hosted guest never loaded the link on the pane runtime'
})
.toHaveLength(1)
- expect(await readOwnedPageUrls(host.app, fixture.linkUrl)).toHaveLength(0)
- await expect(page.getByTestId('remote-browser-pane')).toHaveCount(paneCountBeforeOpen)
+ expect(await readOwnedPageUrls(client!.app, fixture.linkUrl)).toHaveLength(0)
+ expect(await readLocalBrowserViewUrls(page)).toHaveLength(0)
+ await expect(page.getByTestId('remote-browser-pane')).toHaveCount(paneCountBeforeOpen + 1)
- // The store drops the tab synchronously and only then fires browser.tabClose, so the mirror
- // going empty proves nothing about the host or the guest. Settle both before act 3 baselines
- // them, or act 3 reads this teardown landing mid-act as its own doing.
+ // The store drops the tab synchronously and only then fires browser.tabClose, so settle the
+ // mirror, host inventory, and host guest before act 3 reads them as its own baseline.
await closeBrowserTabsExceptPane(page, worktreeId, fixture.paneUrl)
await expect
.poll(() => findMirroredPage(page, worktreeId, fixture.linkUrl), {
timeout: 60_000,
- message: 'the client kept the closed link tab'
+ message: 'the client kept the closed owner-pinned link tab'
})
.toBeNull()
await expect
.poll(
async () =>
- (await readHostBrowserPageUrls(host.client, worktreeSelector)).filter((url) =>
+ (await readHostServerPlacedBrowserUrls(host, worktreeId)).filter((url) =>
url.startsWith(fixture.linkUrl)
).length,
- { timeout: 60_000, message: 'the runtime kept the closed client-hosted page' }
+ { timeout: 60_000, message: 'the runtime kept the closed server-hosted page' }
)
.toBe(0)
await expect
- .poll(() => readOwnedPageUrls(client!.app, fixture.linkUrl), {
+ .poll(() => readOwnedPageUrls(host.app, fixture.linkUrl), {
timeout: 60_000,
- message: 'the client-hosted guest outlived the tab that owned it'
+ message: 'the server-hosted guest outlived the tab that owned it'
})
.toHaveLength(0)
await focusMirroredPage(page, worktreeId, pane.pageId)
- // Act 3: the user moves this workspace onto their own machine while the runtime's page is
- // still on screen. Opening the link must fail in the pane, not load the runtime's dev server
- // here — the client has no business serving a page for a workspace it does not run.
+ // Act 3: the user moves this workspace onto their own machine. Opening the remote pane link
+ // must fail in the pane, not load the runtime's dev server here — the client has no business
+ // serving a page for a workspace it does not run.
await page.evaluate(
({ localHostId, worktreeId }) => {
window.__store?.getState().setActiveWorktree(worktreeId, localHostId)
From 61e010079f769f40cff39aef09f9788c13c3257d Mon Sep 17 00:00:00 2001
From: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Date: Wed, 2 Sep 2026 11:00:24 -0700
Subject: [PATCH 05/26] New agent dashboard (#18222)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* more obvious toggle
* more obvious toggle
* feat(activity): redesign thread rows and add child agent filtering
- Emphasize task title and last activity in row layout over metadata
- Add child agent toggle; hide orchestration workers by default
- Support collapsible groups and ungrouped view mode
- Improve orchestration worker message handling to surface replies
- Add sidebar search and filter controls for agent activity
* periodic checkin
* feat(activity): add "Clear completed" action and performance improvement
- Add "Clear completed" action for activity threads with undo window; clears completed and interrupted rows from view, persists across restart
- Virtualize activity thread list to render only viewport-bounded rows
- Cache activity thread search text to prevent recomputation on every keystroke
- Cache dashboard bucket counts per-worktree for selective invalidation on unrelated changes
- Use useDeferredValue for activity search filtering to keep input responsive
- Make compact mode the default display for activity threads
- Add activity-cleared-at persisted state tracking (per-pane cutoff timestamps)
* improve style
* minor change
* feat(activity): add persisted host and project filters to agents view
Agents scope filters are deliberately separate from workspace-nav filters so a monitoring surface never inherits workspace context silently. Filters survive restarts and always display an active-filter chips row with hidden count, making filtering visible and reversible.
* Graduate Agents view from experimental, refine activity handling
- Agents Dashboard moves from experimental to standard feature with showAgentsSidebar setting controlling visibility
- Add identity-checked cache eviction (dropPersisted IPC) to prevent newer runs from being evicted when UI clears older status, fixing clear-completed safety
- Extract ActivityThreadHoverCardSummary and ActivityThreadListToolbar components for better organization and reusability
- Implement mark-thread-read as separate action from select with clickable bell icon
- Add hasActivityThreadWorkspace helper for checking workspace availability across hosts (SSH/runtime targets)
- Preserve scope filter array identity during hydration for memo optimization
- Track manually-unread turns in auto-ack to prevent re-acknowledgement
- Clean up activity cleared-at cutoffs on pane retirement
- Remove activity-thread-hover-card max-lines lint override (code refactored below threshold)
* Refactor agent cache identity to use timing fields only
- Simplify AgentStatusCacheIdentity: keep only paneKey, receivedAt, stateStartedAt
- This fixes silent no-ops where renderer-enriched fields diverged from main's cache
- Add worktree-jump-navigation for navigating activity to workspaces
- Add manual mark-unread protection separate from auto-ack
- Optimize activity owner resolution with per-build memoization
- Optimize detected worktree lookup with indexed search
* Remove sticky header, add scroll position persistence
Replace the floating sticky header overlay with scroll position memory via
a ref. This preserves the user's scroll location when switching between
threads or remounting the agents list, improving UX without requiring
React state.
* Implement sticky group headers in activity thread list
Keep group headers visible at the top while scrolling when threads are grouped. Headers stick to the viewport while their section is in view, then unstick as the next header approaches.
* add blue flash
* update settings appearnce
* Extracted activity acknowledgement/clearance actions from the oversized UI slice.
- Removed dead sidebar search/menu props and the unused search ref.
- Removed the unnecessary sidebar visibility bitmask.
- Replaced hardcoded sidebar toggle colors with design-system tokens.
- Removed duplicate “mark all read / clear completed” controls in the sidebar.
- Preserved manual-unread state correctly across pane retire, transfer, and drop.
- Made clear-completed cutoffs monotonic so clock skew cannot resurrect old activity.
- Fixed blank workspace names in hover cards with the existing fallback helper.
- Added missing localization entries and stabilized hydrated filter array identity.
- Updated misleading Agents setting copy to describe both sidebar surfaces.
* add onboarding guide for the new agents panel
* Add activity clearance tracking and synced agent view settings
Agent view filters and presentation settings now sync across paired clients.
Preserves per-pane activity clearance cutoffs in persistent state. Improves
activity thread row accessibility with proper ARIA roles, and preserves
terminal host ownership after pane teardown via retained terminal handle.
* rm html
* Graduate Agents from experimental and improve activity visibility
- Migrate `showAgentsSidebar` setting from legacy experimental flags; default new profiles to the agents sidebar
- Replace scoped-thread filtering with visible-thread filtering so bulk actions (mark all read, clear completed) only affect rendered rows
- Rewrite child agent classification as a set of visible pane keys to fix orphan promotion and parent-cycle handling
- Improve activity cleared-at cutoff lifecycle: preserve on row dismissal (pane may still be live) but clear on pane removal
- Add pagehide flush for pending clear-completed evictions so quit/reload cannot replay cleared activity
- Polish agents sidebar: unread count badge, expand button, onboarding intro for migrated/new users
- Extract shared time-ago formatting to a library module
- Fix scroll restoration to defer until content can contain the saved offset
- Improve stable message hold for compact agent rows using state instead of refs
- Add worktree filter-visibility check to distinguish collapsed-but-unfiltered from filtered-hidden
* Graduate Agents from experimental and improve activity visibility
- Remove the deprecated full-page Agents view; fix settings navigation fallback
- Refactor bulk action bindings and separate mark-all-read from visible threads
- Preserve sidebar collapse state across remounts; fix child-agent badge filtering
- Add safety window for scroll-restore and improve worktree host-qualified filtering
* Graduate Agents from experimental and add manual unread tracking
- Move Agents sidebar from experimental settings to standard feature with intro flow
- Add persistent manual unread turn tracking for activity feed
- Consolidate workspace activation through activateAndRevealWorkspace dispatcher
- Improve sidebar view toggle with radio semantics and arrow-key navigation
* Graduate Agents sidebar and separate dashboard experiment
The Agents tab now has its own `showAgentsSidebar` setting (defaults on) independent from the dashboard popout experiment. Activity unread counting is simplified to count all events uniformly without mode-specific filtering. Dashboard visibility is now controlled solely by `experimentalAgentDashboardPopout`, with its own UI in the Experimental settings pane. Migration path updated: only `experimentalActivity=true` graduates to the sidebar; the dashboard experiment remains separate.
* Add agent-session tab support to activity tracking
Build activity event contexts from structured agent-session tabs and
worktree-attributed status entries. When activating a thread, try
agent-session tab activation before falling back to terminal pane.
* • The workspace sidebar tab is now a static Spaces
label—no grouping-based “Projects” label or hidden
width-reservation span.
* Show unread count badge and prioritize attention-needing agent threads
Activity group order now surfaces threads needing attention (blocked,
waiting, interrupted) before working/done so they're never buried. The
Agents tab shows an unread count badge while viewing Spaces, since the
open Agents list already highlights unread rows.
Also improves UX text ("Hide Agents" vs "Maybe later"), accessibility
with proper ARIA labels, and handles edge cases: preserves read state
for retained panes on SSH reconnect and handles deleted worktrees
gracefully in navigation.
* Batch agent-status evictions and optimize activity pane rebuilds
- Add dropPersistedStatusEntries batch API; consolidate evictions into one persist
- Implement fallback timeout in clear-completed for unseen toast callbacks
- Project only activity-relevant tabs; memoize terminal tab derivations
- Stabilize activity virtualizer key to prevent unnecessary item measurements
* Remove unread count badge from Agents sidebar tab
Simplify useActivityUnreadCount by removing the enabled parameter and
conditional logic, as the badge is no longer displayed in the UI.
* Deduplicate activity unread counts across source overlaps
Live pane status is the primary source; retained and migration entries
serve as fallback caches that may briefly overlap it during lifecycle
transitions. Count each pane only once by tracking seen keys, prioritizing
the live status as the canonical source.
Also fix monitoring state display: it's a distinct agent state, not a
tool-running row state, so exclude it from tool preview checks.
* Update activity pane tests to remove unread badge assertions
- Remove ActivityPaneVisibility type and readActivityPaneVisibility() helper
- Update agentsSidebarButton selector to match badge-less state
- Simplify assertions to check pane focus instead of visibility isolation
- Remove test for unread badge acknowledgement flow
* Fix activity pane workspace resolution and localization handling
- Thread defaultHostId through activity operations for correct host resolution
- Add language-aware caching for standalone terminal names with cache invalidation
- Fix scroll restoration bounds calculation for tall viewports
- Add focus management to sidebar radio group keyboard navigation
- Refresh localized sidebar content on language changes
- Preserve activity state across heartbeats to prevent history loss
- Improve host-id strictness in worktree jump navigation
* Preserve activity view when settings fetch fails
A failed window.api.settings.get() leaves settings null, which was
incorrectly treated as opt-out. Add the missing null check so the
activity-view gate only applies when settings are available.
Includes tests for this scenario and related edge cases in keyboard
navigation, worktree jumping, and session state handling.
---
docs/reference/wsl-probe-failure-semantics.md | 10 +-
.../server-status-listener-fanout.test.ts | 138 +++++
src/main/agent-hooks/server/server-cleanup.ts | 46 ++
src/main/ipc/agent-hooks.test.ts | 72 +++
src/main/ipc/agent-status-row-teardown-ipc.ts | 53 +-
.../applying-settings/ui-state-read.ts | 1 +
.../applying-settings/ui-state-update.ts | 4 +
.../normalize-loaded-global-settings.test.ts | 80 +++
.../normalize-loaded-global-settings.ts | 11 +
.../workspace-session-snapshot-publication.ts | 26 +-
.../pane-key-remapping.test.ts | 33 ++
.../restoring-sessions/pane-key-remapping.ts | 75 +++
.../workspace-pane-normalization.ts | 86 ++--
.../client-ui-pairing-local-fields.test.ts | 8 +-
.../runtime/rpc/methods/client-ui-schemas.ts | 6 +
src/preload/api/agent-status-api.ts | 5 +
src/preload/api/agent-status-bridge.ts | 7 +
.../app-shell/use-app-startup-hydration.ts | 10 +
.../src/app-shell/use-persisted-ui-writer.ts | 6 +-
.../activity/ActivityPrototypePage.test.ts | 44 +-
...ivityPrototypePage.thread-grouping.test.ts | 13 +
.../activity/ActivityPrototypePage.tsx | 160 +++---
.../ActivityThreadOptionsMenu.test.tsx | 150 ++++++
.../activity/ActivityTitlebarControls.tsx | 2 +-
...vity-auto-mark-read-loop.react185.test.tsx | 2 +-
.../activity/activity-clear-completed.test.ts | 362 +++++++++++++
.../activity/activity-clear-completed.ts | 206 ++++++++
.../activity/activity-event-build-cache.ts | 129 +++++
...tivity-event-builder-agent-context.test.ts | 165 ++++++
.../activity-event-builder-context.ts | 182 +++++++
.../activity-event-builder-sources.ts | 105 ++++
...vity-event-builder.bounded-history.test.ts | 122 +++++
...ivity-event-builder.host-ownership.test.ts | 206 ++++++++
...ivity-event-builder.identity-reuse.test.ts | 225 ++++++++
.../activity/activity-event-builder.ts | 306 ++++-------
.../components/activity/activity-event-cap.ts | 3 +-
.../activity/activity-pane-events.ts | 108 ++++
.../activity-scope-filter-controls.tsx | 170 ++++++
.../activity/activity-scope-filter.test.ts | 130 +++++
.../activity/activity-scope-filter.ts | 80 +++
.../activity/activity-standalone-worktree.ts | 67 +++
.../activity/activity-tab-projection.ts | 66 +++
.../activity/activity-thread-actions.test.ts | 162 ++++++
.../activity/activity-thread-actions.ts | 101 +++-
.../activity/activity-thread-builder.ts | 89 +++-
.../activity-thread-child-agent.test.ts | 171 +++++++
.../activity/activity-thread-child-agent.ts | 90 ++++
.../activity-thread-collapse-context.ts | 13 +
.../activity/activity-thread-controls.tsx | 130 +++--
...ivity-thread-grouping.search-cache.test.ts | 53 ++
.../activity/activity-thread-grouping.ts | 41 +-
.../activity-thread-hover-card-summary.tsx | 230 +++++++++
.../activity-thread-hover-card.test.tsx | 238 +++++++++
.../activity/activity-thread-hover-card.tsx | 407 +++++++++++++++
...vity-thread-list-pane-collapsible.test.tsx | 292 +++++++++++
.../activity/activity-thread-list-pane.tsx | 484 ++++++++++++------
...y-thread-list-pane.virtualization.test.tsx | 237 +++++++++
.../activity-thread-list-resize-handle.tsx | 37 ++
.../activity/activity-thread-list-toolbar.tsx | 227 ++++++++
.../activity/activity-thread-options-menu.tsx | 291 +++++++++++
.../activity-thread-presentation.test.ts | 97 ++++
.../activity/activity-thread-presentation.ts | 60 ++-
.../activity/activity-thread-row.tsx | 318 ++++++------
.../activity/activity-thread-types.ts | 2 +-
.../activity-thread-virtual-items.test.ts | 82 +++
.../activity/activity-thread-virtual-items.ts | 74 +++
.../activity/activity-thread-virtual-row.tsx | 71 +++
.../activity/dev-activity-fixture.test.ts | 39 ++
.../activity/dev-activity-fixture.ts | 127 +++++
.../event-time-clock-refresh.test.tsx | 54 ++
...e-activity-thread-action-bindings.test.tsx | 113 ++++
.../use-activity-thread-action-bindings.ts | 71 +++
.../activity/use-agent-pane-threads.ts | 264 ++++++++++
.../activity/useActivityUnreadCount.test.ts | 69 ++-
.../activity/useActivityUnreadCount.ts | 130 ++---
.../dashboard/agent-row-lineage-model.ts | 17 +-
...uild-dashboard-bucket-counts.cache.test.ts | 190 +++++++
.../build-dashboard-bucket-counts.ts | 86 ++--
...uild-dashboard-snapshot.rows-cache.test.ts | 142 +++++
.../dashboard/build-dashboard-snapshot.ts | 75 ++-
.../dashboard/useAgentBucketCounts.test.tsx | 11 +-
.../dashboard/useAgentBucketCounts.ts | 16 +-
.../dashboard/useDashboardPopoutBridge.ts | 10 +-
.../dashboard/useLiveDashboardSnapshot.ts | 9 +-
.../dashboard/worktree-agent-rows-cache.ts | 181 +++++++
.../AppearanceWindowSidebarSection.tsx | 2 -
.../settings/ExperimentalPane.test.tsx | 19 +-
.../components/settings/ExperimentalPane.tsx | 83 ++-
.../settings/appearance-sidebar-search.ts | 21 +
.../settings/experimental-search.ts | 61 +--
.../sidebar/AgentDashboardSidebarEntry.tsx | 5 +-
.../src/components/sidebar/Sidebar.test.tsx | 69 ++-
.../components/sidebar/SidebarAgentsList.tsx | 144 ++++++
.../components/sidebar/SidebarHeader.test.tsx | 339 +++++++++++-
.../src/components/sidebar/SidebarHeader.tsx | 263 +++++++---
.../components/sidebar/SidebarNav.test.tsx | 35 +-
.../src/components/sidebar/SidebarNav.tsx | 71 +--
.../SidebarRepositoryFilterSection.tsx | 14 +-
.../sidebar/SidebarWorkspaceOptionsMenu.tsx | 223 +-------
.../sidebar/agents-sidebar-visibility.test.ts | 15 +
.../sidebar/agents-sidebar-visibility.ts | 11 +
src/renderer/src/components/sidebar/index.tsx | 209 +++++++-
.../sidebar/sidebar-count-badge.tsx | 24 +
.../sidebar/sidebar-header-actions.tsx | 194 +++++++
.../sidebar/sidebar-view-toggle.test.tsx | 145 ++++++
.../sidebar/sidebar-view-toggle.tsx | 106 ++++
...se-workspace-reveal-body-redirect.test.tsx | 77 +++
.../use-workspace-reveal-body-redirect.ts | 43 ++
.../components/sidebar/visible-worktrees.ts | 69 +--
.../sidebar/workspace-options-menu-items.tsx | 240 +++++++++
.../sidebar/worktree-agent-row-selectors.ts | 12 +
...-compact-agent-row.stable-message.test.tsx | 112 ++++
.../worktree-card-compact-agent-row.tsx | 49 +-
.../worktree-filter-visibility.test.ts | 79 +++
.../sidebar/worktree-filter-visibility.ts | 43 ++
.../terminal-pane/stale-agent-row.ts | 2 +-
.../use-terminal-pane-close-actions.ts | 2 +-
.../src/components/ui/dropdown-menu.tsx | 2 +-
src/renderer/src/components/ui/popover.tsx | 17 +-
.../useAutoAckViewedAgent.clock-skew.test.ts | 10 +
.../src/hooks/useAutoAckViewedAgent.test.ts | 45 ++
.../src/hooks/useAutoAckViewedAgent.ts | 66 ++-
src/renderer/src/i18n/locales/en.json | 70 ++-
src/renderer/src/i18n/locales/es.json | 29 +-
src/renderer/src/i18n/locales/ja.json | 29 +-
src/renderer/src/i18n/locales/ko.json | 52 +-
src/renderer/src/i18n/locales/zh.json | 52 +-
.../src/lib/activity-thread-display.test.ts | 79 +++
.../src/lib/activity-thread-display.ts | 64 ++-
src/renderer/src/lib/short-time-ago.ts | 16 +
src/renderer/src/lib/worktree-activation.ts | 43 +-
.../src/lib/worktree-jump-navigation.test.ts | 151 ++++++
.../src/lib/worktree-jump-navigation.ts | 95 ++++
.../src/lib/worktree-runtime-owner-index.ts | 9 +-
.../agent-status-primitives.ts | 8 +-
.../runtime/web-session-tabs-sync/state.ts | 2 +
.../store/slices/activity-cleared-at.test.ts | 169 ++++++
.../store/slices/agent-pane-authority.test.ts | 14 +
.../slices/agent-status-ack-cleanup.test.ts | 61 +++
.../slices/agent-status-authority-actions.ts | 10 +
.../slices/agent-status-cleanup-actions.ts | 52 +-
.../src/store/slices/agent-status-contract.ts | 10 +
.../store/slices/agent-status-drop-actions.ts | 58 ++-
.../store/slices/agent-status-drop-reducer.ts | 35 +-
.../slices/agent-status-pane-keyed-records.ts | 12 +
.../agent-status-provider-session-actions.ts | 10 +-
.../agent-status-provider-session.test.ts | 40 ++
.../slices/agent-status-retention-actions.ts | 30 ++
.../slices/agent-status-slice-contract.ts | 6 +-
.../agent-status-worktree-drop-actions.ts | 52 +-
.../persisted-ui-write-baseline.test.ts | 24 +
.../slices/persisted-ui-write-baseline.ts | 13 +-
.../retired-terminal-tab-state-sweep.ts | 8 +-
.../store/slices/runtime-status-recheck.ts | 5 +-
.../slices/ui-hydration-view-layout.test.ts | 35 +-
...ui-hydration-workspace-preferences.test.ts | 118 +++++
.../store/slices/ui-page-navigation.test.ts | 29 ++
.../slices/ui/ui-slice-activity-actions.ts | 155 ++++++
.../store/slices/ui/ui-slice-agent-actions.ts | 105 +---
.../store/slices/ui/ui-slice-contract-core.ts | 6 +
.../ui/ui-slice-contract-preferences.ts | 12 +
.../slices/ui/ui-slice-hydration-actions.ts | 94 ++--
.../ui/ui-slice-hydration-sanitizers.ts | 40 +-
.../slices/ui/ui-slice-hydration-values.ts | 22 +
.../slices/ui/ui-slice-preference-actions.ts | 25 +
.../slices/ui/ui-slice-settings-actions.ts | 5 +-
.../slices/ui/ui-slice-surface-actions.ts | 4 +
.../store/slices/ui/ui-slice-view-actions.ts | 6 +-
.../listing/detected-worktree-meta.ts | 30 +-
.../teardown/worktree-purge-state.ts | 2 +
.../web/preload-api/web-agent-status-api.ts | 2 +
.../web-preference-normalization.ts | 8 +-
.../src/web/web-preload-api-ui.test.ts | 16 +-
src/shared/agent-status-ipc-payload.ts | 10 +
src/shared/agent-status-types.ts | 1 +
src/shared/agents-sidebar-visibility.ts | 12 +
src/shared/constants.ts | 6 +
src/shared/default-global-settings.ts | 1 +
src/shared/global-settings-types.ts | 6 +
src/shared/pairing-local-ui-fields.test.ts | 8 +-
src/shared/pairing-local-ui-fields.ts | 9 +-
src/shared/persisted-ui-state-types.ts | 12 +
src/shared/telemetry-property-schemas.ts | 1 +
.../e2e/activity-agent-pane-isolation.spec.ts | 129 +----
184 files changed, 12459 insertions(+), 1960 deletions(-)
create mode 100644 src/main/persistence/loading-store/normalize-loaded-global-settings.test.ts
create mode 100644 src/main/persistence/restoring-sessions/pane-key-remapping.test.ts
create mode 100644 src/main/persistence/restoring-sessions/pane-key-remapping.ts
create mode 100644 src/renderer/src/components/activity/activity-clear-completed.test.ts
create mode 100644 src/renderer/src/components/activity/activity-clear-completed.ts
create mode 100644 src/renderer/src/components/activity/activity-event-build-cache.ts
create mode 100644 src/renderer/src/components/activity/activity-event-builder-agent-context.test.ts
create mode 100644 src/renderer/src/components/activity/activity-event-builder-context.ts
create mode 100644 src/renderer/src/components/activity/activity-event-builder-sources.ts
create mode 100644 src/renderer/src/components/activity/activity-event-builder.bounded-history.test.ts
create mode 100644 src/renderer/src/components/activity/activity-event-builder.host-ownership.test.ts
create mode 100644 src/renderer/src/components/activity/activity-event-builder.identity-reuse.test.ts
create mode 100644 src/renderer/src/components/activity/activity-pane-events.ts
create mode 100644 src/renderer/src/components/activity/activity-scope-filter-controls.tsx
create mode 100644 src/renderer/src/components/activity/activity-scope-filter.test.ts
create mode 100644 src/renderer/src/components/activity/activity-scope-filter.ts
create mode 100644 src/renderer/src/components/activity/activity-standalone-worktree.ts
create mode 100644 src/renderer/src/components/activity/activity-tab-projection.ts
create mode 100644 src/renderer/src/components/activity/activity-thread-actions.test.ts
create mode 100644 src/renderer/src/components/activity/activity-thread-child-agent.test.ts
create mode 100644 src/renderer/src/components/activity/activity-thread-child-agent.ts
create mode 100644 src/renderer/src/components/activity/activity-thread-collapse-context.ts
create mode 100644 src/renderer/src/components/activity/activity-thread-grouping.search-cache.test.ts
create mode 100644 src/renderer/src/components/activity/activity-thread-hover-card-summary.tsx
create mode 100644 src/renderer/src/components/activity/activity-thread-hover-card.test.tsx
create mode 100644 src/renderer/src/components/activity/activity-thread-hover-card.tsx
create mode 100644 src/renderer/src/components/activity/activity-thread-list-pane-collapsible.test.tsx
create mode 100644 src/renderer/src/components/activity/activity-thread-list-pane.virtualization.test.tsx
create mode 100644 src/renderer/src/components/activity/activity-thread-list-resize-handle.tsx
create mode 100644 src/renderer/src/components/activity/activity-thread-list-toolbar.tsx
create mode 100644 src/renderer/src/components/activity/activity-thread-options-menu.tsx
create mode 100644 src/renderer/src/components/activity/activity-thread-presentation.test.ts
create mode 100644 src/renderer/src/components/activity/activity-thread-virtual-items.test.ts
create mode 100644 src/renderer/src/components/activity/activity-thread-virtual-items.ts
create mode 100644 src/renderer/src/components/activity/activity-thread-virtual-row.tsx
create mode 100644 src/renderer/src/components/activity/dev-activity-fixture.test.ts
create mode 100644 src/renderer/src/components/activity/dev-activity-fixture.ts
create mode 100644 src/renderer/src/components/activity/event-time-clock-refresh.test.tsx
create mode 100644 src/renderer/src/components/activity/use-activity-thread-action-bindings.test.tsx
create mode 100644 src/renderer/src/components/activity/use-activity-thread-action-bindings.ts
create mode 100644 src/renderer/src/components/activity/use-agent-pane-threads.ts
create mode 100644 src/renderer/src/components/dashboard/build-dashboard-bucket-counts.cache.test.ts
create mode 100644 src/renderer/src/components/dashboard/build-dashboard-snapshot.rows-cache.test.ts
create mode 100644 src/renderer/src/components/dashboard/worktree-agent-rows-cache.ts
create mode 100644 src/renderer/src/components/sidebar/SidebarAgentsList.tsx
create mode 100644 src/renderer/src/components/sidebar/agents-sidebar-visibility.test.ts
create mode 100644 src/renderer/src/components/sidebar/agents-sidebar-visibility.ts
create mode 100644 src/renderer/src/components/sidebar/sidebar-count-badge.tsx
create mode 100644 src/renderer/src/components/sidebar/sidebar-header-actions.tsx
create mode 100644 src/renderer/src/components/sidebar/sidebar-view-toggle.test.tsx
create mode 100644 src/renderer/src/components/sidebar/sidebar-view-toggle.tsx
create mode 100644 src/renderer/src/components/sidebar/use-workspace-reveal-body-redirect.test.tsx
create mode 100644 src/renderer/src/components/sidebar/use-workspace-reveal-body-redirect.ts
create mode 100644 src/renderer/src/components/sidebar/workspace-options-menu-items.tsx
create mode 100644 src/renderer/src/components/sidebar/worktree-card-compact-agent-row.stable-message.test.tsx
create mode 100644 src/renderer/src/components/sidebar/worktree-filter-visibility.test.ts
create mode 100644 src/renderer/src/components/sidebar/worktree-filter-visibility.ts
create mode 100644 src/renderer/src/lib/short-time-ago.ts
create mode 100644 src/renderer/src/lib/worktree-jump-navigation.test.ts
create mode 100644 src/renderer/src/lib/worktree-jump-navigation.ts
create mode 100644 src/renderer/src/store/slices/activity-cleared-at.test.ts
create mode 100644 src/renderer/src/store/slices/ui/ui-slice-activity-actions.ts
create mode 100644 src/shared/agents-sidebar-visibility.ts
diff --git a/docs/reference/wsl-probe-failure-semantics.md b/docs/reference/wsl-probe-failure-semantics.md
index cadcbf53364..dcaa67a5d4d 100644
--- a/docs/reference/wsl-probe-failure-semantics.md
+++ b/docs/reference/wsl-probe-failure-semantics.md
@@ -35,11 +35,11 @@ silent, and indistinguishable from the real thing.
Three instances so far:
-| Where | What the user saw | Status |
-| --- | --- | --- |
-| Preflight CLI probes | Caching the result would have pinned "git not installed" until relaunch | Bounded entry ([#17350](https://github.com/stablyai/orca/pull/17350)) |
-| `glab auth status` fallback into WSL | Idle VM woken repeatedly for users who never touch GitLab | Open ([#8941](https://github.com/stablyai/orca/issues/8941)) |
-| `listRunningWslDistrosAsync` | Fails closed to `[]` with no last-known-good, polled every 2s — a persistently broken `wsl.exe` makes every WSL session vanish app-wide | Open (PR #17072 review) |
+| Where | What the user saw | Status |
+| ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- |
+| Preflight CLI probes | Caching the result would have pinned "git not installed" until relaunch | Bounded entry ([#17350](https://github.com/stablyai/orca/pull/17350)) |
+| `glab auth status` fallback into WSL | Idle VM woken repeatedly for users who never touch GitLab | Open ([#8941](https://github.com/stablyai/orca/issues/8941)) |
+| `listRunningWslDistrosAsync` | Fails closed to `[]` with no last-known-good, polled every 2s — a persistently broken `wsl.exe` makes every WSL session vanish app-wide | Open (PR #17072 review) |
## What to do instead
diff --git a/src/main/agent-hooks/server-status-listener-fanout.test.ts b/src/main/agent-hooks/server-status-listener-fanout.test.ts
index 9f977672239..b6633dbf6f1 100644
--- a/src/main/agent-hooks/server-status-listener-fanout.test.ts
+++ b/src/main/agent-hooks/server-status-listener-fanout.test.ts
@@ -205,6 +205,144 @@ describe('AgentHookServer listener replay', () => {
expect(listener).toHaveBeenNthCalledWith(4, [])
})
+ it('evicts only the matching persisted status identity', () => {
+ const server = new AgentHookServer()
+ server.ingestRemote(
+ {
+ paneKey: PANE,
+ tabId: 'tab-1',
+ worktreeId: 'wt-1',
+ providerSession: { key: 'session_id', id: 'resume-me' },
+ payload: { state: 'done', prompt: 'old run', agentType: 'claude' }
+ },
+ 'conn-1'
+ )
+ const old = server.getStatusSnapshot()[0]
+ expect(old).toBeDefined()
+
+ server.ingestRemote(
+ {
+ paneKey: PANE,
+ tabId: 'tab-1',
+ worktreeId: 'wt-1',
+ payload: { state: 'working', prompt: 'new run', agentType: 'claude' }
+ },
+ 'conn-1'
+ )
+ server.dropPersistedStatusEntry({
+ paneKey: old!.paneKey,
+ receivedAt: old!.receivedAt,
+ stateStartedAt: old!.stateStartedAt
+ })
+
+ expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working', prompt: 'new run' })
+
+ // A matching eviction follows ordinary dismissal semantics, including
+ // preserving a resumable provider session for the still-live TUI.
+ const resumed = new AgentHookServer()
+ resumed.ingestRemote(
+ {
+ paneKey: PANE,
+ tabId: 'tab-1',
+ worktreeId: 'wt-1',
+ providerSession: { key: 'session_id', id: 'resume-me' },
+ payload: { state: 'done', prompt: 'old run', agentType: 'claude' }
+ },
+ 'conn-1'
+ )
+ const resumedIdentity = resumed.getStatusSnapshot()[0]!
+ expect(
+ resumed.dropPersistedStatusEntry({
+ paneKey: resumedIdentity.paneKey,
+ receivedAt: resumedIdentity.receivedAt,
+ stateStartedAt: resumedIdentity.stateStartedAt
+ })
+ ).toBe(true)
+ expect(resumed.getStatusSnapshot()[0]).toMatchObject({
+ providerSessionOnly: true,
+ providerSession: { id: 'resume-me' }
+ })
+ })
+
+ it('evicts when the renderer identity was stamped after receipt but pins the same turn', () => {
+ // Runtime-sync and recovery entries stamp updatedAt with Date.now()/capturedAt, which is
+ // at or after main's receivedAt; the eviction must still land for those rows.
+ const server = new AgentHookServer()
+ server.ingestRemote(
+ {
+ paneKey: PANE,
+ tabId: 'tab-1',
+ worktreeId: 'wt-1',
+ payload: { state: 'done', prompt: 'run', agentType: 'claude' }
+ },
+ 'conn-1'
+ )
+ const entry = server.getStatusSnapshot()[0]!
+ expect(
+ server.dropPersistedStatusEntry({
+ paneKey: entry.paneKey,
+ receivedAt: entry.receivedAt + 5_000,
+ stateStartedAt: entry.stateStartedAt
+ })
+ ).toBe(true)
+
+ // A different turn never matches, whatever the receivedAt relationship.
+ const other = new AgentHookServer()
+ other.ingestRemote(
+ {
+ paneKey: PANE,
+ tabId: 'tab-1',
+ worktreeId: 'wt-1',
+ payload: { state: 'done', prompt: 'run', agentType: 'claude' }
+ },
+ 'conn-1'
+ )
+ const otherEntry = other.getStatusSnapshot()[0]!
+ expect(
+ other.dropPersistedStatusEntry({
+ paneKey: otherEntry.paneKey,
+ receivedAt: otherEntry.receivedAt + 5_000,
+ stateStartedAt: otherEntry.stateStartedAt + 1
+ })
+ ).toBe(false)
+ })
+
+ it('evicts a batch of persisted identities with one status-change notification', () => {
+ const server = new AgentHookServer()
+ const otherPane = makePaneKey('tab-2', '22222222-2222-4222-8222-222222222222')
+ for (const paneKey of [PANE, otherPane]) {
+ server.ingestRemote(
+ {
+ paneKey,
+ tabId: paneKey.split(':')[0]!,
+ worktreeId: 'wt-1',
+ payload: { state: 'done', prompt: 'run', agentType: 'claude' }
+ },
+ 'conn-1'
+ )
+ }
+ const listener = vi.fn()
+ server.subscribeStatusChanges(listener)
+ const dropped: string[] = []
+ server.subscribeStatusDrop((paneKey) => dropped.push(paneKey))
+ const identities = server.getStatusSnapshot().map((entry) => ({
+ paneKey: entry.paneKey,
+ receivedAt: entry.receivedAt,
+ stateStartedAt: entry.stateStartedAt
+ }))
+
+ const evicted = server.dropPersistedStatusEntries([
+ ...identities,
+ // A stale identity never matches and never blocks the rest of the batch.
+ { ...identities[0]!, stateStartedAt: identities[0]!.stateStartedAt + 1 }
+ ])
+
+ expect(evicted.sort()).toEqual([PANE, otherPane].sort())
+ expect(dropped.sort()).toEqual([PANE, otherPane].sort())
+ expect(listener).toHaveBeenCalledTimes(1)
+ expect(server.getStatusSnapshot()).toEqual([])
+ })
+
it('notifies pane-status-clear listener when pane teardown evicts a cached status', () => {
const server = new AgentHookServer()
const listener = vi.fn()
diff --git a/src/main/agent-hooks/server/server-cleanup.ts b/src/main/agent-hooks/server/server-cleanup.ts
index 04acc058dea..4fcd0b5e58e 100644
--- a/src/main/agent-hooks/server/server-cleanup.ts
+++ b/src/main/agent-hooks/server/server-cleanup.ts
@@ -1,4 +1,5 @@
import { paneHasStateClaims } from '../../../shared/agent-hook-listener/listener-state'
+import type { AgentStatusCacheIdentity } from '../../../shared/agent-status-types'
import type { EnrichedAgentHookEventPayload } from './server-types'
import { AgentHookServerAuthorityFences } from './server-authority-fences'
@@ -35,6 +36,51 @@ export abstract class AgentHookServerCleanup extends AgentHookServerAuthorityFen
this.emitStatusDropped(deleted.paneKey)
}
+ /** Evict a UI-cleared status only if no newer status has replaced it. */
+ dropPersistedStatusEntry(identity: AgentStatusCacheIdentity): boolean {
+ return this.dropPersistedStatusEntries([identity]).length > 0
+ }
+
+ /** Batch form: one persist and one listener notification for the whole set. Returns the
+ * pane keys that were actually evicted. */
+ dropPersistedStatusEntries(identities: readonly AgentStatusCacheIdentity[]): string[] {
+ const evicted: string[] = []
+ for (const identity of identities) {
+ const resolvedPaneKey = this.resolvePaneKeyAlias(identity.paneKey)
+ const existing = this.state.lastStatusByPaneKey.get(resolvedPaneKey) as
+ | EnrichedAgentHookEventPayload
+ | undefined
+ // Why: stateStartedAt pins the turn; the renderer's updatedAt is stamped at or after this
+ // receivedAt (runtime-sync and recovery paths use Date.now()/capturedAt), so a strictly
+ // newer cached event is the only replacement worth protecting.
+ if (
+ !existing ||
+ existing.stateStartedAt !== identity.stateStartedAt ||
+ existing.receivedAt > identity.receivedAt
+ ) {
+ continue
+ }
+ const deleted = this.deleteStatusEntry(resolvedPaneKey, { preserveAuthority: true })
+ if (!deleted) {
+ continue
+ }
+ const retained = this.toRetainedProviderSessionRow(deleted)
+ if (retained) {
+ this.state.lastStatusByPaneKey.set(deleted.paneKey, retained)
+ }
+ evicted.push(deleted.paneKey)
+ }
+ if (evicted.length === 0) {
+ return evicted
+ }
+ this.scheduleStatusPersist()
+ this.notifyStatusChangeListeners()
+ for (const paneKey of evicted) {
+ this.emitStatusDropped(paneKey)
+ }
+ return evicted
+ }
+
/** Retire panes whose owning process is certifiably dead.
*
* The ordinary teardown already does this: every attributable PTY exit reaches
diff --git a/src/main/ipc/agent-hooks.test.ts b/src/main/ipc/agent-hooks.test.ts
index e9e7a728e83..cd6c21526d8 100644
--- a/src/main/ipc/agent-hooks.test.ts
+++ b/src/main/ipc/agent-hooks.test.ts
@@ -8,6 +8,8 @@ import { makePaneKey } from '../../shared/stable-pane-id'
// evicts the entry.
const dropStatusEntry = vi.fn()
+const dropPersistedStatusEntry = vi.fn()
+const dropPersistedStatusEntries = vi.fn(() => [] as string[])
const dropStatusEntriesByTabPrefix = vi.fn()
const retirePaneAuthority = vi.fn()
const transferPaneAuthority = vi.fn()
@@ -44,6 +46,8 @@ vi.mock('../agent-hooks/server', async () => {
...actual,
agentHookServer: {
dropStatusEntry,
+ dropPersistedStatusEntry,
+ dropPersistedStatusEntries,
dropStatusEntriesByTabPrefix,
retirePaneAuthority,
transferPaneAuthority,
@@ -105,6 +109,9 @@ vi.mock('../kimi/hook-service', () => ({
beforeEach(() => {
dropStatusEntry.mockReset()
+ dropPersistedStatusEntry.mockReset()
+ dropPersistedStatusEntries.mockReset()
+ dropPersistedStatusEntries.mockReturnValue([])
dropStatusEntriesByTabPrefix.mockReset()
retirePaneAuthority.mockReset()
transferPaneAuthority.mockReset()
@@ -279,6 +286,71 @@ describe('agentStatus:drop IPC', () => {
})
})
+describe('agentStatus:dropPersisted IPC', () => {
+ it('forwards a validated cache identity without clearing pane state', async () => {
+ const { registerAgentHookHandlers } = await import('./agent-hooks')
+ registerAgentHookHandlers()
+
+ const handler = onHandlers.get('agentStatus:dropPersisted')
+ expect(handler).toBeDefined()
+ const identity = {
+ paneKey: PANE_KEY,
+ receivedAt: 2_000,
+ stateStartedAt: 1_000
+ }
+ handler!({}, identity)
+ expect(dropPersistedStatusEntry).toHaveBeenCalledWith(identity)
+ expect(dropStatusEntry).not.toHaveBeenCalled()
+ })
+
+ it('forwards a batch, keeping only valid identities, and clears migration state per evicted pane', async () => {
+ const { registerAgentHookHandlers } = await import('./agent-hooks')
+ registerAgentHookHandlers()
+
+ const handler = onHandlers.get('agentStatus:dropPersistedBatch')
+ expect(handler).toBeDefined()
+ const good = { paneKey: PANE_KEY, receivedAt: 2_000, stateStartedAt: 1_000 }
+ const alsoGood = { paneKey: CHILD_PANE_KEY, receivedAt: 3_000, stateStartedAt: 2_500 }
+ dropPersistedStatusEntries.mockReturnValue([PANE_KEY])
+ handler!({}, [good, { paneKey: 'not-a-pane-key', receivedAt: 1, stateStartedAt: 1 }, alsoGood])
+ expect(dropPersistedStatusEntries).toHaveBeenCalledWith([good, alsoGood])
+ expect(clearMigrationUnsupportedPtysForPaneKey).toHaveBeenCalledWith(PANE_KEY)
+ expect(clearMigrationUnsupportedPtysForPaneKey).not.toHaveBeenCalledWith(CHILD_PANE_KEY)
+ expect(dropPersistedStatusEntry).not.toHaveBeenCalled()
+ })
+
+ it('ignores a batch that is not an array or is empty after validation', async () => {
+ const { registerAgentHookHandlers } = await import('./agent-hooks')
+ registerAgentHookHandlers()
+
+ const handler = onHandlers.get('agentStatus:dropPersistedBatch')!
+ for (const value of [null, {}, 'x', [], [{ paneKey: PANE_KEY }]]) {
+ expect(() => handler({}, value)).not.toThrow()
+ }
+ expect(dropPersistedStatusEntries).not.toHaveBeenCalled()
+ })
+
+ it('rejects malformed cache identities', async () => {
+ const { registerAgentHookHandlers } = await import('./agent-hooks')
+ registerAgentHookHandlers()
+
+ const handler = onHandlers.get('agentStatus:dropPersisted')!
+ for (const value of [
+ null,
+ undefined,
+ {},
+ { paneKey: PANE_KEY },
+ { paneKey: PANE_KEY, receivedAt: Number.NaN, stateStartedAt: 1 },
+ { paneKey: PANE_KEY, receivedAt: 2, stateStartedAt: Number.POSITIVE_INFINITY },
+ { paneKey: 'not-a-pane-key', receivedAt: 2, stateStartedAt: 1 },
+ { paneKey: PANE_KEY, receivedAt: '2', stateStartedAt: 1 }
+ ]) {
+ expect(() => handler({}, value)).not.toThrow()
+ }
+ expect(dropPersistedStatusEntry).not.toHaveBeenCalled()
+ })
+})
+
describe('agentStatus:dropByTabPrefix IPC', () => {
it('forwards valid tab ids to tab-prefix cache eviction', async () => {
const { registerAgentHookHandlers } = await import('./agent-hooks')
diff --git a/src/main/ipc/agent-status-row-teardown-ipc.ts b/src/main/ipc/agent-status-row-teardown-ipc.ts
index e33e1c93789..020cfa7259d 100644
--- a/src/main/ipc/agent-status-row-teardown-ipc.ts
+++ b/src/main/ipc/agent-status-row-teardown-ipc.ts
@@ -1,5 +1,6 @@
import { ipcMain } from 'electron'
import { agentHookServer, isValidPaneKey } from '../agent-hooks/server'
+import type { AgentStatusCacheIdentity } from '../../shared/agent-status-types'
import {
clearMigrationUnsupportedPtysByTabPrefix,
clearMigrationUnsupportedPtysForPaneKey
@@ -7,7 +8,7 @@ import {
import { isValidAgentStatusDropTabId } from './agent-status-ipc-boundary'
/**
- * The three renderer-initiated ways a status row goes away. All fire-and-forget
+ * The renderer-initiated ways a status row goes away. All fire-and-forget
* (`ipcRenderer.send` → `ipcMain.on`), so none round-trips a response; removing the
* listeners first keeps re-registration safe.
*
@@ -15,8 +16,13 @@ import { isValidAgentStatusDropTabId } from './agent-status-ipc-boundary'
* still be alive; a confirmed process exit must take them too, or a surviving Claude latch resolves
* the pane's next event straight back to `working`.
*/
+// Why a cap: the renderer sends one batch per Clear-completed click, bounded by visible rows.
+const MAX_DROP_PERSISTED_BATCH = 5_000
+
export function registerAgentStatusRowTeardownIpcHandlers(): void {
ipcMain.removeAllListeners('agentStatus:drop')
+ ipcMain.removeAllListeners('agentStatus:dropPersisted')
+ ipcMain.removeAllListeners('agentStatus:dropPersistedBatch')
ipcMain.removeAllListeners('agentStatus:reconcileEndedProcess')
ipcMain.removeAllListeners('agentStatus:dropByTabPrefix')
@@ -36,6 +42,36 @@ export function registerAgentStatusRowTeardownIpcHandlers(): void {
}
})
+ ipcMain.on('agentStatus:dropPersisted', (_event, request: unknown) => {
+ if (!isValidAgentStatusCacheIdentity(request)) {
+ return
+ }
+ try {
+ if (agentHookServer.dropPersistedStatusEntry(request)) {
+ clearMigrationUnsupportedPtysForPaneKey(request.paneKey)
+ }
+ } catch (err) {
+ console.warn('[agent-hooks] dropPersistedStatusEntry failed:', err)
+ }
+ })
+
+ ipcMain.on('agentStatus:dropPersistedBatch', (_event, request: unknown) => {
+ if (!Array.isArray(request) || request.length > MAX_DROP_PERSISTED_BATCH) {
+ return
+ }
+ const identities = request.filter(isValidAgentStatusCacheIdentity)
+ if (identities.length === 0) {
+ return
+ }
+ try {
+ for (const paneKey of agentHookServer.dropPersistedStatusEntries(identities)) {
+ clearMigrationUnsupportedPtysForPaneKey(paneKey)
+ }
+ } catch (err) {
+ console.warn('[agent-hooks] dropPersistedStatusEntries failed:', err)
+ }
+ })
+
ipcMain.on('agentStatus:reconcileEndedProcess', (_event, paneKey: unknown) => {
if (typeof paneKey !== 'string' || !isValidPaneKey(paneKey)) {
return
@@ -67,3 +103,18 @@ export function registerAgentStatusRowTeardownIpcHandlers(): void {
}
})
}
+
+function isValidAgentStatusCacheIdentity(value: unknown): value is AgentStatusCacheIdentity {
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
+ return false
+ }
+ const request = value as Record
+ return (
+ typeof request.paneKey === 'string' &&
+ isValidPaneKey(request.paneKey) &&
+ typeof request.receivedAt === 'number' &&
+ Number.isFinite(request.receivedAt) &&
+ typeof request.stateStartedAt === 'number' &&
+ Number.isFinite(request.stateStartedAt)
+ )
+}
diff --git a/src/main/persistence/applying-settings/ui-state-read.ts b/src/main/persistence/applying-settings/ui-state-read.ts
index 7a249934260..a640f971f89 100644
--- a/src/main/persistence/applying-settings/ui-state-read.ts
+++ b/src/main/persistence/applying-settings/ui-state-read.ts
@@ -62,6 +62,7 @@ export function getPersistedUI(
markdownTocPanelWidth: clampMarkdownTocPanelWidth(state.ui?.markdownTocPanelWidth),
combinedDiffFileTreeWidth: clampCombinedDiffFileTreeWidth(state.ui?.combinedDiffFileTreeWidth),
visibleWorkspaceHostIds: normalizeVisibleExecutionHostIds(state.ui?.visibleWorkspaceHostIds),
+ agentsVisibleHostIds: normalizeVisibleExecutionHostIds(state.ui?.agentsVisibleHostIds),
workspaceHostOrder: normalizeExecutionHostOrder(state.ui?.workspaceHostOrder),
manualRepoOrder: normalizeManualRepoOrder(state.ui?.manualRepoOrder),
browserDefaultZoomLevel: normalizeBrowserPageZoomLevel(state.ui?.browserDefaultZoomLevel),
diff --git a/src/main/persistence/applying-settings/ui-state-update.ts b/src/main/persistence/applying-settings/ui-state-update.ts
index b93ddfb1296..db06a2738fd 100644
--- a/src/main/persistence/applying-settings/ui-state-update.ts
+++ b/src/main/persistence/applying-settings/ui-state-update.ts
@@ -152,6 +152,10 @@ export function updatePersistedUI(
sanitizedUpdates.visibleWorkspaceHostIds !== undefined
? normalizeVisibleExecutionHostIds(sanitizedUpdates.visibleWorkspaceHostIds)
: normalizeVisibleExecutionHostIds(operations.state.ui?.visibleWorkspaceHostIds),
+ agentsVisibleHostIds:
+ sanitizedUpdates.agentsVisibleHostIds !== undefined
+ ? normalizeVisibleExecutionHostIds(sanitizedUpdates.agentsVisibleHostIds)
+ : normalizeVisibleExecutionHostIds(operations.state.ui?.agentsVisibleHostIds),
workspaceHostOrder:
sanitizedUpdates.workspaceHostOrder !== undefined
? normalizeExecutionHostOrder(sanitizedUpdates.workspaceHostOrder)
diff --git a/src/main/persistence/loading-store/normalize-loaded-global-settings.test.ts b/src/main/persistence/loading-store/normalize-loaded-global-settings.test.ts
new file mode 100644
index 00000000000..6d1040f09ae
--- /dev/null
+++ b/src/main/persistence/loading-store/normalize-loaded-global-settings.test.ts
@@ -0,0 +1,80 @@
+import { homedir } from 'node:os'
+import { describe, expect, it } from 'vitest'
+import { getDefaultPersistedState } from '../../../shared/constants'
+import { normalizeLoadedGlobalSettings } from './normalize-loaded-global-settings'
+import { prepareLoadedTerminalSettings } from './prepare-loaded-terminal-settings'
+import { prepareLoadedProfileSettings } from './prepare-loaded-profile-settings'
+import type { GlobalSettings } from '../../../shared/global-settings-types'
+import type { PersistedState } from '../../../shared/persisted-state-types'
+
+// Simulates a profile created before the dedicated Experimental switch was persisted.
+function normalizeLegacyProfile(overrides: Partial): PersistedState['settings'] {
+ const defaults = getDefaultPersistedState(homedir())
+ const settings: Partial = { ...defaults.settings }
+ delete settings.showAgentsSidebar
+ delete settings.experimentalActivity
+ delete settings.experimentalAgentDashboardPopout
+ Object.assign(settings, overrides)
+ const parsed: PersistedState = { ...defaults, settings: settings as GlobalSettings }
+ const noop = (): void => {}
+ const terminal = prepareLoadedTerminalSettings(parsed, noop)
+ const profile = prepareLoadedProfileSettings(parsed, defaults, noop)
+ return normalizeLoadedGlobalSettings(parsed, terminal, profile)
+}
+
+describe('showAgentsSidebar experimental-setting migration', () => {
+ it('keeps the sidebar for Agents-view opt-ins regardless of the dashboard experiment', () => {
+ const normalized = normalizeLegacyProfile({
+ experimentalActivity: true,
+ experimentalAgentDashboardPopout: false
+ })
+ expect(normalized.showAgentsSidebar).toBe(true)
+ expect(normalized.agentsSidebarMigratedFromExperimental).toBe(true)
+ })
+
+ it('carries the legacy Agents-view opt-in into the sidebar', () => {
+ expect(normalizeLegacyProfile({ experimentalActivity: true }).showAgentsSidebar).toBe(true)
+ })
+
+ it('does not show Agents migration copy for a dashboard-only opt-in', () => {
+ expect(
+ normalizeLegacyProfile({ experimentalAgentDashboardPopout: true })
+ .agentsSidebarMigratedFromExperimental
+ ).toBe(false)
+ })
+
+ it('defaults profiles with no legacy signal to the sidebar', () => {
+ const normalized = normalizeLegacyProfile({})
+ expect(normalized.showAgentsSidebar).toBe(true)
+ expect(normalized.agentsSidebarMigratedFromExperimental).toBe(false)
+ })
+
+ it('does not treat a dashboard opt-out as an Agents-tab opt-out', () => {
+ expect(
+ normalizeLegacyProfile({ experimentalAgentDashboardPopout: false }).showAgentsSidebar
+ ).toBe(true)
+ })
+
+ it('ignores a pre-stamp forced-default experimentalActivity true (not an opt-in)', () => {
+ const normalized = normalizeLegacyProfile({
+ experimentalActivity: true,
+ experimentalActivityDefaultedOffForAllUsers: undefined
+ })
+ expect(normalized.experimentalActivity).toBe(false)
+ expect(normalized.showAgentsSidebar).toBe(true)
+ expect(normalized.agentsSidebarMigratedFromExperimental).toBe(false)
+ })
+
+ it('preserves a stored showAgentsSidebar choice over legacy flags', () => {
+ expect(
+ normalizeLegacyProfile({ showAgentsSidebar: false, experimentalActivity: true })
+ .showAgentsSidebar
+ ).toBe(false)
+ expect(
+ normalizeLegacyProfile({
+ showAgentsSidebar: true,
+ experimentalAgentDashboardPopout: false
+ }).showAgentsSidebar
+ ).toBe(true)
+ })
+})
diff --git a/src/main/persistence/loading-store/normalize-loaded-global-settings.ts b/src/main/persistence/loading-store/normalize-loaded-global-settings.ts
index fb5cc9f1fe8..9370ec4febc 100644
--- a/src/main/persistence/loading-store/normalize-loaded-global-settings.ts
+++ b/src/main/persistence/loading-store/normalize-loaded-global-settings.ts
@@ -1,4 +1,5 @@
import { getDefaultVoiceSettings } from '../../../shared/constants'
+import { resolveAgentsSidebarVisible } from '../../../shared/agents-sidebar-visibility'
import { normalizePRBotAuthorOverrides } from '../../../shared/pr-bot-author-overrides'
import { normalizeTerminalQuickCommands } from '../../../shared/terminal-quick-commands'
import { normalizeOpenInApplications } from '../../../shared/open-in-applications'
@@ -85,6 +86,16 @@ export function normalizeLoadedGlobalSettings(
...migratedTerminalTuiScrollSensitivity.settings,
experimentalActivity: migratedExperimentalActivity,
experimentalActivityDefaultedOffForAllUsers: true,
+ // Keep the experimental Agents tab's rollout default for older profiles while
+ // preserving any choice made through its dedicated Experimental setting.
+ showAgentsSidebar: resolveAgentsSidebarVisible({
+ showAgentsSidebar: parsed.settings?.showAgentsSidebar
+ }),
+ // Preserve the legacy opt-in before the experimental setting is normalized away. This
+ // drives the migration-specific introduction copy without changing runtime behavior.
+ agentsSidebarMigratedFromExperimental:
+ parsed.settings?.agentsSidebarMigratedFromExperimental === true ||
+ migratedExperimentalActivity,
// Why: compact worktree cards graduated from Experimental; preserve the old opt-in for rollout-era profiles.
compactWorktreeCards: loadedCompactWorktreeCards,
experimentalCompactWorktreeCards: undefined,
diff --git a/src/main/persistence/loading-store/workspace-session-snapshot-publication.ts b/src/main/persistence/loading-store/workspace-session-snapshot-publication.ts
index 97cd3b2b544..5b7f90cbb8f 100644
--- a/src/main/persistence/loading-store/workspace-session-snapshot-publication.ts
+++ b/src/main/persistence/loading-store/workspace-session-snapshot-publication.ts
@@ -15,6 +15,8 @@ import { registerPersistedPaneKeyAlias } from '../restoring-sessions/pane-alias-
import {
normalizeWorkspaceSessionPaneIdentities,
remapAcknowledgedAgentPaneKeys,
+ remapActivityClearedAtPaneKeys,
+ remapManuallyUnreadTurnPaneKeys,
remapSshRemotePtyLeaseLeafIds,
type WorkspaceSessionPaneIdentityRemap
} from '../restoring-sessions/workspace-pane-normalization'
@@ -50,10 +52,30 @@ export function setLocalWorkspaceSession(
context.runtime.state.ui?.acknowledgedAgentsByPaneKey,
normalized.leafIdByInputLeafIdByTabId
)
- if (remappedAcknowledgements.changed) {
+ const remappedActivityCutoffs = remapActivityClearedAtPaneKeys(
+ context.runtime.state.ui?.activityClearedAtByPaneKey,
+ normalized.leafIdByInputLeafIdByTabId
+ )
+ const remappedManualUnread = remapManuallyUnreadTurnPaneKeys(
+ context.runtime.state.ui?.manuallyUnreadTurnsByPaneKey,
+ normalized.leafIdByInputLeafIdByTabId
+ )
+ if (
+ remappedAcknowledgements.changed ||
+ remappedActivityCutoffs.changed ||
+ remappedManualUnread.changed
+ ) {
context.runtime.state.ui = {
...context.runtime.state.ui,
- acknowledgedAgentsByPaneKey: remappedAcknowledgements.acknowledgements
+ ...(remappedAcknowledgements.changed
+ ? { acknowledgedAgentsByPaneKey: remappedAcknowledgements.acknowledgements }
+ : {}),
+ ...(remappedActivityCutoffs.changed
+ ? { activityClearedAtByPaneKey: remappedActivityCutoffs.cutoffs }
+ : {}),
+ ...(remappedManualUnread.changed
+ ? { manuallyUnreadTurnsByPaneKey: remappedManualUnread.turns }
+ : {})
}
}
for (const entry of normalized.legacyPaneKeyAliasEntries) {
diff --git a/src/main/persistence/restoring-sessions/pane-key-remapping.test.ts b/src/main/persistence/restoring-sessions/pane-key-remapping.test.ts
new file mode 100644
index 00000000000..c0359efd612
--- /dev/null
+++ b/src/main/persistence/restoring-sessions/pane-key-remapping.test.ts
@@ -0,0 +1,33 @@
+import { describe, expect, it } from 'vitest'
+import { makePaneKey } from '../../../shared/stable-pane-id'
+import {
+ remapActivityClearedAtPaneKeys,
+ remapManuallyUnreadTurnPaneKeys
+} from './pane-key-remapping'
+
+const STABLE_LEAF_ID = '00000000-0000-4000-8000-000000000001'
+
+describe('remapManuallyUnreadTurnPaneKeys', () => {
+ it('promotes legacy pane keys to the restored stable leaf like clear-completed cutoffs', () => {
+ const remap = new Map([['tab-1', new Map([['pane:1', STABLE_LEAF_ID]])]])
+ const turns = { 'tab-1:pane:1': 42, 'tab-2:pane:9': 7 }
+
+ const result = remapManuallyUnreadTurnPaneKeys(turns, remap)
+
+ expect(result.changed).toBe(true)
+ expect(result.turns).toEqual({ [makePaneKey('tab-1', STABLE_LEAF_ID)]: 42, 'tab-2:pane:9': 7 })
+ // Same remap contract as the cutoffs so the two never drift after a session restore.
+ expect(remapActivityClearedAtPaneKeys(turns, remap).cutoffs).toEqual(result.turns)
+ })
+
+ it('reports no change for empty or already-stable records', () => {
+ const remap = new Map([['tab-1', new Map([['pane:1', STABLE_LEAF_ID]])]])
+ expect(remapManuallyUnreadTurnPaneKeys(undefined, remap).changed).toBe(false)
+ expect(remapManuallyUnreadTurnPaneKeys({}, remap).changed).toBe(false)
+ const stable = { [makePaneKey('tab-1', STABLE_LEAF_ID)]: 1 }
+ expect(remapManuallyUnreadTurnPaneKeys(stable, remap)).toEqual({
+ turns: stable,
+ changed: false
+ })
+ })
+})
diff --git a/src/main/persistence/restoring-sessions/pane-key-remapping.ts b/src/main/persistence/restoring-sessions/pane-key-remapping.ts
new file mode 100644
index 00000000000..4f3440f087e
--- /dev/null
+++ b/src/main/persistence/restoring-sessions/pane-key-remapping.ts
@@ -0,0 +1,75 @@
+import type { PersistedState } from '../../../shared/persisted-state-types'
+import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../../shared/stable-pane-id'
+
+type PaneLeafRemap = Map>
+
+function remapPaneKeys(
+ values: Record | undefined,
+ leafIdByInputLeafIdByTabId: PaneLeafRemap
+): { values: Record | undefined; changed: boolean } {
+ if (!values || Object.keys(values).length === 0) {
+ return { values, changed: false }
+ }
+
+ let changed = false
+ const next: Record = {}
+ const setValue = (paneKey: string, value: T): void => {
+ const existing = next[paneKey]
+ next[paneKey] = existing === undefined ? value : (Math.max(existing, value) as T)
+ }
+ for (const [paneKey, value] of Object.entries(values)) {
+ const parsed = parsePaneKey(paneKey)
+ if (parsed) {
+ setValue(paneKey, value)
+ continue
+ }
+
+ const delimiter = paneKey.indexOf(':')
+ if (delimiter <= 0 || delimiter === paneKey.length - 1) {
+ setValue(paneKey, value)
+ continue
+ }
+
+ const tabId = paneKey.slice(0, delimiter)
+ const legacyLeafId = paneKey.slice(delimiter + 1)
+ const remappedLeafId = leafIdByInputLeafIdByTabId.get(tabId)?.get(legacyLeafId)
+ if (!remappedLeafId || !isTerminalLeafId(remappedLeafId)) {
+ setValue(paneKey, value)
+ continue
+ }
+
+ try {
+ // Carry values over when a legacy leaf is promoted to a UUID.
+ setValue(makePaneKey(tabId, remappedLeafId), value)
+ changed = true
+ } catch {
+ setValue(paneKey, value)
+ }
+ }
+
+ return { values: next, changed }
+}
+
+export function remapAcknowledgedAgentPaneKeys(
+ acknowledgements: PersistedState['ui']['acknowledgedAgentsByPaneKey'],
+ leafIdByInputLeafIdByTabId: PaneLeafRemap
+): { acknowledgements: PersistedState['ui']['acknowledgedAgentsByPaneKey']; changed: boolean } {
+ const result = remapPaneKeys(acknowledgements, leafIdByInputLeafIdByTabId)
+ return { acknowledgements: result.values, changed: result.changed }
+}
+
+export function remapManuallyUnreadTurnPaneKeys(
+ turns: PersistedState['ui']['manuallyUnreadTurnsByPaneKey'],
+ leafIdByInputLeafIdByTabId: PaneLeafRemap
+): { turns: PersistedState['ui']['manuallyUnreadTurnsByPaneKey']; changed: boolean } {
+ const result = remapPaneKeys(turns, leafIdByInputLeafIdByTabId)
+ return { turns: result.values, changed: result.changed }
+}
+
+export function remapActivityClearedAtPaneKeys(
+ cutoffs: PersistedState['ui']['activityClearedAtByPaneKey'],
+ leafIdByInputLeafIdByTabId: PaneLeafRemap
+): { cutoffs: PersistedState['ui']['activityClearedAtByPaneKey']; changed: boolean } {
+ const result = remapPaneKeys(cutoffs, leafIdByInputLeafIdByTabId)
+ return { cutoffs: result.values, changed: result.changed }
+}
diff --git a/src/main/persistence/restoring-sessions/workspace-pane-normalization.ts b/src/main/persistence/restoring-sessions/workspace-pane-normalization.ts
index 2bfafebae9f..cfc26c7bb8a 100644
--- a/src/main/persistence/restoring-sessions/workspace-pane-normalization.ts
+++ b/src/main/persistence/restoring-sessions/workspace-pane-normalization.ts
@@ -8,7 +8,7 @@ import {
type ExecutionHostId
} from '../../../shared/execution-host'
import type { SshRemotePtyLease } from '../../../shared/ssh-types'
-import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../../shared/stable-pane-id'
+import { isTerminalLeafId, parsePaneKey } from '../../../shared/stable-pane-id'
import { findCrossHostPaneTabIds, withoutPaneTabIds } from './cross-host-pane-tab-ids'
import {
createLazyTerminalTabLookup,
@@ -22,6 +22,17 @@ import {
migrationUnsupportedEntriesEqual,
normalizeLegacyPaneKeyAliasEntries
} from './pane-alias-normalization'
+import {
+ remapAcknowledgedAgentPaneKeys,
+ remapActivityClearedAtPaneKeys,
+ remapManuallyUnreadTurnPaneKeys
+} from './pane-key-remapping'
+
+export {
+ remapAcknowledgedAgentPaneKeys,
+ remapActivityClearedAtPaneKeys,
+ remapManuallyUnreadTurnPaneKeys
+} from './pane-key-remapping'
export function normalizeWorkspaceSessionPaneIdentities(
session: WorkspaceSessionState,
@@ -220,6 +231,14 @@ export function normalizePersistedPaneIdentityState(state: PersistedState): {
state.ui?.acknowledgedAgentsByPaneKey,
withoutPaneTabIds(acknowledgementLeafIdByInputLeafIdByTabId, crossHostTabIds)
)
+ const remappedActivityCutoffs = remapActivityClearedAtPaneKeys(
+ state.ui?.activityClearedAtByPaneKey,
+ withoutPaneTabIds(acknowledgementLeafIdByInputLeafIdByTabId, crossHostTabIds)
+ )
+ const remappedManualUnread = remapManuallyUnreadTurnPaneKeys(
+ state.ui?.manuallyUnreadTurnsByPaneKey,
+ withoutPaneTabIds(acknowledgementLeafIdByInputLeafIdByTabId, crossHostTabIds)
+ )
const migrationUnsupportedChanged = !migrationUnsupportedEntriesEqual(
state.migrationUnsupportedPtyEntries ?? [],
mergedMigrationUnsupportedEntries
@@ -234,7 +253,9 @@ export function normalizePersistedPaneIdentityState(state: PersistedState): {
!remappedLeases.changed &&
!migrationUnsupportedChanged &&
!legacyAliasesChanged &&
- !remappedAcknowledgements.changed
+ !remappedAcknowledgements.changed &&
+ !remappedActivityCutoffs.changed &&
+ !remappedManualUnread.changed
) {
return {
state,
@@ -251,11 +272,21 @@ export function normalizePersistedPaneIdentityState(state: PersistedState): {
sshRemotePtyLeases: remappedLeases.leases,
migrationUnsupportedPtyEntries: mergedMigrationUnsupportedEntries,
legacyPaneKeyAliasEntries: mergedLegacyPaneKeyAliasEntries,
- ...(remappedAcknowledgements.changed
+ ...(remappedAcknowledgements.changed ||
+ remappedActivityCutoffs.changed ||
+ remappedManualUnread.changed
? {
ui: {
...state.ui,
- acknowledgedAgentsByPaneKey: remappedAcknowledgements.acknowledgements
+ ...(remappedAcknowledgements.changed
+ ? { acknowledgedAgentsByPaneKey: remappedAcknowledgements.acknowledgements }
+ : {}),
+ ...(remappedActivityCutoffs.changed
+ ? { activityClearedAtByPaneKey: remappedActivityCutoffs.cutoffs }
+ : {}),
+ ...(remappedManualUnread.changed
+ ? { manuallyUnreadTurnsByPaneKey: remappedManualUnread.turns }
+ : {})
}
}
: {})
@@ -265,50 +296,3 @@ export function normalizePersistedPaneIdentityState(state: PersistedState): {
legacyPaneKeyAliasEntries: mergedLegacyPaneKeyAliasEntries
}
}
-
-export function remapAcknowledgedAgentPaneKeys(
- acknowledgements: PersistedState['ui']['acknowledgedAgentsByPaneKey'],
- leafIdByInputLeafIdByTabId: Map>
-): { acknowledgements: PersistedState['ui']['acknowledgedAgentsByPaneKey']; changed: boolean } {
- if (!acknowledgements || Object.keys(acknowledgements).length === 0) {
- return { acknowledgements, changed: false }
- }
-
- let changed = false
- const next: NonNullable = {}
- const setAcknowledgement = (paneKey: string, acknowledgedAt: number): void => {
- const existing = next[paneKey]
- next[paneKey] = existing === undefined ? acknowledgedAt : Math.max(existing, acknowledgedAt)
- }
- for (const [paneKey, acknowledgedAt] of Object.entries(acknowledgements)) {
- const parsed = parsePaneKey(paneKey)
- if (parsed) {
- setAcknowledgement(paneKey, acknowledgedAt)
- continue
- }
-
- const delimiter = paneKey.indexOf(':')
- if (delimiter <= 0 || delimiter === paneKey.length - 1) {
- setAcknowledgement(paneKey, acknowledgedAt)
- continue
- }
-
- const tabId = paneKey.slice(0, delimiter)
- const legacyLeafId = paneKey.slice(delimiter + 1)
- const remappedLeafId = leafIdByInputLeafIdByTabId.get(tabId)?.get(legacyLeafId)
- if (!remappedLeafId || !isTerminalLeafId(remappedLeafId)) {
- setAcknowledgement(paneKey, acknowledgedAt)
- continue
- }
-
- try {
- // Why: when a legacy leaf is promoted to a UUID, carry the read marker over so seen rows don't come back unread.
- setAcknowledgement(makePaneKey(tabId, remappedLeafId), acknowledgedAt)
- changed = true
- } catch {
- setAcknowledgement(paneKey, acknowledgedAt)
- }
- }
-
- return { acknowledgements: next, changed }
-}
diff --git a/src/main/runtime/rpc/methods/client-ui-pairing-local-fields.test.ts b/src/main/runtime/rpc/methods/client-ui-pairing-local-fields.test.ts
index 02c93c14f6b..d6c08ad44cf 100644
--- a/src/main/runtime/rpc/methods/client-ui-pairing-local-fields.test.ts
+++ b/src/main/runtime/rpc/methods/client-ui-pairing-local-fields.test.ts
@@ -44,7 +44,13 @@ describe('client UI RPC pairing-local field seams', () => {
manualRepoOrder: [
{ hostId: 'runtime:web-11111111-2222-3333-4444-555555555555', repoId: 'repo-a' }
],
- workspaceHostOrder: ['runtime:web-11111111-2222-3333-4444-555555555555', 'local']
+ workspaceHostOrder: ['runtime:web-11111111-2222-3333-4444-555555555555', 'local'],
+ agentsVisibleHostIds: ['runtime:web-11111111-2222-3333-4444-555555555555'],
+ agentsFilterRepoIds: ['repo-a'],
+ agentsShowChildAgents: true,
+ agentsCompactMode: false,
+ activityClearedAtByPaneKey: { 'tab-1:leaf-1': 123 },
+ manuallyUnreadTurnsByPaneKey: { 'tab-1:leaf-1': 321 }
}
it.each(PAIRING_LOCAL_UI_FIELDS.map((field) => [field] as const))(
diff --git a/src/main/runtime/rpc/methods/client-ui-schemas.ts b/src/main/runtime/rpc/methods/client-ui-schemas.ts
index 79b923a55db..edb3f651c32 100644
--- a/src/main/runtime/rpc/methods/client-ui-schemas.ts
+++ b/src/main/runtime/rpc/methods/client-ui-schemas.ts
@@ -122,6 +122,10 @@ const UiUpdateFields = z
showInactiveWorkspaces: z.boolean().optional(),
workspaceHostScope: z.string().optional(),
visibleWorkspaceHostIds: z.array(z.string()).nullable().optional(),
+ agentsVisibleHostIds: z.array(z.string()).nullable().optional(),
+ agentsFilterRepoIds: StringArray.optional(),
+ agentsShowChildAgents: z.boolean().optional(),
+ agentsCompactMode: z.boolean().optional(),
workspaceHostOrder: z.array(z.string()).optional(),
automationHostFilter: z
.union([
@@ -171,6 +175,8 @@ const UiUpdateFields = z
updateReassuranceSeen: z.boolean().optional(),
osc52ClipboardDefaultOnNoticePending: z.boolean().optional(),
acknowledgedAgentsByPaneKey: z.record(z.string(), z.number().finite()).optional(),
+ activityClearedAtByPaneKey: z.record(z.string(), z.number().finite()).optional(),
+ manuallyUnreadTurnsByPaneKey: z.record(z.string(), z.number().finite()).optional(),
browserDefaultUrl: NullableString.optional(),
browserDefaultSearchEngine: z
.enum(['google', 'duckduckgo', 'bing', 'kagi'])
diff --git a/src/preload/api/agent-status-api.ts b/src/preload/api/agent-status-api.ts
index fd6da88e351..7aa6c21115d 100644
--- a/src/preload/api/agent-status-api.ts
+++ b/src/preload/api/agent-status-api.ts
@@ -1,4 +1,5 @@
import type {
+ AgentStatusCacheIdentity,
AgentStatusClearIpcPayload,
AgentStatusIpcPayload,
MigrationUnsupportedPtyEntry
@@ -30,6 +31,10 @@ export type AgentStatusApi = {
getMigrationUnsupportedSnapshot: () => Promise
/** Drop a paneKey from the main-process hook cache and on-disk last-status file. Fire-and-forget. */
drop: (paneKey: string) => void
+ /** Evict a previously-cleared status only when its identity still matches the main-process cache. */
+ dropPersisted: (identity: AgentStatusCacheIdentity) => void
+ /** Same as dropPersisted for many identities in one IPC message and one listener notification. */
+ dropPersistedBatch?: (identities: readonly AgentStatusCacheIdentity[]) => void
/** Retire a pane whose agent process is proven gone — clears the row AND the per-pane caches a
* dismissal deliberately keeps. Not `drop`: that one is a user dismissal of a live pane's row. */
reconcileEndedProcess: (paneKey: string) => void
diff --git a/src/preload/api/agent-status-bridge.ts b/src/preload/api/agent-status-bridge.ts
index b5ac5c8b5b2..3cc1654aaed 100644
--- a/src/preload/api/agent-status-bridge.ts
+++ b/src/preload/api/agent-status-bridge.ts
@@ -1,5 +1,6 @@
import { ipcRenderer } from 'electron'
import type {
+ AgentStatusCacheIdentity,
AgentStatusClearIpcPayload,
AgentStatusIpcPayload,
MigrationUnsupportedPtyEntry
@@ -66,6 +67,12 @@ export const agentStatusApi = {
drop: (paneKey: string): void => {
ipcRenderer.send('agentStatus:drop', paneKey)
},
+ dropPersisted: (identity: AgentStatusCacheIdentity): void => {
+ ipcRenderer.send('agentStatus:dropPersisted', identity)
+ },
+ dropPersistedBatch: (identities: readonly AgentStatusCacheIdentity[]): void => {
+ ipcRenderer.send('agentStatus:dropPersistedBatch', identities)
+ },
reconcileEndedProcess: (paneKey: string): void => {
ipcRenderer.send('agentStatus:reconcileEndedProcess', paneKey)
},
diff --git a/src/renderer/src/app-shell/use-app-startup-hydration.ts b/src/renderer/src/app-shell/use-app-startup-hydration.ts
index 2b5272f440d..447fbfb3780 100644
--- a/src/renderer/src/app-shell/use-app-startup-hydration.ts
+++ b/src/renderer/src/app-shell/use-app-startup-hydration.ts
@@ -269,6 +269,16 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta
// Why (issue #1158): unlock the session writer only after hydration and all dependent steps succeeded, so a mid-startup throw can't serialize partially-mutated state to disk.
actions.setHydrationSucceeded(true)
actions.setTerminalStartupRestorationReady(true)
+ // Why the explicit opt-in: unconditional seeding hijacks every empty dev
+ // profile's active workspace, making onboarding/empty-state flows untestable.
+ if (
+ import.meta.env.DEV &&
+ String(import.meta.env.VITE_ACTIVITY_DEV_FIXTURE).toLowerCase() === 'true'
+ ) {
+ const { seedDevActivityFixture } =
+ await import('../components/activity/dev-activity-fixture')
+ seedDevActivityFixture()
+ }
logRendererStartupDiagnostic('startup-hydration-done', {
durationMs: Math.round(performance.now() - startupStartedAt)
})
diff --git a/src/renderer/src/app-shell/use-persisted-ui-writer.ts b/src/renderer/src/app-shell/use-persisted-ui-writer.ts
index 857b55fc8a4..19109c25eb2 100644
--- a/src/renderer/src/app-shell/use-persisted-ui-writer.ts
+++ b/src/renderer/src/app-shell/use-persisted-ui-writer.ts
@@ -167,7 +167,11 @@ export function usePersistedUIWriter(): void {
// paths in agent-status.ts (close/dismiss) flow to disk through map identity changes.
// Without persisting, agent rows that survive restart come back bold even when the
// user had already visited them.
- acknowledgedAgentsByPaneKey: s.acknowledgedAgentsByPaneKey
+ acknowledgedAgentsByPaneKey: s.acknowledgedAgentsByPaneKey,
+ // Why: "Clear completed" must survive restart, or cleared done/interrupted rows return.
+ activityClearedAtByPaneKey: s.activityClearedAtByPaneKey,
+ // Why: an explicit "mark unread" must survive restart, or the row comes back read.
+ manuallyUnreadTurnsByPaneKey: s.manuallyUnreadTurnsByPaneKey
}))
)
useEffect(() => {
diff --git a/src/renderer/src/components/activity/ActivityPrototypePage.test.ts b/src/renderer/src/components/activity/ActivityPrototypePage.test.ts
index 48bdbb3a250..9ccf93248dc 100644
--- a/src/renderer/src/components/activity/ActivityPrototypePage.test.ts
+++ b/src/renderer/src/components/activity/ActivityPrototypePage.test.ts
@@ -399,7 +399,7 @@ describe('buildActivityEvents', () => {
expect(threads[0].events[0].entry.prompt).toBe('Retained prior run')
})
- it('groups visible threads by current status order', () => {
+ it('groups visible threads with attention states before working and done', () => {
const repo = makeRepo()
const worktree = makeWorktree()
const workingTab = makeTab()
@@ -442,11 +442,49 @@ describe('buildActivityEvents', () => {
})
)
- expect(groups.map((group) => group.id)).toEqual(['working', 'blocked', 'done'])
+ expect(groups.map((group) => group.id)).toEqual(['blocked', 'working', 'done'])
expect(groups.map((group) => group.threads.map((thread) => thread.paneKey))).toEqual([
- [PANE_KEY],
[PANE_KEY_2],
+ [PANE_KEY],
[PANE_KEY_3]
])
})
+
+ it('merges runtime orchestration context into activity events and entries', () => {
+ const repo = makeRepo()
+ const worktree = makeWorktree()
+ const tab1 = makeTabWithIds('tab-1', worktree.id)
+ const tab2 = makeTabWithIds('tab-2', worktree.id)
+ const result = buildActivityEvents({
+ agentStatusByPaneKey: {
+ [PANE_KEY]: makeWorkingEntryWithoutHistory(),
+ [PANE_KEY_2]: {
+ ...makeWorkingEntryWithoutHistory(),
+ paneKey: PANE_KEY_2,
+ terminalHandle: 'terminal-child'
+ }
+ },
+ runtimeAgentOrchestrationByPaneKey: {
+ [PANE_KEY_2]: {
+ parentPaneKey: PANE_KEY,
+ parentTerminalHandle: 'terminal-parent',
+ taskId: 'task-counsel',
+ dispatchId: 'ctx-counsel'
+ }
+ },
+ retainedAgentsByPaneKey: {},
+ tabsByWorktree: {
+ [worktree.id]: [tab1, tab2]
+ },
+ worktreeMap: new Map([[worktree.id, worktree]]),
+ repoMap: new Map([[repo.id, repo]]),
+ acknowledgedAgentsByPaneKey: {},
+ now: 5_000
+ })
+
+ expect(result.liveAgentByPaneKey[PANE_KEY_2].entry.orchestration?.parentPaneKey).toBe(PANE_KEY)
+ expect(result.liveAgentByPaneKey[PANE_KEY_2].entry.orchestration?.parentTerminalHandle).toBe(
+ 'terminal-parent'
+ )
+ })
})
diff --git a/src/renderer/src/components/activity/ActivityPrototypePage.thread-grouping.test.ts b/src/renderer/src/components/activity/ActivityPrototypePage.thread-grouping.test.ts
index 72a1b6d3355..0435f2c61f2 100644
--- a/src/renderer/src/components/activity/ActivityPrototypePage.thread-grouping.test.ts
+++ b/src/renderer/src/components/activity/ActivityPrototypePage.thread-grouping.test.ts
@@ -207,4 +207,17 @@ describe('activity thread grouping', () => {
it('returns no groups for empty thread input', () => {
expect(buildActivityThreadGroups([], 'status')).toEqual([])
})
+
+ it('keeps all threads in one ungrouped list', () => {
+ const threads = makeThreads(
+ makeActivityResult({
+ entries: {
+ [PANE_KEY]: makeWorkingEntryWithoutHistory(),
+ [PANE_KEY_2]: makeWorkingEntryWithoutHistory()
+ }
+ })
+ )
+
+ expect(buildActivityThreadGroups(threads, 'none')).toEqual([{ key: 'all', label: '', threads }])
+ })
})
diff --git a/src/renderer/src/components/activity/ActivityPrototypePage.tsx b/src/renderer/src/components/activity/ActivityPrototypePage.tsx
index 59fa8a0f1a0..83581d4cf10 100644
--- a/src/renderer/src/components/activity/ActivityPrototypePage.tsx
+++ b/src/renderer/src/components/activity/ActivityPrototypePage.tsx
@@ -1,8 +1,7 @@
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
-import { useShallow } from 'zustand/react/shallow'
-import { useSidebarResize } from '@/hooks/useSidebarResize'
import { useAppStore } from '@/store'
-import { getRepoMapFromState, getWorktreeMapFromState } from '@/store/selectors'
+import { useSidebarResize } from '@/hooks/useSidebarResize'
+import { ActivityScopeFilterChips } from './activity-scope-filter-controls'
import {
setActivityTerminalPortals,
type ActivityTerminalPortalTarget
@@ -11,15 +10,10 @@ import {
reconcileActivityPortalThreads,
resolveActivityPortalSwap
} from './activity-portal-thread-reconciliation'
-import { buildActivityEvents } from './activity-event-builder'
-import { buildAgentPaneThreads } from './activity-thread-builder'
-import {
- activityThreadMatchesSearchQuery,
- buildActivityThreadGroups,
- isActivitySearchQueryTooLarge
-} from './activity-thread-grouping'
+import { useAgentPaneThreads } from './use-agent-pane-threads'
import { handleActivityFilterFocusShortcut } from './activity-filter-focus-shortcut'
-import { createActivityThreadActions } from './activity-thread-actions'
+import { hasActivityThreadWorkspace } from './activity-thread-actions'
+import { useActivityThreadActionBindings } from './use-activity-thread-action-bindings'
import { ActivityThreadListPane } from './activity-thread-list-pane'
import { ActivityThreadDetailPane } from './activity-thread-detail-pane'
import {
@@ -42,7 +36,11 @@ export default function ActivityPrototypePage(): React.JSX.Element {
const activityFilterInputRef = useRef(null)
// Why: bounds auto mark-read to one acknowledgement per selected thread turn.
const autoAcknowledgedTurnRef = useRef(null)
- const [compactMode, setCompactMode] = useState(false)
+ // Why store-backed: persisted preferences shared with the sidebar agents list.
+ const compactMode = useAppStore((s) => s.agentsCompactMode)
+ const setCompactMode = useAppStore((s) => s.setAgentsCompactMode)
+ const showChildAgents = useAppStore((s) => s.agentsShowChildAgents)
+ const setShowChildAgents = useAppStore((s) => s.setAgentsShowChildAgents)
const [selectedPaneKey, setSelectedPaneKey] = useState(null)
const [displayedPaneKey, setDisplayedPaneKey] = useState(null)
const [activePortalSlotId, setActivePortalSlotId] =
@@ -64,86 +62,30 @@ export default function ActivityPrototypePage(): React.JSX.Element {
setWidth: setThreadListWidth
})
- const storeData = useAppStore(
- useShallow((s) => ({
- agentStatusByPaneKey: s.agentStatusByPaneKey,
- migrationUnsupportedByPtyId: s.migrationUnsupportedByPtyId,
- retainedAgentsByPaneKey: s.retainedAgentsByPaneKey,
- tabsByWorktree: s.tabsByWorktree,
- worktreeMap: getWorktreeMapFromState(s),
- repoMap: getRepoMapFromState(s),
- acknowledgedAgentsByPaneKey: s.acknowledgedAgentsByPaneKey,
- acknowledgeAgents: s.acknowledgeAgents,
- unacknowledgeAgents: s.unacknowledgeAgents,
- generatedTitlesEnabled: s.settings?.tabAutoGenerateTitle === true
- }))
- )
- // Why: agentStatusEpoch is a dep (not used in the body) so the memo recomputes when freshness boundaries expire even without new PTY data.
- const agentStatusEpoch = useAppStore((s) => s.agentStatusEpoch)
-
- const { events: allEvents, liveAgentByPaneKey } = useMemo(
- () =>
- buildActivityEvents({
- agentStatusByPaneKey: storeData.agentStatusByPaneKey,
- migrationUnsupportedByPtyId: storeData.migrationUnsupportedByPtyId,
- retainedAgentsByPaneKey: storeData.retainedAgentsByPaneKey,
- tabsByWorktree: storeData.tabsByWorktree,
- worktreeMap: storeData.worktreeMap,
- repoMap: storeData.repoMap,
- acknowledgedAgentsByPaneKey: storeData.acknowledgedAgentsByPaneKey,
- // Why: Date.now() is read in the memo body (not a dep) so stale-decay recomputes when agentStatusEpoch ticks, not on wall-clock time.
- now: Date.now()
- }),
- // eslint-disable-next-line react-hooks/exhaustive-deps
- [storeData, agentStatusEpoch]
- )
-
- const allThreads = useMemo(
- () =>
- buildAgentPaneThreads({
- events: allEvents,
- liveAgentByPaneKey,
- generatedTitlesEnabled: storeData.generatedTitlesEnabled
- }),
- [allEvents, liveAgentByPaneKey, storeData.generatedTitlesEnabled]
- )
- const selectedPaneKeyIsLive =
- selectedPaneKey === null || allThreads.some((thread) => thread.paneKey === selectedPaneKey)
- const effectiveSelectedPaneKey = selectedPaneKeyIsLive ? selectedPaneKey : null
+ const {
+ storeData,
+ allThreads,
+ selectedPaneKeyIsLive,
+ effectiveSelectedPaneKey,
+ visibleThreads,
+ markAllReadThreads,
+ visibleThreadGroups
+ } = useAgentPaneThreads({ query, readFilter, groupBy, selectedPaneKey, showChildAgents })
if (!selectedPaneKeyIsLive) {
// Why: rows disappear when agent retention or tab state changes; clear stale selection before detail/portal rendering targets it.
setSelectedPaneKey(null)
}
- const visibleThreads = useMemo(() => {
- const normalizedQuery = isActivitySearchQueryTooLarge(query) ? null : query.trim().toLowerCase()
- return allThreads.filter((thread) => {
- // Why: keep the just-selected thread visible after auto-mark-read flips it to read, else unread-only mode makes the clicked row vanish from the list.
- if (
- readFilter === 'unread' &&
- !thread.unread &&
- thread.paneKey !== effectiveSelectedPaneKey
- ) {
- return false
- }
- if (normalizedQuery === null) {
- return false
- }
- return activityThreadMatchesSearchQuery({ thread, searchQuery: normalizedQuery })
- })
- }, [allThreads, readFilter, query, effectiveSelectedPaneKey])
- const visibleThreadGroups = useMemo(
- () => buildActivityThreadGroups(visibleThreads, groupBy),
- [visibleThreads, groupBy]
- )
-
const selectedThread = effectiveSelectedPaneKey
? (allThreads.find((thread) => thread.paneKey === effectiveSelectedPaneKey) ?? null)
: null
const selectedTabId = selectedThread?.tab.id ?? null
+ const selectedWorktreeAvailable = selectedThread
+ ? hasActivityThreadWorkspace(selectedThread, storeData)
+ : false
// Why: repo-less terminal buckets can produce Activity rows, but the workspace Terminal tree only portals real worktrees.
const selectedHasLiveTab =
- selectedThread && selectedTabId && storeData.worktreeMap.has(selectedThread.worktree.id)
+ selectedThread && selectedTabId && selectedWorktreeAvailable
? (storeData.tabsByWorktree[selectedThread.worktree.id] ?? []).some(
(tab) => tab.id === selectedTabId
)
@@ -152,8 +94,11 @@ export default function ActivityPrototypePage(): React.JSX.Element {
? (allThreads.find((thread) => thread.paneKey === displayedPaneKey) ?? null)
: null
const displayedTabId = displayedThread?.tab.id ?? null
+ const displayedWorktreeAvailable = displayedThread
+ ? hasActivityThreadWorkspace(displayedThread, storeData)
+ : false
const displayedHasLiveTab =
- displayedThread && displayedTabId && storeData.worktreeMap.has(displayedThread.worktree.id)
+ displayedThread && displayedTabId && displayedWorktreeAvailable
? (storeData.tabsByWorktree[displayedThread.worktree.id] ?? []).some(
(tab) => tab.id === displayedTabId
)
@@ -298,13 +243,38 @@ export default function ActivityPrototypePage(): React.JSX.Element {
return () => window.removeEventListener('keydown', focusActivityFilter, { capture: true })
}, [activePortalTargetEl, inactivePortalTargetEl])
- const { hasUnreadThreads, markThreadUnread, selectThread, jumpToWorkspace, markAllThreadsRead } =
- createActivityThreadActions({
- allThreads,
- acknowledgeAgents: storeData.acknowledgeAgents,
- unacknowledgeAgents: storeData.unacknowledgeAgents,
- setSelectedPaneKey
- })
+ const {
+ markThreadRead,
+ markThreadUnread,
+ selectThread,
+ jumpToWorkspace,
+ markAllThreadsRead,
+ hasUnreadThreads,
+ hasCompletedThreads,
+ handleClearCompleted
+ } = useActivityThreadActionBindings({
+ visibleThreads,
+ markAllReadThreads,
+ acknowledgeAgents: storeData.acknowledgeAgents,
+ unacknowledgeAgents: storeData.unacknowledgeAgents,
+ setSelectedPaneKey
+ })
+
+ const canJumpToWorkspace = useCallback(
+ (thread: Parameters[0]) =>
+ hasActivityThreadWorkspace(thread, {
+ worktreesByRepo: storeData.worktreesByRepo,
+ detectedWorktreesByRepo: storeData.detectedWorktreesByRepo,
+ folderWorkspaces: storeData.folderWorkspaces,
+ defaultHostId: storeData.defaultHostId
+ }),
+ [
+ storeData.worktreesByRepo,
+ storeData.detectedWorktreesByRepo,
+ storeData.folderWorkspaces,
+ storeData.defaultHostId
+ ]
+ )
useEffect(() => {
if (
@@ -356,25 +326,29 @@ export default function ActivityPrototypePage(): React.JSX.Element {
readFilter={readFilter}
onReadFilterChange={setReadFilter}
compactMode={compactMode}
+ showChildAgents={showChildAgents}
hasUnreadThreads={hasUnreadThreads}
onCompactModeChange={setCompactMode}
+ onShowChildAgentsChange={setShowChildAgents}
onMarkAllThreadsRead={markAllThreadsRead}
+ hasCompletedThreads={hasCompletedThreads}
+ onClearCompleted={handleClearCompleted}
visibleThreadGroups={visibleThreadGroups}
visibleThreadCount={visibleThreads.length}
selectedPaneKey={selectedThread?.paneKey ?? null}
onSelectThread={selectThread}
onJumpToWorkspace={jumpToWorkspace}
+ onMarkThreadRead={markThreadRead}
onMarkThreadUnread={markThreadUnread}
- canJumpToWorkspace={(thread) => storeData.worktreeMap.has(thread.worktree.id)}
+ canJumpToWorkspace={canJumpToWorkspace}
isThreadListResizing={isThreadListResizing}
onResizeStart={onResizeStart}
+ scopeFilterRow={}
/>
void
compactMode?: boolean
+ showChildAgents?: boolean
+ onShowChildAgentsChange?: (showChildAgents: boolean) => void
hasUnreadThreads?: boolean
}): ReactElement {
return (
{
expect(document.body.textContent).toContain('Compact mode')
})
+
+ it('renders group by options when provided', async () => {
+ const onGroupByChange = vi.fn()
+ await act(async () => {
+ root.render()
+ })
+
+ const trigger = container.querySelector(
+ 'button[aria-label="Thread list options"]'
+ )
+
+ await act(async () => {
+ trigger?.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Enter' }))
+ })
+
+ expect(document.body.textContent).toContain('Group by')
+ expect(document.body.textContent).toContain('Status')
+
+ const subTrigger = document.querySelector(
+ '[data-slot="dropdown-menu-sub-trigger"]'
+ )
+ expect(subTrigger).not.toBeNull()
+
+ await act(async () => {
+ subTrigger?.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'ArrowRight' }))
+ })
+
+ expect(document.body.textContent).toContain('Project')
+ expect(document.body.textContent).toContain('Worktree')
+ expect(document.body.textContent).toContain('Agent')
+ })
+
+ it('explains compact mode on hover', async () => {
+ await act(async () => {
+ root.render()
+ })
+
+ const trigger = container.querySelector(
+ 'button[aria-label="Thread list options"]'
+ )
+ await act(async () => {
+ trigger?.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Enter' }))
+ })
+
+ const compactMode = document.querySelector('[role="menuitemcheckbox"]')
+ await act(async () => {
+ compactMode?.dispatchEvent(new Event('pointermove', { bubbles: true }))
+ })
+
+ expect(document.body.textContent).toContain(
+ 'Shows shorter thread rows with one-line titles and two-line status messages.'
+ )
+ })
+
+ it('puts search and unread actions in the menu when header overflow handlers are provided', async () => {
+ const onSearch = vi.fn()
+ const onToggleUnread = vi.fn()
+ await act(async () => {
+ root.render(
+
+
+
+ )
+ })
+
+ const trigger = container.querySelector(
+ 'button[aria-label="Thread list options"]'
+ )
+ await act(async () => {
+ trigger?.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Enter' }))
+ })
+
+ expect(document.body.textContent).toContain('Search')
+ expect(document.body.textContent).toContain('Show unread only')
+ })
+
+ it('explains show unread threads only on hover and shows unread dot when hasUnreadThreads is true', async () => {
+ const onToggleUnread = vi.fn()
+ await act(async () => {
+ root.render(
+
+
+
+ )
+ })
+
+ const trigger = container.querySelector(
+ 'button[aria-label="Thread list options"]'
+ )
+ await act(async () => {
+ trigger?.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Enter' }))
+ })
+
+ const unreadItem = document.querySelector('[role="menuitemcheckbox"]')
+ await act(async () => {
+ unreadItem?.dispatchEvent(new Event('pointermove', { bubbles: true }))
+ })
+
+ expect(document.body.textContent).toContain(
+ 'Filters the activity list to show only threads with unread updates.'
+ )
+ expect(document.querySelector('[data-unread-dot]')).not.toBeNull()
+ })
+
+ it('renders show child agents checkbox when onShowChildAgentsChange is provided', async () => {
+ const onShowChildAgentsChange = vi.fn()
+ await act(async () => {
+ root.render(
+
+ )
+ })
+
+ const trigger = container.querySelector(
+ 'button[aria-label="Thread list options"]'
+ )
+
+ await act(async () => {
+ trigger?.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Enter' }))
+ })
+
+ expect(document.body.textContent).toContain('Show child agents')
+ })
})
diff --git a/src/renderer/src/components/activity/ActivityTitlebarControls.tsx b/src/renderer/src/components/activity/ActivityTitlebarControls.tsx
index 93ca4c65c1c..c6194ffcc96 100644
--- a/src/renderer/src/components/activity/ActivityTitlebarControls.tsx
+++ b/src/renderer/src/components/activity/ActivityTitlebarControls.tsx
@@ -8,7 +8,7 @@ import { useActivityUnreadCount } from './useActivityUnreadCount'
import { translate } from '@/i18n/i18n'
export function ActivityTitlebarControls(): React.JSX.Element {
- const unreadCount = useActivityUnreadCount(true, 'agent-events')
+ const unreadCount = useActivityUnreadCount()
const closeActivityPage = useAppStore((s) => s.closeActivityPage)
return (
diff --git a/src/renderer/src/components/activity/activity-auto-mark-read-loop.react185.test.tsx b/src/renderer/src/components/activity/activity-auto-mark-read-loop.react185.test.tsx
index 9fdc4c0ecef..013afb58408 100644
--- a/src/renderer/src/components/activity/activity-auto-mark-read-loop.react185.test.tsx
+++ b/src/renderer/src/components/activity/activity-auto-mark-read-loop.react185.test.tsx
@@ -125,7 +125,7 @@ async function mountActivityPage(): Promise {
}
async function selectSeededThread(): Promise {
- const row = Array.from(seededContainer.querySelectorAll('[role="button"]')).find(
+ const row = Array.from(seededContainer.querySelectorAll('[role="listitem"]')).find(
(element) => element.textContent?.includes(PROMPT)
)
expect(row).toBeDefined()
diff --git a/src/renderer/src/components/activity/activity-clear-completed.test.ts b/src/renderer/src/components/activity/activity-clear-completed.test.ts
new file mode 100644
index 00000000000..3723563d143
--- /dev/null
+++ b/src/renderer/src/components/activity/activity-clear-completed.test.ts
@@ -0,0 +1,362 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type { RetainedAgentEntry } from '@/store/slices/agent-status'
+import type { ActivityEvent, AgentPaneThread } from './activity-thread-types'
+import { makeTab, makeWorktree } from './ActivityPrototypePage-test-fixtures'
+
+const mockStore = vi.hoisted(() => {
+ const state = {
+ activityClearedAtByPaneKey: {} as Record,
+ agentStatusByPaneKey: {} as Record,
+ retainedAgentsByPaneKey: {} as Record,
+ retentionSuppressedPaneKeys: {} as Record,
+ applyActivityClearedAt: vi.fn((patch: Record) => {
+ const next = { ...state.activityClearedAtByPaneKey }
+ for (const [key, value] of Object.entries(patch)) {
+ if (value === null) {
+ delete next[key]
+ } else {
+ next[key] = value
+ }
+ }
+ state.activityClearedAtByPaneKey = next
+ }),
+ dismissRetainedAgents: vi.fn((paneKeys: readonly string[]) => {
+ const next = { ...state.retainedAgentsByPaneKey }
+ for (const key of paneKeys) {
+ if (state.agentStatusByPaneKey[key]) {
+ state.retentionSuppressedPaneKeys[key] = true
+ }
+ delete next[key]
+ }
+ state.retainedAgentsByPaneKey = next
+ }),
+ clearRetentionSuppressedPaneKeys: vi.fn((paneKeys: string[]) => {
+ for (const key of paneKeys) {
+ delete state.retentionSuppressedPaneKeys[key]
+ }
+ }),
+ retainAgents: vi.fn((entries: RetainedAgentEntry[]) => {
+ const next = { ...state.retainedAgentsByPaneKey }
+ for (const retained of entries) {
+ next[retained.entry.paneKey] = retained
+ }
+ state.retainedAgentsByPaneKey = next
+ })
+ }
+ return state
+})
+
+const toastSpy = vi.hoisted(() => vi.fn())
+
+vi.mock('@/store', () => ({
+ useAppStore: { getState: () => mockStore }
+}))
+vi.mock('sonner', () => ({ toast: toastSpy }))
+
+import {
+ CLEAR_COMPLETED_EVICTION_FALLBACK_MS,
+ clearCompletedActivity,
+ flushPendingClearCompletedEvictions,
+ isClearableActivityThread,
+ planClearCompletedActivity
+} from './activity-clear-completed'
+
+function makeThread(paneKey: string, overrides: Partial = {}): AgentPaneThread {
+ return {
+ paneKey,
+ tab: makeTab(),
+ worktree: makeWorktree(),
+ repo: null,
+ currentAgentState: null,
+ currentAgentEntry: null,
+ latestEvent: null,
+ latestTimestamp: 5_000,
+ agentType: 'claude',
+ unread: false,
+ paneTitle: `Agent ${paneKey}`,
+ responsePreview: '',
+ events: [],
+ ...overrides
+ }
+}
+
+function doneEvent(interrupted: boolean): ActivityEvent {
+ return {
+ id: 'evt',
+ state: 'done',
+ timestamp: 5_000,
+ worktree: makeWorktree(),
+ repo: null,
+ entry: { interrupted } as ActivityEvent['entry'],
+ tab: makeTab(),
+ agentType: 'claude',
+ agentAlive: false,
+ unread: false
+ }
+}
+
+const workingThread = makeThread('t-working:1', { currentAgentState: 'working' })
+const blockedThread = makeThread('t-blocked:1', { currentAgentState: 'blocked' })
+const waitingThread = makeThread('t-waiting:1', { currentAgentState: 'waiting' })
+const doneThread = makeThread('t-done:1', { latestEvent: doneEvent(false) })
+const interruptedThread = makeThread('t-interrupted:1', { latestEvent: doneEvent(true) })
+
+function makeRetained(paneKey: string): RetainedAgentEntry {
+ return {
+ entry: {
+ state: 'done',
+ prompt: 'retained run',
+ updatedAt: 5_000,
+ stateStartedAt: 5_000,
+ paneKey,
+ stateHistory: [],
+ agentType: 'claude'
+ },
+ worktreeId: 'wt-1',
+ tab: makeTab(),
+ agentType: 'claude',
+ startedAt: 5_000
+ }
+}
+
+describe('isClearableActivityThread', () => {
+ it('clears only completed and interrupted threads', () => {
+ expect(isClearableActivityThread(doneThread)).toBe(true)
+ expect(isClearableActivityThread(interruptedThread)).toBe(true)
+ expect(isClearableActivityThread(workingThread)).toBe(false)
+ expect(isClearableActivityThread(blockedThread)).toBe(false)
+ expect(isClearableActivityThread(waitingThread)).toBe(false)
+ })
+})
+
+describe('clearCompletedActivity', () => {
+ beforeEach(() => {
+ mockStore.activityClearedAtByPaneKey = {}
+ mockStore.agentStatusByPaneKey = {}
+ mockStore.retainedAgentsByPaneKey = { 't-done:1': makeRetained('t-done:1') }
+ mockStore.retentionSuppressedPaneKeys = {}
+ vi.stubGlobal('window', {
+ api: { agentStatus: { dropPersisted: vi.fn(), dropPersistedBatch: vi.fn() } }
+ })
+ })
+
+ afterEach(() => {
+ // Drain any eviction left pending by a test that never closed its toast.
+ flushPendingClearCompletedEvictions()
+ vi.clearAllMocks()
+ vi.unstubAllGlobals()
+ })
+
+ function lastToastOptions(): {
+ action: { label: string; onClick: () => void }
+ onDismiss: () => void
+ onAutoClose: () => void
+ } {
+ return toastSpy.mock.calls.at(-1)?.[1]
+ }
+
+ it('plans cutoffs and retained removals for completed threads only', () => {
+ const plan = planClearCompletedActivity(
+ [workingThread, blockedThread, doneThread, interruptedThread],
+ mockStore
+ )
+ expect(plan.clearedThreadCount).toBe(2)
+ expect(plan.cutoffPatch).toEqual({ 't-done:1': 5_000, 't-interrupted:1': 5_000 })
+ expect(plan.restorePatch).toEqual({ 't-done:1': null, 't-interrupted:1': null })
+ expect(plan.retainedSnapshots.map((r) => r.entry.paneKey)).toEqual(['t-done:1'])
+ })
+
+ it('stamps cutoffs, dismisses retained snapshots, and defers the disk drop to toast close', () => {
+ const cleared = clearCompletedActivity([workingThread, doneThread, interruptedThread])
+ expect(cleared).toBe(true)
+ expect(mockStore.activityClearedAtByPaneKey).toEqual({
+ 't-done:1': 5_000,
+ 't-interrupted:1': 5_000
+ })
+ expect(mockStore.dismissRetainedAgents).toHaveBeenCalledWith(['t-done:1'])
+ const drop = (
+ window as unknown as {
+ api: { agentStatus: { dropPersistedBatch: ReturnType } }
+ }
+ ).api.agentStatus.dropPersistedBatch
+ expect(drop).not.toHaveBeenCalled()
+
+ lastToastOptions().onAutoClose()
+ expect(drop).toHaveBeenCalledTimes(1)
+ expect(drop).toHaveBeenCalledWith([
+ expect.objectContaining({ paneKey: 't-done:1', receivedAt: 5_000, stateStartedAt: 5_000 })
+ ])
+ // A later dismiss must not double-drop.
+ lastToastOptions().onDismiss()
+ expect(drop).toHaveBeenCalledTimes(1)
+ })
+
+ it('undo restores prior cutoffs and re-retains snapshots, and skips the disk drop', () => {
+ mockStore.activityClearedAtByPaneKey = { 't-done:1': 1_111 }
+ clearCompletedActivity([doneThread, interruptedThread])
+ expect(mockStore.activityClearedAtByPaneKey).toEqual({
+ 't-done:1': 5_000,
+ 't-interrupted:1': 5_000
+ })
+ expect(mockStore.retainedAgentsByPaneKey['t-done:1']).toBeUndefined()
+
+ lastToastOptions().action.onClick()
+ expect(mockStore.activityClearedAtByPaneKey).toEqual({ 't-done:1': 1_111 })
+ expect(mockStore.retainedAgentsByPaneKey['t-done:1']).toBeDefined()
+
+ lastToastOptions().onAutoClose()
+ const drop = (
+ window as unknown as {
+ api: { agentStatus: { dropPersistedBatch: ReturnType } }
+ }
+ ).api.agentStatus.dropPersistedBatch
+ expect(drop).not.toHaveBeenCalled()
+ })
+
+ it('undo restores a completed live row and removes the suppressor created by clear', () => {
+ const retained = mockStore.retainedAgentsByPaneKey['t-done:1']
+ mockStore.agentStatusByPaneKey['t-done:1'] = retained.entry
+ mockStore.activityClearedAtByPaneKey = { 't-done:1': 1_111 }
+
+ clearCompletedActivity([doneThread])
+ expect(mockStore.activityClearedAtByPaneKey['t-done:1']).toBe(5_000)
+ expect(mockStore.retentionSuppressedPaneKeys['t-done:1']).toBe(true)
+
+ lastToastOptions().action.onClick()
+
+ expect(mockStore.activityClearedAtByPaneKey['t-done:1']).toBe(1_111)
+ expect(mockStore.retentionSuppressedPaneKeys['t-done:1']).toBeUndefined()
+ expect(mockStore.retainedAgentsByPaneKey['t-done:1']).toBeUndefined()
+ })
+
+ it('undo removes the suppressor even after an identity-only live entry replacement', () => {
+ // A runtime orchestration merge replaces the live entry object without a state
+ // change; the suppressor undo must key on the turn, not on object identity.
+ const retained = mockStore.retainedAgentsByPaneKey['t-done:1']
+ mockStore.agentStatusByPaneKey['t-done:1'] = retained.entry
+
+ clearCompletedActivity([doneThread])
+ expect(mockStore.retentionSuppressedPaneKeys['t-done:1']).toBe(true)
+ mockStore.agentStatusByPaneKey['t-done:1'] = { ...retained.entry }
+
+ lastToastOptions().action.onClick()
+
+ expect(mockStore.retentionSuppressedPaneKeys['t-done:1']).toBeUndefined()
+ })
+
+ it('undo keeps the suppressor when the live row has moved to a new turn', () => {
+ const retained = mockStore.retainedAgentsByPaneKey['t-done:1']
+ mockStore.agentStatusByPaneKey['t-done:1'] = retained.entry
+
+ clearCompletedActivity([doneThread])
+ mockStore.agentStatusByPaneKey['t-done:1'] = {
+ ...retained.entry,
+ stateStartedAt: retained.entry.stateStartedAt + 1
+ }
+
+ lastToastOptions().action.onClick()
+
+ expect(mockStore.retentionSuppressedPaneKeys['t-done:1']).toBe(true)
+ })
+
+ it('does not restore a cleared snapshot over a newer retained run', () => {
+ clearCompletedActivity([doneThread])
+ const newer = makeRetained('t-done:1')
+ newer.entry.prompt = 'newer run'
+ mockStore.retainedAgentsByPaneKey['t-done:1'] = newer
+
+ lastToastOptions().action.onClick()
+
+ expect(mockStore.retainedAgentsByPaneKey['t-done:1']).toBe(newer)
+ expect(mockStore.activityClearedAtByPaneKey['t-done:1']).toBeUndefined()
+ })
+
+ it('stamps a real cutoff for a thread with no usable timestamp', () => {
+ // A zero cutoff is dropped by the hydrate sanitizer and the clear would replay after restart.
+ const unstamped = makeThread('t-done:1', { latestEvent: doneEvent(false), latestTimestamp: 0 })
+ const plan = planClearCompletedActivity([unstamped], mockStore, 42_000)
+ expect(plan.cutoffPatch).toEqual({ 't-done:1': 42_000 })
+ })
+
+ it('evicts after the fallback window when no toast close callback ever fires', () => {
+ vi.useFakeTimers()
+ try {
+ clearCompletedActivity([doneThread])
+ const drop = (
+ window as unknown as {
+ api: { agentStatus: { dropPersistedBatch: ReturnType } }
+ }
+ ).api.agentStatus.dropPersistedBatch
+ expect(drop).not.toHaveBeenCalled()
+
+ vi.advanceTimersByTime(CLEAR_COMPLETED_EVICTION_FALLBACK_MS)
+ expect(drop).toHaveBeenCalledTimes(1)
+
+ lastToastOptions().onDismiss()
+ expect(drop).toHaveBeenCalledTimes(1)
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ it('undo cancels the fallback eviction timer', () => {
+ vi.useFakeTimers()
+ try {
+ clearCompletedActivity([doneThread])
+ lastToastOptions().action.onClick()
+ vi.advanceTimersByTime(CLEAR_COMPLETED_EVICTION_FALLBACK_MS)
+ const drop = (
+ window as unknown as {
+ api: { agentStatus: { dropPersistedBatch: ReturnType } }
+ }
+ ).api.agentStatus.dropPersistedBatch
+ expect(drop).not.toHaveBeenCalled()
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ it('falls back to per-identity drops when the batch API is absent', () => {
+ const dropOne = vi.fn()
+ vi.stubGlobal('window', { api: { agentStatus: { dropPersisted: dropOne } } })
+ clearCompletedActivity([doneThread])
+ lastToastOptions().onAutoClose()
+ expect(dropOne).toHaveBeenCalledTimes(1)
+ })
+
+ it('does nothing when no thread is clearable', () => {
+ expect(clearCompletedActivity([workingThread, blockedThread])).toBe(false)
+ expect(toastSpy).not.toHaveBeenCalled()
+ expect(mockStore.applyActivityClearedAt).not.toHaveBeenCalled()
+ })
+
+ it('pagehide flush evicts a clear whose undo toast is still open', () => {
+ clearCompletedActivity([doneThread])
+ const drop = (
+ window as unknown as {
+ api: { agentStatus: { dropPersistedBatch: ReturnType } }
+ }
+ ).api.agentStatus.dropPersistedBatch
+ expect(drop).not.toHaveBeenCalled()
+
+ // Quit/reload path: the toast's close callbacks never fire.
+ flushPendingClearCompletedEvictions()
+ expect(drop).toHaveBeenCalledTimes(1)
+
+ // The flushed eviction is consumed; later toast close must not double-drop.
+ lastToastOptions().onAutoClose()
+ expect(drop).toHaveBeenCalledTimes(1)
+ })
+
+ it('pagehide flush skips a clear that was undone', () => {
+ clearCompletedActivity([doneThread])
+ lastToastOptions().action.onClick()
+ flushPendingClearCompletedEvictions()
+ const drop = (
+ window as unknown as {
+ api: { agentStatus: { dropPersistedBatch: ReturnType } }
+ }
+ ).api.agentStatus.dropPersistedBatch
+ expect(drop).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/renderer/src/components/activity/activity-clear-completed.ts b/src/renderer/src/components/activity/activity-clear-completed.ts
new file mode 100644
index 00000000000..72eee3ea4af
--- /dev/null
+++ b/src/renderer/src/components/activity/activity-clear-completed.ts
@@ -0,0 +1,206 @@
+import { toast } from 'sonner'
+import { useAppStore } from '@/store'
+import { translate } from '@/i18n/i18n'
+import type { RetainedAgentEntry } from '@/store/slices/agent-status'
+import type { AgentStatusCacheIdentity } from '../../../../shared/agent-status-types'
+import { threadStatusGroupId } from './activity-thread-grouping'
+import type { AgentPaneThread } from './activity-thread-types'
+
+export type ClearCompletedActivityPlan = {
+ /** Panes whose activity gets a cleared-at cutoff stamped. */
+ cutoffPatch: Record
+ /** Exact prior cutoff values (or null when absent) so undo restores byte-for-byte. */
+ restorePatch: Record
+ /** Retained snapshots removed by the clear; undo re-retains them verbatim. */
+ retainedSnapshots: RetainedAgentEntry[]
+ /** Exact status identities cleared so deferred disk eviction cannot remove a later run. */
+ cacheIdentities: AgentStatusCacheIdentity[]
+ clearedThreadCount: number
+}
+
+/** A thread is clearable when it needs nothing from the user: completed or interrupted,
+ * with no fresh live working/monitoring/blocked/waiting state. */
+export function isClearableActivityThread(thread: AgentPaneThread): boolean {
+ const groupId = threadStatusGroupId(thread)
+ return groupId === 'done' || groupId === 'interrupted'
+}
+
+export function planClearCompletedActivity(
+ threads: readonly AgentPaneThread[],
+ state: {
+ activityClearedAtByPaneKey: Record
+ retainedAgentsByPaneKey: Record
+ },
+ now: number = Date.now()
+): ClearCompletedActivityPlan {
+ const cutoffPatch: Record = {}
+ const restorePatch: Record = {}
+ const retainedSnapshots: RetainedAgentEntry[] = []
+ const cacheIdentities: AgentStatusCacheIdentity[] = []
+ let clearedThreadCount = 0
+ for (const thread of threads) {
+ if (!isClearableActivityThread(thread)) {
+ continue
+ }
+ clearedThreadCount += 1
+ const previousCutoff = state.activityClearedAtByPaneKey[thread.paneKey] ?? null
+ const latestCutoff = Math.max(previousCutoff ?? 0, thread.latestTimestamp)
+ // Why `now` for an unstamped thread: the hydrate sanitizer drops non-positive cutoffs, so a
+ // zero cutoff would replay the cleared thread after restart.
+ cutoffPatch[thread.paneKey] = latestCutoff > 0 ? latestCutoff : now
+ restorePatch[thread.paneKey] = previousCutoff
+ const retained = state.retainedAgentsByPaneKey[thread.paneKey]
+ if (retained) {
+ retainedSnapshots.push(retained)
+ const entry = retained.entry
+ // updatedAt mirrors the wire receivedAt; renderer-enriched fields (connectionId,
+ // worktreeId) diverge from main's cache and are deliberately excluded.
+ cacheIdentities.push({
+ paneKey: thread.paneKey,
+ receivedAt: entry.updatedAt,
+ stateStartedAt: entry.stateStartedAt
+ })
+ }
+ }
+ return { cutoffPatch, restorePatch, retainedSnapshots, cacheIdentities, clearedThreadCount }
+}
+
+// Deferred evictions whose undo toast is still open; flushed on pagehide because the toast's
+// close callbacks never fire on quit/reload, which would let cleared rows replay next launch.
+const pendingDiskEvictions = new Set<() => void>()
+export function flushPendingClearCompletedEvictions(): void {
+ // Set iteration tolerates the self-delete each evict() performs.
+ for (const evict of pendingDiskEvictions) {
+ evict()
+ }
+}
+if (typeof window !== 'undefined') {
+ window.addEventListener('pagehide', flushPendingClearCompletedEvictions)
+}
+
+// Why a fallback: sonner only fires onDismiss/onAutoClose for the toast's own close paths; a
+// `toast.dismiss()` from another caller leaves the eviction pending until pagehide.
+export const CLEAR_COMPLETED_EVICTION_FALLBACK_MS = 60_000
+
+function evictPersistedStatuses(identities: readonly AgentStatusCacheIdentity[]): void {
+ const api = window.api?.agentStatus
+ if (!api || identities.length === 0) {
+ return
+ }
+ if (api.dropPersistedBatch) {
+ api.dropPersistedBatch(identities)
+ return
+ }
+ for (const identity of identities) {
+ api.dropPersisted?.(identity)
+ }
+}
+
+/**
+ * Clear completed/interrupted activity threads with an undo window.
+ *
+ * Live agent status, resume identity, and attention/working rows are untouched:
+ * clearing stamps per-pane cutoffs (persisted UI) and removes retained completed
+ * snapshots. The identity-checked main-process cache eviction is deferred until
+ * the undo toast closes so Undo can restore everything losslessly.
+ */
+export function clearCompletedActivity(threads: readonly AgentPaneThread[]): boolean {
+ const state = useAppStore.getState()
+ const plan = planClearCompletedActivity(threads, state)
+ if (plan.clearedThreadCount === 0) {
+ return false
+ }
+ state.applyActivityClearedAt(plan.cutoffPatch)
+ // Why turn timestamps, not entry identity: a runtime orchestration merge replaces the live
+ // entry object without a state change (setRuntimeAgentOrchestrationByPaneKey), and an
+ // identity check would then strand the clear-planted suppressor past Undo, losing the run.
+ const introducedSuppressorLiveTurns = new Map(
+ plan.retainedSnapshots.flatMap((retained) => {
+ const paneKey = retained.entry.paneKey
+ const liveEntry = state.agentStatusByPaneKey[paneKey]
+ return liveEntry && !state.retentionSuppressedPaneKeys[paneKey]
+ ? ([[paneKey, liveEntry.stateStartedAt]] as const)
+ : []
+ })
+ )
+ state.dismissRetainedAgents(plan.retainedSnapshots.map((retained) => retained.entry.paneKey))
+
+ let undone = false
+ let dropped = false
+ let fallbackTimer: ReturnType | null = null
+ const dropRetainedFromDiskCache = (): void => {
+ pendingDiskEvictions.delete(dropRetainedFromDiskCache)
+ if (fallbackTimer !== null) {
+ clearTimeout(fallbackTimer)
+ fallbackTimer = null
+ }
+ if (undone || dropped) {
+ return
+ }
+ dropped = true
+ evictPersistedStatuses(plan.cacheIdentities)
+ }
+ pendingDiskEvictions.add(dropRetainedFromDiskCache)
+ fallbackTimer = setTimeout(dropRetainedFromDiskCache, CLEAR_COMPLETED_EVICTION_FALLBACK_MS)
+ toast(
+ plan.clearedThreadCount === 1
+ ? translate('auto.components.activity.clearCompleted.clearedOne', 'Cleared 1 completed agent')
+ : translate(
+ 'auto.components.activity.clearCompleted.clearedMany',
+ 'Cleared {{count}} completed agents',
+ { count: plan.clearedThreadCount }
+ ),
+ {
+ action: {
+ label: translate('auto.components.activity.clearCompleted.undo', 'Undo'),
+ onClick: () => {
+ undone = true
+ pendingDiskEvictions.delete(dropRetainedFromDiskCache)
+ if (fallbackTimer !== null) {
+ clearTimeout(fallbackTimer)
+ fallbackTimer = null
+ }
+ const current = useAppStore.getState()
+ const retainedByPaneKey = new Map(
+ plan.retainedSnapshots.map((retained) => [retained.entry.paneKey, retained])
+ )
+ const restorePatch: Record = {}
+ const snapshotsToRestore: RetainedAgentEntry[] = []
+ const suppressorPaneKeysToClear: string[] = []
+ for (const paneKey of Object.keys(plan.restorePatch)) {
+ const currentLive = current.agentStatusByPaneKey?.[paneKey]
+ const currentRetained = current.retainedAgentsByPaneKey[paneKey]
+ const clearedSnapshot = retainedByPaneKey.get(paneKey)
+ const cutoffStillOwned =
+ current.activityClearedAtByPaneKey[paneKey] === plan.cutoffPatch[paneKey]
+ if (cutoffStillOwned) {
+ restorePatch[paneKey] = plan.restorePatch[paneKey] ?? null
+ }
+ if (
+ cutoffStillOwned &&
+ introducedSuppressorLiveTurns.has(paneKey) &&
+ currentLive?.stateStartedAt === introducedSuppressorLiveTurns.get(paneKey) &&
+ current.retentionSuppressedPaneKeys[paneKey]
+ ) {
+ suppressorPaneKeysToClear.push(paneKey)
+ }
+ if (currentLive || (currentRetained && currentRetained !== clearedSnapshot)) {
+ continue
+ }
+ if (clearedSnapshot && !currentRetained) {
+ snapshotsToRestore.push(clearedSnapshot)
+ }
+ }
+ current.applyActivityClearedAt(restorePatch)
+ current.clearRetentionSuppressedPaneKeys(suppressorPaneKeysToClear)
+ if (snapshotsToRestore.length > 0) {
+ current.retainAgents(snapshotsToRestore)
+ }
+ }
+ },
+ onDismiss: dropRetainedFromDiskCache,
+ onAutoClose: dropRetainedFromDiskCache
+ }
+ )
+ return true
+}
diff --git a/src/renderer/src/components/activity/activity-event-build-cache.ts b/src/renderer/src/components/activity/activity-event-build-cache.ts
new file mode 100644
index 00000000000..09f713c5170
--- /dev/null
+++ b/src/renderer/src/components/activity/activity-event-build-cache.ts
@@ -0,0 +1,129 @@
+import { entryWithRuntimeOrchestration } from '../sidebar/worktree-agent-row-orchestration'
+import type {
+ AgentStatusEntry,
+ AgentStatusOrchestrationContext,
+ AgentType
+} from '../../../../shared/agent-status-types'
+import type { Repo } from '../../../../shared/repo-types'
+import type { TerminalTab } from '../../../../shared/terminal-tab-types'
+import type { Worktree } from '../../../../shared/worktree/types'
+import type {
+ ActivityEvent,
+ ActivityLiveAgentSnapshot,
+ ActivityLiveAgentState
+} from './activity-thread-types'
+import { buildPaneActivityEvents } from './activity-pane-events'
+
+type PaneActivityCacheEntry = {
+ source: unknown
+ orchestration: AgentStatusOrchestrationContext | undefined
+ acknowledgedAt: number
+ clearedAt: number
+ worktree: Worktree
+ repo: Repo | null
+ tab: TerminalTab
+ events: ActivityEvent[]
+ live: ActivityLiveAgentSnapshot | null
+ rowEntry: AgentStatusEntry
+}
+
+export type ActivityEventBuildCache = {
+ panes: Map
+}
+
+export function createActivityEventBuildCache(): ActivityEventBuildCache {
+ return { panes: new Map() }
+}
+
+export type PaneBuildRequest = {
+ cacheKey: string
+ source: unknown
+ entry: AgentStatusEntry
+ orchestration: AgentStatusOrchestrationContext | undefined
+ worktree: Worktree
+ repo: Repo | null
+ tab: TerminalTab
+ agentType: AgentType
+ agentAlive: boolean
+ acknowledgedAt: number
+ clearedAt: number
+ migrationUnsupportedPtyId?: string
+ liveState: ActivityLiveAgentState | null
+}
+
+export function resolvePaneBuild(
+ request: PaneBuildRequest,
+ cache: ActivityEventBuildCache | undefined,
+ seenCacheKeys: Set | null
+): { events: ActivityEvent[]; live: ActivityLiveAgentSnapshot | null } {
+ seenCacheKeys?.add(request.cacheKey)
+ const cached = cache?.panes.get(request.cacheKey)
+ const inputsUnchanged =
+ cached !== undefined &&
+ cached.source === request.source &&
+ cached.orchestration === request.orchestration &&
+ cached.acknowledgedAt === request.acknowledgedAt &&
+ cached.clearedAt === request.clearedAt &&
+ cached.worktree === request.worktree &&
+ cached.repo === request.repo &&
+ cached.tab === request.tab
+ const rowEntry = inputsUnchanged
+ ? cached.rowEntry
+ : entryWithRuntimeOrchestration(
+ request.entry,
+ request.orchestration ? { [request.entry.paneKey]: request.orchestration } : undefined
+ )
+
+ const liveTimestamp = rowEntry.stateStartedAt
+ const liveMatchesCache =
+ inputsUnchanged &&
+ (request.liveState === null
+ ? cached.live === null
+ : cached.live !== null &&
+ cached.live.state === request.liveState &&
+ cached.live.timestamp === liveTimestamp)
+
+ if (inputsUnchanged && liveMatchesCache) {
+ return { events: cached.events, live: cached.live }
+ }
+
+ const events = inputsUnchanged
+ ? cached.events
+ : buildPaneActivityEvents({
+ entry: rowEntry,
+ worktree: request.worktree,
+ repo: request.repo,
+ tab: request.tab,
+ agentType: request.agentType,
+ agentAlive: request.agentAlive,
+ acknowledgedAt: request.acknowledgedAt,
+ clearedAt: request.clearedAt,
+ migrationUnsupportedPtyId: request.migrationUnsupportedPtyId
+ })
+ const live: ActivityLiveAgentSnapshot | null =
+ request.liveState === null
+ ? null
+ : {
+ state: request.liveState,
+ timestamp: liveTimestamp,
+ worktree: request.worktree,
+ repo: request.repo,
+ entry: rowEntry,
+ tab: request.tab,
+ agentType: request.agentType
+ }
+
+ cache?.panes.set(request.cacheKey, {
+ source: request.source,
+ orchestration: request.orchestration,
+ acknowledgedAt: request.acknowledgedAt,
+ clearedAt: request.clearedAt,
+ worktree: request.worktree,
+ repo: request.repo,
+ tab: request.tab,
+ events,
+ live,
+ rowEntry
+ })
+ return { events, live }
+}
diff --git a/src/renderer/src/components/activity/activity-event-builder-agent-context.test.ts b/src/renderer/src/components/activity/activity-event-builder-agent-context.test.ts
new file mode 100644
index 00000000000..4f876eb5824
--- /dev/null
+++ b/src/renderer/src/components/activity/activity-event-builder-agent-context.test.ts
@@ -0,0 +1,165 @@
+import { describe, expect, it, vi } from 'vitest'
+import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
+import type { Tab } from '../../../../shared/tab-types'
+import {
+ makeRepo,
+ makeWorkingEntryWithoutHistory,
+ makeWorktree,
+ PANE_KEY
+} from './ActivityPrototypePage-test-fixtures'
+import { buildActivityEvents } from './activity-event-builder'
+
+function build(args: {
+ entry: AgentStatusEntry
+ unifiedTabs?: Tab[]
+}): ReturnType {
+ const repo = makeRepo()
+ const worktree = makeWorktree()
+ return buildActivityEvents({
+ agentStatusByPaneKey: { [PANE_KEY]: args.entry },
+ retainedAgentsByPaneKey: {},
+ tabsByWorktree: { [worktree.id]: [] },
+ unifiedTabsByWorktree: { [worktree.id]: args.unifiedTabs ?? [] },
+ worktreeMap: new Map([[worktree.id, worktree]]),
+ repoMap: new Map([[repo.id, repo]]),
+ acknowledgedAgentsByPaneKey: {},
+ now: 3_000
+ })
+}
+
+describe('activity event agent contexts', () => {
+ it('builds a live thread context from a unified structured-agent tab', () => {
+ const structuredTab = {
+ id: 'tab-1',
+ entityId: 'session-1',
+ groupId: 'group-1',
+ worktreeId: 'wt-1',
+ executionHostId: 'local',
+ contentType: 'agent-session',
+ label: 'Codex chat',
+ customLabel: null,
+ color: null,
+ sortOrder: 0,
+ createdAt: 1,
+ agentSessionAgent: 'codex'
+ } satisfies Tab
+
+ const result = build({
+ entry: makeWorkingEntryWithoutHistory(),
+ unifiedTabs: [structuredTab]
+ })
+
+ expect(result.liveAgentByPaneKey[PANE_KEY]).toMatchObject({
+ state: 'working',
+ worktree: { id: 'wt-1' },
+ tab: { id: 'tab-1', ptyId: null, title: 'Codex chat' }
+ })
+ })
+
+ it('uses direct worktree attribution before an agent tab reaches the renderer', () => {
+ const result = build({
+ entry: {
+ ...makeWorkingEntryWithoutHistory(),
+ worktreeId: 'wt-1'
+ }
+ })
+
+ expect(result.liveAgentByPaneKey[PANE_KEY]).toMatchObject({
+ state: 'working',
+ worktree: { id: 'wt-1' },
+ tab: { id: 'tab-1', worktreeId: 'wt-1', ptyId: null }
+ })
+ })
+
+ it('preserves a unified structured session remote-runtime owner', () => {
+ const localRepo = makeRepo()
+ const runtimeRepo = {
+ ...makeRepo(),
+ executionHostId: 'runtime:env-1' as const,
+ displayName: 'Runtime repo'
+ }
+ const localWorktree = makeWorktree()
+ const runtimeWorktree = {
+ ...makeWorktree(),
+ hostId: 'runtime:env-1' as const,
+ runtimeOwnerEnvironmentId: 'env-1',
+ displayName: 'Runtime worktree'
+ }
+ const structuredTab = {
+ id: 'tab-1',
+ entityId: 'session-1',
+ groupId: 'group-1',
+ worktreeId: 'wt-1',
+ executionHostId: 'runtime:env-1',
+ contentType: 'agent-session',
+ label: 'Remote Codex chat',
+ customLabel: null,
+ color: null,
+ sortOrder: 0,
+ createdAt: 1,
+ agentSessionAgent: 'codex'
+ } satisfies Tab
+ const resolveWorktree = vi.fn((_worktreeId, executionHostId) =>
+ executionHostId === 'runtime:env-1' ? runtimeWorktree : localWorktree
+ )
+
+ const result = buildActivityEvents({
+ agentStatusByPaneKey: {
+ [PANE_KEY]: { ...makeWorkingEntryWithoutHistory(), connectionId: null }
+ },
+ retainedAgentsByPaneKey: {},
+ tabsByWorktree: { [localWorktree.id]: [] },
+ unifiedTabsByWorktree: { [localWorktree.id]: [structuredTab] },
+ worktreeMap: new Map([[localWorktree.id, localWorktree]]),
+ repoMap: new Map([[localRepo.id, localRepo]]),
+ repos: [localRepo, runtimeRepo],
+ resolveWorktree,
+ acknowledgedAgentsByPaneKey: {},
+ now: 3_000
+ })
+
+ expect(resolveWorktree).toHaveBeenCalledWith('wt-1', 'runtime:env-1')
+ expect(result.liveAgentByPaneKey[PANE_KEY]?.worktree).toBe(runtimeWorktree)
+ expect(result.liveAgentByPaneKey[PANE_KEY]?.repo).toBe(runtimeRepo)
+ })
+
+ it('preserves an early worktree-attributed SSH owner before its tab arrives', () => {
+ const localRepo = makeRepo()
+ const remoteRepo = {
+ ...makeRepo(),
+ connectionId: 'builder',
+ displayName: 'SSH repo'
+ }
+ const localWorktree = makeWorktree()
+ const remoteWorktree = {
+ ...makeWorktree(),
+ hostId: 'ssh:builder' as const,
+ displayName: 'SSH worktree'
+ }
+ const resolveWorktree = vi.fn((_worktreeId, executionHostId) =>
+ executionHostId === 'ssh:builder' ? remoteWorktree : localWorktree
+ )
+
+ const result = buildActivityEvents({
+ agentStatusByPaneKey: {
+ [PANE_KEY]: {
+ ...makeWorkingEntryWithoutHistory(),
+ worktreeId: 'wt-1',
+ connectionId: 'builder'
+ }
+ },
+ retainedAgentsByPaneKey: {},
+ tabsByWorktree: { [localWorktree.id]: [] },
+ worktreeMap: new Map([[localWorktree.id, localWorktree]]),
+ repoMap: new Map([[localRepo.id, localRepo]]),
+ repos: [localRepo, remoteRepo],
+ resolveWorktree,
+ acknowledgedAgentsByPaneKey: {},
+ now: 3_000
+ })
+
+ expect(resolveWorktree).toHaveBeenCalledWith('wt-1', 'ssh:builder')
+ expect(result.liveAgentByPaneKey[PANE_KEY]?.worktree).toBe(remoteWorktree)
+ expect(result.liveAgentByPaneKey[PANE_KEY]?.repo).toBe(remoteRepo)
+ })
+})
diff --git a/src/renderer/src/components/activity/activity-event-builder-context.ts b/src/renderer/src/components/activity/activity-event-builder-context.ts
new file mode 100644
index 00000000000..3fdd2584fef
--- /dev/null
+++ b/src/renderer/src/components/activity/activity-event-builder-context.ts
@@ -0,0 +1,182 @@
+import { findIndexedRepoOwnerForHost } from '@/lib/worktree-runtime-owner-index'
+import { getRemoteRuntimePtyEnvironmentId } from '@/runtime/runtime-terminal-stream'
+import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
+import {
+ LOCAL_EXECUTION_HOST_ID,
+ toRuntimeExecutionHostId,
+ toSshExecutionHostId,
+ type ExecutionHostId
+} from '../../../../shared/execution-host'
+import type { Repo } from '../../../../shared/repo-types'
+import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id'
+import type { Tab } from '../../../../shared/tab-types'
+import type { TerminalTab } from '../../../../shared/terminal-tab-types'
+import type { Worktree } from '../../../../shared/worktree/types'
+import {
+ effectiveWorktreeAgentRowStartedAt,
+ tabFromWorktreeAttributedStatusEntry
+} from '../sidebar/worktree-agent-row-fallback-tab'
+import type { BuildActivityEventsArgs } from './activity-event-builder'
+import { standaloneActivityWorktree } from './activity-standalone-worktree'
+
+export type ActivityTabContext = { worktreeId: string; tab: TerminalTab }
+export type ActivityEventOwner = { worktree: Worktree; repo: Repo | null; knownWorktree: boolean }
+export type ActivityTabHostIndex = Map>
+
+// Why memoized on the source object: the pane build cache compares `tab` by identity, so a
+// fresh derived object per rebuild would miss the cache for every agent-session and
+// missing-tab row. Upstream keeps the source identity stable while its fields are unchanged.
+const agentSessionTerminalTabs = new WeakMap()
+const attributedTabContexts = new WeakMap()
+
+function terminalTabFromAgentSessionTab(tab: Tab): TerminalTab {
+ const cached = agentSessionTerminalTabs.get(tab)
+ if (cached) {
+ return cached
+ }
+ const derived: TerminalTab = {
+ id: tab.id,
+ ptyId: null,
+ worktreeId: tab.worktreeId,
+ title: tab.customLabel ?? tab.generatedLabel ?? tab.label,
+ customTitle: tab.customLabel,
+ color: tab.color,
+ isPinned: tab.isPinned,
+ sortOrder: tab.sortOrder,
+ createdAt: tab.createdAt
+ }
+ agentSessionTerminalTabs.set(tab, derived)
+ return derived
+}
+
+export function buildActivityTabContext(
+ tabsByWorktree: Record,
+ unifiedTabsByWorktree?: Record
+): Map {
+ const contexts = new Map()
+ for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) {
+ for (const tab of tabs) {
+ contexts.set(tab.id, { worktreeId, tab })
+ }
+ }
+ for (const [worktreeId, tabs] of Object.entries(unifiedTabsByWorktree ?? {})) {
+ for (const tab of tabs) {
+ if (tab.contentType !== 'agent-session' || contexts.has(tab.id)) {
+ continue
+ }
+ contexts.set(tab.id, { worktreeId, tab: terminalTabFromAgentSessionTab(tab) })
+ }
+ }
+ return contexts
+}
+
+export function attributedActivityTabContext(entry: AgentStatusEntry): ActivityTabContext | null {
+ const cached = attributedTabContexts.get(entry)
+ if (cached !== undefined) {
+ return cached
+ }
+ const tab = tabFromWorktreeAttributedStatusEntry(entry, effectiveWorktreeAgentRowStartedAt(entry))
+ const context = tab ? { worktreeId: tab.worktreeId, tab } : null
+ attributedTabContexts.set(entry, context)
+ return context
+}
+
+export function buildActivityTabHostIndex(
+ unifiedTabsByWorktree?: Record
+): ActivityTabHostIndex {
+ const index: ActivityTabHostIndex = new Map()
+ for (const [worktreeId, tabs] of Object.entries(unifiedTabsByWorktree ?? {})) {
+ for (const tab of tabs) {
+ if (
+ (tab.contentType !== 'terminal' && tab.contentType !== 'agent-session') ||
+ !tab.executionHostId
+ ) {
+ continue
+ }
+ let byTabId = index.get(worktreeId)
+ if (!byTabId) {
+ byTabId = new Map()
+ index.set(worktreeId, byTabId)
+ }
+ const contextTabId = tab.contentType === 'terminal' ? tab.entityId : tab.id
+ const existing = byTabId.get(contextTabId)
+ byTabId.set(
+ contextTabId,
+ existing === undefined || existing === tab.executionHostId ? tab.executionHostId : null
+ )
+ }
+ }
+ return index
+}
+
+function resolveActivityExecutionHostId(
+ context: ActivityTabContext,
+ entry: AgentStatusEntry,
+ terminalPtyId: string | null | undefined,
+ tabHostIndex: ActivityTabHostIndex
+): ExecutionHostId | undefined {
+ const tabHostId = tabHostIndex.get(context.worktreeId)?.get(context.tab.id)
+ if (tabHostId) {
+ return tabHostId
+ }
+ // Why before connectionId: a runtime pane's status entry publishes connectionId: null,
+ // which would otherwise resolve to LOCAL (see dashboard-card-terminal-input's precedent).
+ const runtimeEnvironmentId = getRemoteRuntimePtyEnvironmentId(terminalPtyId ?? '')
+ if (runtimeEnvironmentId) {
+ return toRuntimeExecutionHostId(runtimeEnvironmentId)
+ }
+ if (entry.connectionId !== undefined) {
+ return entry.connectionId ? toSshExecutionHostId(entry.connectionId) : LOCAL_EXECUTION_HOST_ID
+ }
+ const connectionId = parseAppSshPtyId(terminalPtyId ?? '')?.connectionId
+ return connectionId ? toSshExecutionHostId(connectionId) : undefined
+}
+
+export function resolveActivityEventOwner(
+ args: BuildActivityEventsArgs,
+ context: ActivityTabContext,
+ entry: AgentStatusEntry,
+ terminalPtyId: string | null | undefined,
+ tabHostIndex: ActivityTabHostIndex,
+ ownerCache: Map
+): ActivityEventOwner {
+ const executionHostId = resolveActivityExecutionHostId(
+ context,
+ entry,
+ terminalPtyId,
+ tabHostIndex
+ )
+ // Why: resolution runs per pane per rebuild and the miss path scans detected worktrees;
+ // everything below depends only on worktreeId + host, so memoize per build.
+ const ownerCacheKey = `${context.worktreeId}\0${executionHostId ?? ''}`
+ const cached = ownerCache.get(ownerCacheKey)
+ if (cached) {
+ return cached
+ }
+ const resolvedWorktree = args.resolveWorktree?.(context.worktreeId, executionHostId)
+ const mappedWorktree = args.worktreeMap.get(context.worktreeId)
+ const worktree =
+ resolvedWorktree ??
+ mappedWorktree ??
+ standaloneActivityWorktree(context.worktreeId, executionHostId)
+ let repo =
+ executionHostId && args.repos
+ ? findIndexedRepoOwnerForHost(args.repos, worktree.repoId, executionHostId)
+ : null
+ if (!repo && worktree.runtimeOwnerEnvironmentId && args.repos) {
+ repo = findIndexedRepoOwnerForHost(
+ args.repos,
+ worktree.repoId,
+ toRuntimeExecutionHostId(worktree.runtimeOwnerEnvironmentId)
+ )
+ }
+ const owner: ActivityEventOwner = {
+ worktree,
+ repo: repo ?? args.repoMap.get(worktree.repoId) ?? null,
+ knownWorktree: Boolean(
+ resolvedWorktree || mappedWorktree || args.tabsByWorktree[context.worktreeId]
+ )
+ }
+ ownerCache.set(ownerCacheKey, owner)
+ return owner
+}
diff --git a/src/renderer/src/components/activity/activity-event-builder-sources.ts b/src/renderer/src/components/activity/activity-event-builder-sources.ts
new file mode 100644
index 00000000000..1049ed95acf
--- /dev/null
+++ b/src/renderer/src/components/activity/activity-event-builder-sources.ts
@@ -0,0 +1,105 @@
+import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsupported-agent-entry'
+import { parsePaneKey } from '../../../../shared/stable-pane-id'
+import type { TerminalTab } from '../../../../shared/terminal-tab-types'
+import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
+import type { Repo } from '../../../../shared/repo-types'
+import type { Worktree } from '../../../../shared/worktree/types'
+import type { ActivityLiveAgentSnapshot, ActivityEvent } from './activity-thread-types'
+import type { ActivityEventBuildCache } from './activity-event-build-cache'
+import { resolvePaneBuild } from './activity-event-build-cache'
+import type { BuildActivityEventsArgs } from './activity-event-builder'
+
+export function appendUnsupportedAndRetainedEvents(context: {
+ args: BuildActivityEventsArgs
+ cache: ActivityEventBuildCache | undefined
+ seenCacheKeys: Set | null
+ liveAgentByPaneKey: Record
+ tabContext: Map
+ resolveOwner: (
+ context: { worktreeId: string; tab: TerminalTab },
+ entry: AgentStatusEntry,
+ terminalPtyId?: string | null
+ ) => { worktree: Worktree; repo: Repo | null; knownWorktree: boolean }
+ pushPaneEvents: (paneEvents: ActivityEvent[]) => void
+}): void {
+ const {
+ args,
+ cache,
+ seenCacheKeys,
+ liveAgentByPaneKey,
+ tabContext,
+ resolveOwner,
+ pushPaneEvents
+ } = context
+
+ for (const unsupported of Object.values(args.migrationUnsupportedByPtyId ?? {})) {
+ const cacheKey = `unsupported:${unsupported.paneKey ?? unsupported.ptyId}`
+ const cached = cache?.panes.get(cacheKey)
+ const entry =
+ cached?.source === unsupported
+ ? cached.rowEntry
+ : migrationUnsupportedToAgentStatusEntry(unsupported)
+ const parsed = entry ? parsePaneKey(entry.paneKey) : null
+ const tabEntry = parsed ? tabContext.get(parsed.tabId) : null
+ if (!entry || !tabEntry) {
+ continue
+ }
+ const owner = resolveOwner(tabEntry, entry, unsupported.ptyId)
+ const { events: paneEvents, live } = resolvePaneBuild(
+ {
+ cacheKey,
+ source: unsupported,
+ entry,
+ orchestration: undefined,
+ worktree: owner.worktree,
+ repo: owner.repo,
+ tab: tabEntry.tab,
+ agentType: entry.agentType ?? 'unknown',
+ agentAlive: false,
+ acknowledgedAt: args.acknowledgedAgentsByPaneKey[entry.paneKey] ?? 0,
+ clearedAt: args.activityClearedAtByPaneKey?.[entry.paneKey] ?? 0,
+ migrationUnsupportedPtyId: unsupported.ptyId,
+ liveState: 'blocked'
+ },
+ cache,
+ seenCacheKeys
+ )
+ if (live) {
+ liveAgentByPaneKey[entry.paneKey] = live
+ }
+ pushPaneEvents(paneEvents)
+ }
+
+ for (const [paneKey, retained] of Object.entries(args.retainedAgentsByPaneKey)) {
+ if (!parsePaneKey(paneKey)) {
+ continue
+ }
+ const owner = resolveOwner(
+ { worktreeId: retained.worktreeId, tab: retained.tab },
+ retained.entry,
+ retained.tab.ptyId ?? retained.entry.terminalHandle
+ )
+ if (!owner.knownWorktree) {
+ continue
+ }
+ const { events: paneEvents } = resolvePaneBuild(
+ {
+ cacheKey: `retained:${paneKey}`,
+ source: retained,
+ entry: retained.entry,
+ orchestration: args.runtimeAgentOrchestrationByPaneKey?.[paneKey],
+ worktree: owner.worktree,
+ repo: owner.repo,
+ tab: retained.tab,
+ agentType: retained.agentType,
+ agentAlive: false,
+ acknowledgedAt: args.acknowledgedAgentsByPaneKey[paneKey] ?? 0,
+ clearedAt: args.activityClearedAtByPaneKey?.[paneKey] ?? 0,
+ liveState: null
+ },
+ cache,
+ seenCacheKeys
+ )
+ pushPaneEvents(paneEvents)
+ }
+}
diff --git a/src/renderer/src/components/activity/activity-event-builder.bounded-history.test.ts b/src/renderer/src/components/activity/activity-event-builder.bounded-history.test.ts
new file mode 100644
index 00000000000..c6350555584
--- /dev/null
+++ b/src/renderer/src/components/activity/activity-event-builder.bounded-history.test.ts
@@ -0,0 +1,122 @@
+import { describe, expect, it } from 'vitest'
+import type {
+ AgentStateHistoryEntry,
+ AgentStatusEntry
+} from '../../../../shared/agent-status-types'
+import { buildActivityEvents, newestActivityHistoryEntries } from './activity-event-builder'
+import { EVENTS_PER_PANE_CAP } from './activity-event-cap'
+import { makeRepo, makeTab, makeWorktree, PANE_KEY } from './ActivityPrototypePage-test-fixtures'
+
+function historyEntry(
+ startedAt: number,
+ state: AgentStateHistoryEntry['state']
+): AgentStateHistoryEntry {
+ return { state, prompt: `prompt-${startedAt}`, startedAt }
+}
+
+function build(args: {
+ entries?: Record
+ activityClearedAtByPaneKey?: Record
+ now?: number
+}) {
+ const repo = makeRepo()
+ const worktree = makeWorktree()
+ const tab = makeTab()
+ return buildActivityEvents({
+ agentStatusByPaneKey: args.entries ?? {},
+ retainedAgentsByPaneKey: {},
+ tabsByWorktree: { [worktree.id]: [tab] },
+ worktreeMap: new Map([[worktree.id, worktree]]),
+ repoMap: new Map([[repo.id, repo]]),
+ acknowledgedAgentsByPaneKey: {},
+ activityClearedAtByPaneKey: args.activityClearedAtByPaneKey,
+ now: args.now ?? 100_000
+ })
+}
+
+describe('newestActivityHistoryEntries', () => {
+ it('takes only the newest cap-many eligible entries without scanning results past the cap', () => {
+ const history: AgentStateHistoryEntry[] = []
+ for (let i = 0; i < 10_000; i += 1) {
+ history.push(historyEntry(i + 1, i % 2 === 0 ? 'done' : 'working'))
+ }
+ const newest = newestActivityHistoryEntries(history, EVENTS_PER_PANE_CAP)
+ expect(newest).toHaveLength(EVENTS_PER_PANE_CAP)
+ // Only done/blocked/waiting are eligible; newest five eligible are the last five even-indexed rows, oldest-first.
+ expect(newest.map((entry) => entry.startedAt)).toEqual([9991, 9993, 9995, 9997, 9999])
+ })
+
+ it('returns fewer entries when eligible history is short', () => {
+ const history = [historyEntry(1, 'working'), historyEntry(2, 'done')]
+ expect(
+ newestActivityHistoryEntries(history, EVENTS_PER_PANE_CAP).map((e) => e.startedAt)
+ ).toEqual([2])
+ })
+})
+
+describe('buildActivityEvents bounded history', () => {
+ it('produces identical visible events for a pane with unbounded history as the per-pane cap allows', () => {
+ const longHistory: AgentStateHistoryEntry[] = []
+ for (let i = 0; i < 1_000; i += 1) {
+ longHistory.push(historyEntry(i + 1, 'done'))
+ }
+ const entry: AgentStatusEntry = {
+ state: 'done',
+ prompt: 'latest',
+ updatedAt: 5_000,
+ stateStartedAt: 5_000,
+ paneKey: PANE_KEY,
+ stateHistory: longHistory,
+ agentType: 'claude'
+ }
+ const { events } = build({ entries: { [PANE_KEY]: entry } })
+ // Per-pane cap holds: newest events only, newest-first ordering preserved.
+ expect(events).toHaveLength(EVENTS_PER_PANE_CAP)
+ expect(events.map((event) => event.timestamp)).toEqual([5_000, 1_000, 999, 998, 997])
+ })
+})
+
+describe('buildActivityEvents cleared cutoff', () => {
+ const doneEntry: AgentStatusEntry = {
+ state: 'done',
+ prompt: 'finish it',
+ updatedAt: 2_000,
+ stateStartedAt: 2_000,
+ paneKey: PANE_KEY,
+ stateHistory: [historyEntry(1_000, 'done')],
+ agentType: 'claude'
+ }
+
+ it('hides events stamped at or before the pane cutoff', () => {
+ const { events } = build({
+ entries: { [PANE_KEY]: doneEntry },
+ activityClearedAtByPaneKey: { [PANE_KEY]: 2_000 }
+ })
+ expect(events).toHaveLength(0)
+ })
+
+ it('keeps events newer than the cutoff', () => {
+ const { events } = build({
+ entries: { [PANE_KEY]: doneEntry },
+ activityClearedAtByPaneKey: { [PANE_KEY]: 1_000 }
+ })
+ expect(events.map((event) => event.timestamp)).toEqual([2_000])
+ })
+
+ it('does not suppress a live working snapshot for a cleared pane', () => {
+ const workingEntry: AgentStatusEntry = {
+ ...doneEntry,
+ state: 'working',
+ updatedAt: 99_000,
+ stateStartedAt: 99_000
+ }
+ const { events, liveAgentByPaneKey } = build({
+ entries: { [PANE_KEY]: workingEntry },
+ activityClearedAtByPaneKey: { [PANE_KEY]: 98_000 },
+ now: 99_500
+ })
+ expect(liveAgentByPaneKey[PANE_KEY]?.state).toBe('working')
+ // The historical done at 1_000 stays hidden by the cutoff.
+ expect(events).toHaveLength(0)
+ })
+})
diff --git a/src/renderer/src/components/activity/activity-event-builder.host-ownership.test.ts b/src/renderer/src/components/activity/activity-event-builder.host-ownership.test.ts
new file mode 100644
index 00000000000..51beb6cf1f9
--- /dev/null
+++ b/src/renderer/src/components/activity/activity-event-builder.host-ownership.test.ts
@@ -0,0 +1,206 @@
+import { describe, expect, it, vi } from 'vitest'
+import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
+import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
+import {
+ LEAF_ID,
+ makeRepo,
+ makeRetainedDoneEntry,
+ makeTab,
+ makeWorktree
+} from './ActivityPrototypePage-test-fixtures'
+import { buildActivityEvents } from './activity-event-builder'
+
+const PANE_KEY = `tab-1:${LEAF_ID}`
+
+function doneEntry(connectionId: string | null): AgentStatusEntry {
+ return {
+ state: 'done',
+ prompt: 'Finished task',
+ updatedAt: 2_000,
+ stateStartedAt: 2_000,
+ paneKey: PANE_KEY,
+ tabId: 'tab-1',
+ connectionId,
+ stateHistory: [],
+ agentType: 'claude'
+ }
+}
+
+describe('activity event host ownership', () => {
+ it('uses the status transport host when worktree and repo ids collide', () => {
+ const localRepo = makeRepo()
+ const remoteRepo = { ...makeRepo(), connectionId: 'builder', displayName: 'Remote repo' }
+ const localWorktree = makeWorktree()
+ const remoteWorktree = {
+ ...makeWorktree(),
+ hostId: 'ssh:builder' as const,
+ displayName: 'Remote worktree'
+ }
+ const tab = makeTab()
+ const resolveWorktree = vi.fn((_worktreeId, executionHostId) =>
+ executionHostId === 'ssh:builder' ? remoteWorktree : localWorktree
+ )
+
+ const result = buildActivityEvents({
+ agentStatusByPaneKey: { [PANE_KEY]: doneEntry('builder') },
+ retainedAgentsByPaneKey: {},
+ tabsByWorktree: { [localWorktree.id]: [tab] },
+ worktreeMap: new Map([[localWorktree.id, localWorktree]]),
+ repoMap: new Map([[localRepo.id, localRepo]]),
+ repos: [localRepo, remoteRepo],
+ resolveWorktree,
+ acknowledgedAgentsByPaneKey: {},
+ now: 3_000
+ })
+
+ expect(resolveWorktree).toHaveBeenCalledWith(localWorktree.id, 'ssh:builder')
+ expect(result.events[0]?.worktree).toBe(remoteWorktree)
+ expect(result.events[0]?.repo).toBe(remoteRepo)
+ })
+
+ it('uses the mirrored tab host when paired-runtime status is host-local', () => {
+ const localRepo = makeRepo()
+ const runtimeRepo = {
+ ...makeRepo(),
+ executionHostId: 'runtime:env-1' as const,
+ displayName: 'Runtime repo'
+ }
+ const localWorktree = makeWorktree()
+ const runtimeWorktree = {
+ ...makeWorktree(),
+ hostId: 'runtime:env-1' as const,
+ runtimeOwnerEnvironmentId: 'env-1',
+ displayName: 'Runtime worktree'
+ }
+ const tab = makeTab()
+ const resolveWorktree = vi.fn((_worktreeId, executionHostId) =>
+ executionHostId === 'runtime:env-1' ? runtimeWorktree : localWorktree
+ )
+
+ const result = buildActivityEvents({
+ agentStatusByPaneKey: { [PANE_KEY]: doneEntry(null) },
+ retainedAgentsByPaneKey: {},
+ tabsByWorktree: { [localWorktree.id]: [tab] },
+ unifiedTabsByWorktree: {
+ [localWorktree.id]: [
+ {
+ id: tab.id,
+ entityId: tab.id,
+ groupId: 'group-1',
+ worktreeId: localWorktree.id,
+ executionHostId: 'runtime:env-1',
+ contentType: 'terminal',
+ label: tab.title,
+ customLabel: null,
+ color: null,
+ sortOrder: 0,
+ createdAt: 1
+ }
+ ]
+ },
+ worktreeMap: new Map([[localWorktree.id, localWorktree]]),
+ repoMap: new Map([[localRepo.id, localRepo]]),
+ repos: [localRepo, runtimeRepo],
+ resolveWorktree,
+ acknowledgedAgentsByPaneKey: {},
+ now: 3_000
+ })
+
+ expect(resolveWorktree).toHaveBeenCalledWith(localWorktree.id, 'runtime:env-1')
+ expect(result.events[0]?.worktree).toBe(runtimeWorktree)
+ expect(result.events[0]?.repo).toBe(runtimeRepo)
+ })
+
+ it('keeps retained folder-workspace activity after its terminal tab is gone', () => {
+ const folderWorktree = {
+ ...makeWorktree(),
+ id: folderWorkspaceKey('folder-1'),
+ repoId: 'folder-workspace:group-1',
+ hostId: 'local' as const,
+ displayName: 'Docs folder'
+ }
+ const tab = { ...makeTab(), worktreeId: folderWorktree.id }
+ const retained = makeRetainedDoneEntry(tab)
+ retained.worktreeId = folderWorktree.id
+ retained.entry = doneEntry(null)
+
+ const result = buildActivityEvents({
+ agentStatusByPaneKey: {},
+ retainedAgentsByPaneKey: { [PANE_KEY]: retained },
+ tabsByWorktree: {},
+ worktreeMap: new Map(),
+ repoMap: new Map(),
+ resolveWorktree: (worktreeId, executionHostId) =>
+ worktreeId === folderWorktree.id && executionHostId === 'local'
+ ? folderWorktree
+ : undefined,
+ acknowledgedAgentsByPaneKey: {},
+ now: 3_000
+ })
+
+ expect(result.events[0]?.worktree).toBe(folderWorktree)
+ expect(result.events[0]?.worktree.displayName).toBe('Docs folder')
+ })
+
+ it('carries migrationUnsupportedPtyId on events built for un-migratable panes', () => {
+ const worktree = makeWorktree()
+ const repo = makeRepo()
+ const tab = makeTab()
+
+ const result = buildActivityEvents({
+ agentStatusByPaneKey: {},
+ retainedAgentsByPaneKey: {},
+ migrationUnsupportedByPtyId: {
+ 'pty-1': {
+ ptyId: 'pty-1',
+ paneKey: PANE_KEY,
+ tabId: tab.id,
+ reason: 'legacy-numeric-pane-key',
+ source: 'local',
+ updatedAt: 1_000
+ }
+ },
+ tabsByWorktree: { [worktree.id]: [tab] },
+ worktreeMap: new Map([[worktree.id, worktree]]),
+ repoMap: new Map([[repo.id, repo]]),
+ resolveWorktree: () => worktree,
+ acknowledgedAgentsByPaneKey: {},
+ now: 3_000
+ })
+
+ expect(result.events.length).toBeGreaterThan(0)
+ for (const event of result.events) {
+ expect(event.migrationUnsupportedPtyId).toBe('pty-1')
+ }
+ })
+
+ it('uses the retained terminal handle to preserve runtime host ownership after teardown', () => {
+ const localWorktree = makeWorktree()
+ const runtimeWorktree = {
+ ...makeWorktree(),
+ hostId: 'runtime:env-1' as const,
+ runtimeOwnerEnvironmentId: 'env-1',
+ displayName: 'Runtime worktree'
+ }
+ const tab = { ...makeTab(), ptyId: null }
+ const retained = makeRetainedDoneEntry(tab)
+ retained.entry = { ...doneEntry(null), terminalHandle: 'remote:env-1@@pty-1' }
+ const resolveWorktree = vi.fn((_worktreeId, executionHostId) =>
+ executionHostId === 'runtime:env-1' ? runtimeWorktree : localWorktree
+ )
+
+ const result = buildActivityEvents({
+ agentStatusByPaneKey: {},
+ retainedAgentsByPaneKey: { [PANE_KEY]: retained },
+ tabsByWorktree: {},
+ worktreeMap: new Map([[localWorktree.id, localWorktree]]),
+ repoMap: new Map(),
+ resolveWorktree,
+ acknowledgedAgentsByPaneKey: {},
+ now: 3_000
+ })
+
+ expect(resolveWorktree).toHaveBeenCalledWith(localWorktree.id, 'runtime:env-1')
+ expect(result.events[0]?.worktree).toBe(runtimeWorktree)
+ })
+})
diff --git a/src/renderer/src/components/activity/activity-event-builder.identity-reuse.test.ts b/src/renderer/src/components/activity/activity-event-builder.identity-reuse.test.ts
new file mode 100644
index 00000000000..1c1a76aff20
--- /dev/null
+++ b/src/renderer/src/components/activity/activity-event-builder.identity-reuse.test.ts
@@ -0,0 +1,225 @@
+import { describe, expect, it } from 'vitest'
+import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
+import type { RetainedAgentEntry } from '@/store/slices/agent-status'
+import { makePaneKey } from '../../../../shared/stable-pane-id'
+import {
+ buildActivityEvents,
+ createActivityEventBuildCache,
+ type ActivityEventBuildCache
+} from './activity-event-builder'
+import {
+ buildAgentPaneThreads,
+ createAgentPaneThreadReuseCache,
+ type AgentPaneThreadReuseCache
+} from './activity-thread-builder'
+import {
+ LEAF_ID,
+ LEAF_ID_2,
+ makeRepo,
+ makeTab,
+ makeTabWithIds,
+ makeWorktree
+} from './ActivityPrototypePage-test-fixtures'
+
+const PANE_A = makePaneKey('tab-1', LEAF_ID)
+const PANE_B = makePaneKey('tab-2', LEAF_ID_2)
+const NOW = 100_000
+
+function entry(paneKey: string, overrides: Partial = {}): AgentStatusEntry {
+ return {
+ state: 'done',
+ prompt: `run ${paneKey}`,
+ updatedAt: 50_000,
+ stateStartedAt: 50_000,
+ paneKey,
+ stateHistory: [{ state: 'done', prompt: 'older', startedAt: 10_000 }],
+ agentType: 'claude',
+ ...overrides
+ }
+}
+
+type BuildArgs = Parameters[0]
+
+function makeArgs(overrides: Partial = {}): BuildArgs {
+ const repo = makeRepo()
+ const worktree = makeWorktree()
+ return {
+ agentStatusByPaneKey: {
+ [PANE_A]: entry(PANE_A),
+ [PANE_B]: entry(PANE_B, { state: 'working', stateStartedAt: NOW - 1_000 })
+ },
+ retainedAgentsByPaneKey: {},
+ tabsByWorktree: {
+ [worktree.id]: [makeTab(), makeTabWithIds('tab-2', worktree.id)]
+ },
+ worktreeMap: new Map([[worktree.id, worktree]]),
+ repoMap: new Map([[repo.id, repo]]),
+ acknowledgedAgentsByPaneKey: {},
+ now: NOW,
+ ...overrides
+ }
+}
+
+function buildBoth(
+ args: BuildArgs,
+ eventCache: ActivityEventBuildCache,
+ threadCache: AgentPaneThreadReuseCache
+) {
+ const result = buildActivityEvents(args, eventCache)
+ const threads = buildAgentPaneThreads(
+ { events: result.events, liveAgentByPaneKey: result.liveAgentByPaneKey },
+ threadCache
+ )
+ return { ...result, threads }
+}
+
+function threadByPane(threads: T[], paneKey: string): T | undefined {
+ return threads.find((thread) => thread.paneKey === paneKey)
+}
+
+describe('activity build identity reuse', () => {
+ it('returns identical event, snapshot, thread, and list identities for identical inputs', () => {
+ const eventCache = createActivityEventBuildCache()
+ const threadCache = createAgentPaneThreadReuseCache()
+ const args = makeArgs()
+ const first = buildBoth(args, eventCache, threadCache)
+ const second = buildBoth(args, eventCache, threadCache)
+
+ expect(second.threads).toBe(first.threads)
+ expect(second.events.map((event) => event)).toEqual(first.events.map((event) => event))
+ for (let i = 0; i < first.events.length; i += 1) {
+ expect(second.events[i]).toBe(first.events[i])
+ }
+ expect(second.liveAgentByPaneKey[PANE_B]).toBe(first.liveAgentByPaneKey[PANE_B])
+ })
+
+ it('changes only the written pane; every other thread keeps its identity', () => {
+ const eventCache = createActivityEventBuildCache()
+ const threadCache = createAgentPaneThreadReuseCache()
+ const args = makeArgs()
+ const first = buildBoth(args, eventCache, threadCache)
+
+ const next = makeArgs({
+ agentStatusByPaneKey: {
+ ...args.agentStatusByPaneKey,
+ [PANE_B]: entry(PANE_B, {
+ state: 'working',
+ stateStartedAt: NOW - 1_000,
+ prompt: 'streamed update'
+ })
+ },
+ tabsByWorktree: args.tabsByWorktree,
+ worktreeMap: args.worktreeMap,
+ repoMap: args.repoMap
+ })
+ const second = buildBoth(next, eventCache, threadCache)
+
+ expect(threadByPane(second.threads, PANE_A)).toBe(threadByPane(first.threads, PANE_A))
+ expect(threadByPane(second.threads, PANE_B)).not.toBe(threadByPane(first.threads, PANE_B))
+ expect(second.threads).not.toBe(first.threads)
+ })
+
+ it('an acknowledgement or cleared-cutoff change rebuilds only that pane', () => {
+ const eventCache = createActivityEventBuildCache()
+ const threadCache = createAgentPaneThreadReuseCache()
+ const args = makeArgs()
+ const first = buildBoth(args, eventCache, threadCache)
+
+ const acked = buildBoth(
+ makeArgs({
+ agentStatusByPaneKey: args.agentStatusByPaneKey,
+ tabsByWorktree: args.tabsByWorktree,
+ worktreeMap: args.worktreeMap,
+ repoMap: args.repoMap,
+ acknowledgedAgentsByPaneKey: { [PANE_A]: NOW }
+ }),
+ eventCache,
+ threadCache
+ )
+ expect(threadByPane(acked.threads, PANE_B)).toBe(threadByPane(first.threads, PANE_B))
+ expect(threadByPane(acked.threads, PANE_A)?.unread).toBe(false)
+ expect(threadByPane(first.threads, PANE_A)?.unread).toBe(true)
+
+ const cleared = buildBoth(
+ makeArgs({
+ agentStatusByPaneKey: args.agentStatusByPaneKey,
+ tabsByWorktree: args.tabsByWorktree,
+ worktreeMap: args.worktreeMap,
+ repoMap: args.repoMap,
+ acknowledgedAgentsByPaneKey: { [PANE_A]: NOW },
+ activityClearedAtByPaneKey: { [PANE_A]: NOW }
+ }),
+ eventCache,
+ threadCache
+ )
+ expect(threadByPane(cleared.threads, PANE_B)).toBe(threadByPane(first.threads, PANE_B))
+ expect(threadByPane(cleared.threads, PANE_A)).toBeUndefined()
+ })
+
+ it('freshness decay refreshes the live snapshot without churning event identities', () => {
+ const eventCache = createActivityEventBuildCache()
+ const threadCache = createAgentPaneThreadReuseCache()
+ const args = makeArgs()
+ const first = buildBoth(args, eventCache, threadCache)
+ expect(first.liveAgentByPaneKey[PANE_B]?.state).toBe('working')
+
+ // Same inputs much later: the working turn is stale now, so the snapshot drops.
+ const decayed = buildBoth(
+ makeArgs({ ...args, now: NOW + 60 * 60 * 1000 }),
+ eventCache,
+ threadCache
+ )
+ expect(decayed.liveAgentByPaneKey[PANE_B]).toBeUndefined()
+ // PANE_A had no live snapshot; its thread survives untouched.
+ expect(threadByPane(decayed.threads, PANE_A)).toBe(threadByPane(first.threads, PANE_A))
+ })
+
+ it('cached builds always equal a cold uncached build (no drift)', () => {
+ const eventCache = createActivityEventBuildCache()
+ const threadCache = createAgentPaneThreadReuseCache()
+ const scenarios: BuildArgs[] = [
+ makeArgs(),
+ makeArgs({ acknowledgedAgentsByPaneKey: { [PANE_A]: NOW } }),
+ makeArgs({ activityClearedAtByPaneKey: { [PANE_A]: NOW } }),
+ makeArgs({
+ runtimeAgentOrchestrationByPaneKey: {
+ [PANE_B]: { taskId: 't1', dispatchId: 'd1', parentPaneKey: PANE_A }
+ }
+ }),
+ makeArgs({ now: NOW + 60 * 60 * 1000 })
+ ]
+ for (const scenario of scenarios) {
+ const cached = buildBoth(scenario, eventCache, threadCache)
+ const cold = buildActivityEvents(scenario)
+ const coldThreads = buildAgentPaneThreads({
+ events: cold.events,
+ liveAgentByPaneKey: cold.liveAgentByPaneKey
+ })
+ expect(cached.events).toEqual(cold.events)
+ expect(cached.liveAgentByPaneKey).toEqual(cold.liveAgentByPaneKey)
+ expect(cached.threads).toEqual(coldThreads)
+ }
+ })
+
+ it('keeps first-source-wins dedupe when a pane is both live and retained, and evicts gone panes', () => {
+ const eventCache = createActivityEventBuildCache()
+ const threadCache = createAgentPaneThreadReuseCache()
+ const retained: RetainedAgentEntry = {
+ entry: entry(PANE_A, { prompt: 'retained copy' }),
+ worktreeId: makeWorktree().id,
+ tab: makeTab(),
+ agentType: 'claude',
+ startedAt: 50_000
+ }
+ const args = makeArgs({ retainedAgentsByPaneKey: { [PANE_A]: retained } })
+ const cachedResult = buildBoth(args, eventCache, threadCache)
+ const cold = buildActivityEvents(args)
+ expect(cachedResult.events).toEqual(cold.events)
+ expect(eventCache.panes.has(`retained:${PANE_A}`)).toBe(true)
+
+ // Retained entry dismissed: its cache row must not linger.
+ buildBoth(makeArgs(), eventCache, threadCache)
+ expect(eventCache.panes.has(`retained:${PANE_A}`)).toBe(false)
+ expect(eventCache.panes.has(`live:${PANE_A}`)).toBe(true)
+ })
+})
diff --git a/src/renderer/src/components/activity/activity-event-builder.ts b/src/renderer/src/components/activity/activity-event-builder.ts
index 11e66f65bb2..5e1e3a112ae 100644
--- a/src/renderer/src/components/activity/activity-event-builder.ts
+++ b/src/renderer/src/components/activity/activity-event-builder.ts
@@ -1,33 +1,41 @@
import { isExplicitAgentStatusFresh } from '@/lib/agent-status'
-import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsupported-agent-entry'
import type { RetainedAgentEntry } from '@/store/slices/agent-status'
import {
AGENT_STATUS_STALE_AFTER_MS,
- type AgentStateHistoryEntry,
type AgentStatusEntry,
+ type AgentStatusOrchestrationContext,
type AgentStatusState,
- type AgentType,
type MigrationUnsupportedPtyEntry
} from '../../../../shared/agent-status-types'
-import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
+import type { ExecutionHostId } from '../../../../shared/execution-host'
import type { Repo } from '../../../../shared/repo-types'
import { parsePaneKey } from '../../../../shared/stable-pane-id'
+import type { Tab } from '../../../../shared/tab-types'
import type { TerminalTab } from '../../../../shared/terminal-tab-types'
import type { Worktree } from '../../../../shared/worktree/types'
import type {
ActivityEvent,
- ActivityEventState,
ActivityHookLiveAgentState,
ActivityLiveAgentSnapshot,
ActivityLiveAgentState
} from './activity-thread-types'
import { capActivityEvents } from './activity-event-cap'
+import { newestActivityHistoryEntries } from './activity-pane-events'
+import {
+ createActivityEventBuildCache,
+ resolvePaneBuild,
+ type ActivityEventBuildCache
+} from './activity-event-build-cache'
+import { appendUnsupportedAndRetainedEvents } from './activity-event-builder-sources'
+import {
+ attributedActivityTabContext,
+ buildActivityTabContext,
+ buildActivityTabHostIndex,
+ resolveActivityEventOwner,
+ type ActivityEventOwner
+} from './activity-event-builder-context'
-const STANDALONE_ACTIVITY_WORKTREE_REPO_ID = '__activity_standalone__'
-
-function isActivityEventState(state: AgentStatusState): state is ActivityEventState {
- return state === 'done' || state === 'blocked' || state === 'waiting'
-}
+export { createActivityEventBuildCache, type ActivityEventBuildCache, newestActivityHistoryEntries }
function isActivityHookLiveAgentState(
state: AgentStatusState
@@ -50,142 +58,47 @@ function freshActivityLiveAgentState(
: entry.state
}
-function standaloneActivityWorktree(worktreeId: string): Worktree {
- const displayName =
- worktreeId === FLOATING_TERMINAL_WORKTREE_ID ? 'Floating terminal' : 'Standalone terminal'
- return {
- id: worktreeId,
- repoId: STANDALONE_ACTIVITY_WORKTREE_REPO_ID,
- path: '',
- head: '',
- branch: displayName,
- isBare: false,
- isMainWorktree: false,
- displayName,
- comment: '',
- linkedIssue: null,
- linkedPR: null,
- linkedLinearIssue: null,
- isArchived: false,
- isUnread: false,
- isPinned: false,
- sortOrder: 0,
- lastActivityAt: 0
- }
-}
-
-function historyEntrySnapshot(
- entry: AgentStatusEntry,
- history: AgentStateHistoryEntry
-): AgentStatusEntry {
- return {
- ...entry,
- state: history.state,
- prompt: history.prompt,
- updatedAt: history.startedAt,
- stateStartedAt: history.startedAt,
- stateHistory: [],
- toolName: undefined,
- toolInput: undefined,
- lastAssistantMessage: undefined,
- interrupted: history.interrupted
- }
-}
-
-function appendActivityEvent(args: {
- events: ActivityEvent[]
- seenEventIds: Set
- state: ActivityEventState
- timestamp: number
- worktree: Worktree
- repo: Repo | null
- entry: AgentStatusEntry
- tab: TerminalTab
- agentType: AgentType
- agentAlive: boolean
- acknowledgedAt: number
- migrationUnsupportedPtyId?: string
-}): void {
- const id = `agent:${args.entry.paneKey}:${args.state}:${args.timestamp}`
- if (args.seenEventIds.has(id)) {
- return
- }
- args.seenEventIds.add(id)
- args.events.push({
- id,
- state: args.state,
- timestamp: args.timestamp,
- worktree: args.worktree,
- repo: args.repo,
- entry: args.entry,
- tab: args.tab,
- agentType: args.agentType,
- agentAlive: args.agentAlive,
- migrationUnsupportedPtyId: args.migrationUnsupportedPtyId,
- unread: args.acknowledgedAt < args.timestamp
- })
-}
-
-function appendActivityEventsForEntry(args: {
- events: ActivityEvent[]
- seenEventIds: Set
- entry: AgentStatusEntry
- worktree: Worktree
- repo: Repo | null
- tab: TerminalTab
- agentType: AgentType
- agentAlive: boolean
- acknowledgedAt: number
- migrationUnsupportedPtyId?: string
-}): void {
- // Why: Activity is append-only; when a pane continues (done→working), stateHistory is the only record of the previous done/blocking event.
- for (const history of args.entry.stateHistory) {
- if (!isActivityEventState(history.state)) {
- continue
- }
- appendActivityEvent({
- ...args,
- state: history.state,
- timestamp: history.startedAt,
- entry: historyEntrySnapshot(args.entry, history)
- })
- }
-
- // Why: SessionStart creates an idle row, not an "Agent finished" activity event (STA-3386).
- if (!isActivityEventState(args.entry.state) || args.entry.sessionBoundary === true) {
- return
- }
- appendActivityEvent({
- ...args,
- state: args.entry.state,
- timestamp: args.entry.stateStartedAt
- })
-}
-
-type BuildActivityEventsArgs = {
+export type BuildActivityEventsArgs = {
agentStatusByPaneKey: Record
+ runtimeAgentOrchestrationByPaneKey?: Record
migrationUnsupportedByPtyId?: Record
retainedAgentsByPaneKey: Record
tabsByWorktree: Record
+ unifiedTabsByWorktree?: Record
worktreeMap: Map
repoMap: Map
+ repos?: readonly Repo[]
+ resolveWorktree?: (worktreeId: string, executionHostId?: ExecutionHostId) => Worktree | undefined
acknowledgedAgentsByPaneKey: Record
+ /** Per-pane "Clear completed" cutoffs; events stamped at or before the cutoff are hidden. */
+ activityClearedAtByPaneKey?: Record
now: number
}
-export function buildActivityEvents(args: BuildActivityEventsArgs): {
+export function buildActivityEvents(
+ args: BuildActivityEventsArgs,
+ cache?: ActivityEventBuildCache
+): {
events: ActivityEvent[]
liveAgentByPaneKey: Record
} {
const events: ActivityEvent[] = []
const seenEventIds = new Set()
- const tabContext = new Map()
+ const tabContext = buildActivityTabContext(args.tabsByWorktree, args.unifiedTabsByWorktree)
+ const tabHostIndex = buildActivityTabHostIndex(args.unifiedTabsByWorktree)
+ const ownerCache = new Map()
const liveAgentByPaneKey: Record = {}
+ const seenCacheKeys = cache ? new Set() : null
- for (const [worktreeId, tabs] of Object.entries(args.tabsByWorktree)) {
- const worktree = args.worktreeMap.get(worktreeId) ?? standaloneActivityWorktree(worktreeId)
- for (const tab of tabs) {
- tabContext.set(tab.id, { worktree, tab })
+ const pushPaneEvents = (paneEvents: ActivityEvent[]): void => {
+ // Why: a paneKey can appear in more than one source (live + retained overlap);
+ // event ids stay globally unique so the first source wins, as before.
+ for (const event of paneEvents) {
+ if (seenEventIds.has(event.id)) {
+ continue
+ }
+ seenEventIds.add(event.id)
+ events.push(event)
}
}
@@ -194,101 +107,64 @@ export function buildActivityEvents(args: BuildActivityEventsArgs): {
if (!parsed) {
continue
}
- const context = tabContext.get(parsed.tabId)
+ const context = tabContext.get(parsed.tabId) ?? attributedActivityTabContext(entry)
if (!context) {
continue
}
- const ackAt = args.acknowledgedAgentsByPaneKey[paneKey] ?? 0
+ const owner = resolveActivityEventOwner(
+ args,
+ context,
+ entry,
+ context.tab.ptyId,
+ tabHostIndex,
+ ownerCache
+ )
+ const orchestration = args.runtimeAgentOrchestrationByPaneKey?.[paneKey]
// Why: live status is separate from history; a fresh working turn updates the thread without counting as an unread done/blocked/waiting event.
+ // The freshness check runs on the raw entry (orchestration merges never change state/timing fields).
const liveState = freshActivityLiveAgentState(entry, args.now)
- if (liveState) {
- liveAgentByPaneKey[paneKey] = {
- state: liveState,
- timestamp: entry.stateStartedAt,
- worktree: context.worktree,
- repo: args.repoMap.get(context.worktree.repoId) ?? null,
+ const { events: paneEvents, live } = resolvePaneBuild(
+ {
+ cacheKey: `live:${paneKey}`,
+ source: entry,
entry,
+ orchestration,
+ worktree: owner.worktree,
+ repo: owner.repo,
tab: context.tab,
- agentType: entry.agentType ?? 'unknown'
+ agentType: entry.agentType ?? 'unknown',
+ agentAlive: true,
+ acknowledgedAt: args.acknowledgedAgentsByPaneKey[paneKey] ?? 0,
+ clearedAt: args.activityClearedAtByPaneKey?.[paneKey] ?? 0,
+ liveState
+ },
+ cache,
+ seenCacheKeys
+ )
+ if (live) {
+ liveAgentByPaneKey[paneKey] = live
+ }
+ pushPaneEvents(paneEvents)
+ }
+
+ appendUnsupportedAndRetainedEvents({
+ args,
+ cache,
+ seenCacheKeys,
+ liveAgentByPaneKey,
+ tabContext,
+ resolveOwner: (context, entry, terminalPtyId) =>
+ resolveActivityEventOwner(args, context, entry, terminalPtyId, tabHostIndex, ownerCache),
+ pushPaneEvents
+ })
+
+ // Why: evict panes gone from every source so the cache can't outgrow the live state maps.
+ if (cache && seenCacheKeys) {
+ for (const cacheKey of cache.panes.keys()) {
+ if (!seenCacheKeys.has(cacheKey)) {
+ cache.panes.delete(cacheKey)
}
}
- appendActivityEventsForEntry({
- events,
- seenEventIds,
- worktree: context.worktree,
- repo: args.repoMap.get(context.worktree.repoId) ?? null,
- entry,
- tab: context.tab,
- agentType: entry.agentType ?? 'unknown',
- agentAlive: true,
- acknowledgedAt: ackAt
- })
}
-
- appendUnsupportedAndRetainedEvents(args, events, seenEventIds, liveAgentByPaneKey, tabContext)
return { events: capActivityEvents(events), liveAgentByPaneKey }
}
-
-function appendUnsupportedAndRetainedEvents(
- args: BuildActivityEventsArgs,
- events: ActivityEvent[],
- seenEventIds: Set,
- liveAgentByPaneKey: Record,
- tabContext: Map
-): void {
- for (const unsupported of Object.values(args.migrationUnsupportedByPtyId ?? {})) {
- const entry = migrationUnsupportedToAgentStatusEntry(unsupported)
- const parsed = entry ? parsePaneKey(entry.paneKey) : null
- const context = parsed ? tabContext.get(parsed.tabId) : null
- if (!entry || !context) {
- continue
- }
- const ackAt = args.acknowledgedAgentsByPaneKey[entry.paneKey] ?? 0
- liveAgentByPaneKey[entry.paneKey] = {
- state: 'blocked',
- timestamp: entry.stateStartedAt,
- worktree: context.worktree,
- repo: args.repoMap.get(context.worktree.repoId) ?? null,
- entry,
- tab: context.tab,
- agentType: entry.agentType ?? 'unknown'
- }
- appendActivityEventsForEntry({
- events,
- seenEventIds,
- worktree: context.worktree,
- repo: args.repoMap.get(context.worktree.repoId) ?? null,
- entry,
- tab: context.tab,
- agentType: entry.agentType ?? 'unknown',
- agentAlive: false,
- acknowledgedAt: ackAt,
- migrationUnsupportedPtyId: unsupported.ptyId
- })
- }
-
- for (const [paneKey, retained] of Object.entries(args.retainedAgentsByPaneKey)) {
- if (!parsePaneKey(paneKey)) {
- continue
- }
- const worktree =
- args.worktreeMap.get(retained.worktreeId) ??
- (args.tabsByWorktree[retained.worktreeId]
- ? standaloneActivityWorktree(retained.worktreeId)
- : null)
- if (!worktree) {
- continue
- }
- appendActivityEventsForEntry({
- events,
- seenEventIds,
- worktree,
- repo: args.repoMap.get(worktree.repoId) ?? null,
- entry: retained.entry,
- tab: retained.tab,
- agentType: retained.agentType,
- agentAlive: false,
- acknowledgedAt: args.acknowledgedAgentsByPaneKey[paneKey] ?? 0
- })
- }
-}
diff --git a/src/renderer/src/components/activity/activity-event-cap.ts b/src/renderer/src/components/activity/activity-event-cap.ts
index c6b80d53823..54d84c3039c 100644
--- a/src/renderer/src/components/activity/activity-event-cap.ts
+++ b/src/renderer/src/components/activity/activity-event-cap.ts
@@ -1,7 +1,8 @@
import type { ActivityEvent } from './activity-thread-types'
// Why: per-pane cap guarantees each agent appears in the left list even when one pane has a long history.
-const EVENTS_PER_PANE_CAP = 5
+// Exported so the event builder can skip building history entries the cap would drop anyway.
+export const EVENTS_PER_PANE_CAP = 5
export function capActivityEvents(events: ActivityEvent[]): ActivityEvent[] {
const sorted = events.sort((a, b) => b.timestamp - a.timestamp)
diff --git a/src/renderer/src/components/activity/activity-pane-events.ts b/src/renderer/src/components/activity/activity-pane-events.ts
new file mode 100644
index 00000000000..d3da82e5486
--- /dev/null
+++ b/src/renderer/src/components/activity/activity-pane-events.ts
@@ -0,0 +1,108 @@
+import type {
+ AgentStateHistoryEntry,
+ AgentStatusEntry
+} from '../../../../shared/agent-status-types'
+import type { Repo } from '../../../../shared/repo-types'
+import type { TerminalTab } from '../../../../shared/terminal-tab-types'
+import type { Worktree } from '../../../../shared/worktree/types'
+import type { ActivityEvent, ActivityEventState } from './activity-thread-types'
+import { EVENTS_PER_PANE_CAP } from './activity-event-cap'
+
+export function isActivityEventState(
+ state: AgentStatusEntry['state']
+): state is ActivityEventState {
+ return state === 'done' || state === 'blocked' || state === 'waiting'
+}
+
+function historyEntrySnapshot(
+ entry: AgentStatusEntry,
+ history: AgentStateHistoryEntry
+): AgentStatusEntry {
+ return {
+ ...entry,
+ state: history.state,
+ prompt: history.prompt,
+ updatedAt: history.startedAt,
+ stateStartedAt: history.startedAt,
+ stateHistory: [],
+ toolName: undefined,
+ toolInput: undefined,
+ lastAssistantMessage: undefined,
+ interrupted: history.interrupted
+ }
+}
+
+/** Newest activity-eligible history entries, at most `cap`, oldest-first. */
+export function newestActivityHistoryEntries(
+ history: readonly AgentStateHistoryEntry[],
+ cap: number
+): AgentStateHistoryEntry[] {
+ const newest: AgentStateHistoryEntry[] = []
+ for (let i = history.length - 1; i >= 0 && newest.length < cap; i -= 1) {
+ if (isActivityEventState(history[i].state)) {
+ newest.push(history[i])
+ }
+ }
+ return newest.toReversed()
+}
+
+type PaneEventInputs = {
+ entry: AgentStatusEntry
+ worktree: Worktree
+ repo: Repo | null
+ tab: TerminalTab
+ agentType: AgentStatusEntry['agentType']
+ agentAlive: boolean
+ acknowledgedAt: number
+ clearedAt: number
+ migrationUnsupportedPtyId?: string
+}
+
+/** Build one pane's activity events (bounded by the per-pane cap, cutoff applied). */
+export function buildPaneActivityEvents(args: PaneEventInputs): ActivityEvent[] {
+ const events: ActivityEvent[] = []
+ const seenIds = new Set()
+ const append = (state: ActivityEventState, timestamp: number, entry: AgentStatusEntry): void => {
+ const id = `agent:${entry.paneKey}:${state}:${timestamp}`
+ if (seenIds.has(id)) {
+ return
+ }
+ seenIds.add(id)
+ events.push({
+ id,
+ state,
+ timestamp,
+ worktree: args.worktree,
+ repo: args.repo,
+ entry,
+ tab: args.tab,
+ agentType: args.agentType ?? 'unknown',
+ agentAlive: args.agentAlive,
+ migrationUnsupportedPtyId: args.migrationUnsupportedPtyId,
+ unread: args.acknowledgedAt < timestamp
+ })
+ }
+
+ for (const history of newestActivityHistoryEntries(
+ args.entry.stateHistory,
+ EVENTS_PER_PANE_CAP
+ )) {
+ if (history.startedAt <= args.clearedAt) {
+ continue
+ }
+ append(
+ history.state as ActivityEventState,
+ history.startedAt,
+ historyEntrySnapshot(args.entry, history)
+ )
+ }
+
+ if (!isActivityEventState(args.entry.state) || args.entry.sessionBoundary === true) {
+ return events
+ }
+ if (args.entry.stateStartedAt <= args.clearedAt) {
+ return events
+ }
+ append(args.entry.state, args.entry.stateStartedAt, args.entry)
+ return events
+}
diff --git a/src/renderer/src/components/activity/activity-scope-filter-controls.tsx b/src/renderer/src/components/activity/activity-scope-filter-controls.tsx
new file mode 100644
index 00000000000..6256595ba6b
--- /dev/null
+++ b/src/renderer/src/components/activity/activity-scope-filter-controls.tsx
@@ -0,0 +1,170 @@
+import React, { useMemo } from 'react'
+import { X } from 'lucide-react'
+import { useAppStore } from '@/store'
+import { DropdownMenuLabel, DropdownMenuSeparator } from '@/components/ui/dropdown-menu'
+import SidebarRepositoryFilterSection from '@/components/sidebar/SidebarRepositoryFilterSection'
+import { SidebarHostScopeMenuSection } from '@/components/sidebar/SidebarHostScopeMenuSection'
+import {
+ getSidebarHostVisibilityLabel,
+ shouldShowHostScopeControls
+} from '@/components/sidebar/sidebar-host-options'
+import { useSidebarHostScopeOptions } from '@/components/sidebar/use-sidebar-host-scope-options'
+import type { SidebarHostOption } from '@/components/sidebar/sidebar-host-options'
+import { getExecutionHostLabel, type ExecutionHostId } from '../../../../shared/execution-host'
+import { translate } from '@/i18n/i18n'
+
+/**
+ * Host/project scope controls for the Agents activity surfaces. State is the
+ * persisted agents-view scope (agentsVisibleHostIds / agentsFilterRepoIds),
+ * deliberately separate from the workspace-nav filters.
+ */
+export function ActivityScopeFilterMenuSections(): React.JSX.Element | null {
+ const repos = useAppStore((s) => s.repos)
+ const agentsVisibleHostIds = useAppStore((s) => s.agentsVisibleHostIds)
+ const setAgentsVisibleHostIds = useAppStore((s) => s.setAgentsVisibleHostIds)
+ const agentsFilterRepoIds = useAppStore((s) => s.agentsFilterRepoIds)
+ const setAgentsFilterRepoIds = useAppStore((s) => s.setAgentsFilterRepoIds)
+ const { hostOptions } = useSidebarHostScopeOptions()
+ const showHostScopeControls = shouldShowHostScopeControls(hostOptions)
+
+ if (!showHostScopeControls && repos.length <= 1) {
+ return null
+ }
+ return (
+ <>
+
+ {translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.showSection', 'Show')}
+
+ {showHostScopeControls ? (
+ setAgentsVisibleHostIds(null)}
+ visibleWorkspaceHostIds={agentsVisibleHostIds}
+ setVisibleWorkspaceHostIds={setAgentsVisibleHostIds}
+ />
+ ) : null}
+
+
+ >
+ )
+}
+
+// Why not getSidebarHostVisibilityLabel: it collapses a full selection to "All
+// hosts", but a chip only renders while a filter is set — name the selection,
+// falling back to the raw host label for hosts no longer in the options list.
+function getScopeHostChipLabel(
+ visibleHostIds: readonly ExecutionHostId[],
+ hostOptions: readonly SidebarHostOption[]
+): string {
+ if (visibleHostIds.length === 1) {
+ const id = visibleHostIds[0]
+ return hostOptions.find((host) => host.id === id)?.label ?? getExecutionHostLabel(id)
+ }
+ return translate(
+ 'auto.components.sidebar.sidebarHostOptions.visibleHostsCount',
+ '{{value0}} hosts',
+ { value0: visibleHostIds.length }
+ )
+}
+
+function ScopeFilterChip({
+ label,
+ clearLabel,
+ onClear
+}: {
+ label: string
+ clearLabel: string
+ onClear: () => void
+}): React.JSX.Element {
+ return (
+
+ {label}
+
+
+ )
+}
+
+/**
+ * Dismissible chips naming the active persisted scope.
+ * Why always shown while a scope is active: the filter survives restarts, so an
+ * invisible one would silently hide running agents from a monitoring surface.
+ *
+ * Why the outer/inner split: this stays mounted on every activity surface, so
+ * while no scope is set it must subscribe only to the two filter fields — the
+ * host-registry derivation (settings, SSH/runtime status churn) lives in the
+ * inner row and mounts only for an active filter.
+ */
+export function ActivityScopeFilterChips(): React.JSX.Element | null {
+ const agentsVisibleHostIds = useAppStore((s) => s.agentsVisibleHostIds)
+ const agentsFilterRepoIds = useAppStore((s) => s.agentsFilterRepoIds)
+ if (agentsVisibleHostIds === null && agentsFilterRepoIds.length === 0) {
+ return null
+ }
+ return
+}
+
+function ActiveScopeFilterChipsRow(): React.JSX.Element | null {
+ const repos = useAppStore((s) => s.repos)
+ const agentsVisibleHostIds = useAppStore((s) => s.agentsVisibleHostIds)
+ const setAgentsVisibleHostIds = useAppStore((s) => s.setAgentsVisibleHostIds)
+ const agentsFilterRepoIds = useAppStore((s) => s.agentsFilterRepoIds)
+ const setAgentsFilterRepoIds = useAppStore((s) => s.setAgentsFilterRepoIds)
+ const { hostOptions } = useSidebarHostScopeOptions()
+
+ const selectedRepoNames = useMemo(
+ () =>
+ repos.filter((repo) => agentsFilterRepoIds.includes(repo.id)).map((repo) => repo.displayName),
+ [repos, agentsFilterRepoIds]
+ )
+ const hasHostFilter = agentsVisibleHostIds !== null
+ const hasRepoFilter = selectedRepoNames.length > 0
+ // Why: a repo filter of only-stale ids renders nothing; the outer gate is a fast path, not the authority.
+ if (!hasHostFilter && !hasRepoFilter) {
+ return null
+ }
+ const repoLabel =
+ selectedRepoNames.length === 1
+ ? selectedRepoNames[0]
+ : translate(
+ 'auto.components.sidebar.SidebarRepositoryFilterSection.selectedProjectsCount',
+ '{{value0}} projects',
+ { value0: selectedRepoNames.length }
+ )
+ return (
+