diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml
index c17ad60d5d3..50062161da6 100644
--- a/.github/workflows/pr.yml
+++ b/.github/workflows/pr.yml
@@ -897,6 +897,9 @@ jobs:
contents: read
uses: ./.github/workflows/e2e.yml
with:
+ # The synthetic pull-request merge ref can disappear while this reusable
+ # workflow is queued. The head SHA is immutable and works for every PR.
+ ref: ${{ github.event.pull_request.head.sha }}
test_files: ${{ needs.e2e-paths.outputs.test_files }}
ssh_source_changed: ${{ needs.e2e-paths.outputs.ssh_source_changed }}
diff --git a/config/scripts/pr-e2e-gate-contract.test.mjs b/config/scripts/pr-e2e-gate-contract.test.mjs
index 232780c6941..ceac6b8cc6e 100644
--- a/config/scripts/pr-e2e-gate-contract.test.mjs
+++ b/config/scripts/pr-e2e-gate-contract.test.mjs
@@ -114,6 +114,7 @@ describe('PR E2E gate contract', () => {
expect(prWorkflow.jobs['e2e-paths'].outputs.test_files).toBe(
'${{ steps.filter.outputs.test_files }}'
)
+ expect(prWorkflow.jobs.e2e.with.ref).toBe('${{ github.event.pull_request.head.sha }}')
expect(prWorkflow.jobs.e2e.with.test_files).toBe('${{ needs.e2e-paths.outputs.test_files }}')
})
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/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/reference/windows-edr-posture.md b/docs/reference/windows-edr-posture.md
index d44aaf33e38..06eb2d5ff9b 100644
--- a/docs/reference/windows-edr-posture.md
+++ b/docs/reference/windows-edr-posture.md
@@ -72,12 +72,20 @@ behavioural engine can be expected to score it low.
### Every process gets a handle, on a timer
-`src/main/windows/windows-process-table.ts` takes a Toolhelp32 snapshot under one
-of two flag sets. Identity (`None | CreationTime`) answers pid/ppid/name from the
-snapshot alone and opens nothing; the detailed set adds `CommandLine`, which
-costs one `OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION)` per process. `Memory`
-is retired — it took a second handle carrying `PROCESS_VM_READ` and never read
-through it.
+`src/main/windows/windows-process-table.ts` takes a Toolhelp32 snapshot under
+**one** flag set, `CommandLine | CreationTime`, shared by every caller. pid, ppid
+and name come out of the snapshot itself and open nothing. `CommandLine` is what
+opens a handle: the addon calls `GetProcessCommandLine` per process, which opens
+`PROCESS_QUERY_INFORMATION | PROCESS_VM_READ` and walks the PEB with three
+`ReadProcessMemory` calls (`src/process_commandline.cc:32,41-47` in the vendored
+`@vscode/windows-process-tree` 0.8.0 source that `config/patches/` patches).
+
+`Memory` is retired as of this change, and that is a real reduction: it made
+`GetProcessMemoryUsage` open a **second** `PROCESS_QUERY_INFORMATION |
+PROCESS_VM_READ` handle per process for a `GetProcessMemoryInfo` call whose
+result no caller read (`src/process.cc:47-63`). Dropping it halves the handles
+opened per snapshot. It does not remove the remote memory read, because the
+command line still performs one.
It exists because seven independent readers used to fork `powershell.exe` for a
`Get-CimInstance Win32_Process` scan. That cost, measured: a PowerShell
@@ -90,23 +98,41 @@ panes multiplied it (#15036). The native snapshot answers the same question in
See
[`windows-process-enumeration.md`](./windows-process-enumeration.md).
-Asking for fewer fields is cheaper, and since the split the module does: one
-cache per flag set, so teardown identity and the session owner probe open no
-handle at all (6.3 ms p50) while only the callers that read a command line pay
-for one (12.3 ms p50, at 492 processes). Each cache still single-flights within
-itself, and one gate serializes the native reads because the vendored wrapper
-coalesces the flags of two overlapping calls.
+Asking for fewer fields is cheaper, and the module now asks for the smallest set
+that still answers every caller. There is **no** per-flag-set cache split: one
+TTL-cached snapshot serves everyone, deliberately, because a split would restore
+the per-pane fan-out the cache exists to remove — a 32-wide teardown has to
+collapse into one scan. So the cheap identity-only read is not something any
+caller can select; every read pays for `CommandLine`. An earlier revision of this
+file described a two-cache design with 6.3 ms / 12.3 ms p50 figures at 492
+processes. That design is not in the tree and those numbers describe no code
+path here; the figures that do apply are the module's own, in
+[`windows-process-enumeration.md`](./windows-process-enumeration.md).
**How an EDR reads it:** a cross-process handle plus a remote memory read against
every process on the box, repeating on a cadence, is the read half of the
telemetry that credential dumping and process injection produce. MDE surfaced it
-as "suspicious memory activity". The memory read is gone: the command line now
-comes from the kernel, through `NtQueryInformationProcess`'s
-`ProcessCommandLineInformation` class, which needs only
-`PROCESS_QUERY_LIMITED_INFORMATION`. `ReadProcessMemory` is absent from the
-compiled addon, asserted against the binary's import table because the published
-prebuild loads fine and emits byte-identical strings. What is left to declare to
-administrators is the per-process handle itself.
+as "suspicious memory activity".
+
+**That signal is still present.** An earlier revision of this file claimed the
+command line "now comes from the kernel" through `NtQueryInformationProcess`'s
+`ProcessCommandLineInformation` class, needing only
+`PROCESS_QUERY_LIMITED_INFORMATION`, and that `ReadProcessMemory` was absent from
+the compiled addon. None of that is true of the code we ship.
+`process_commandline.cc` calls `NtQueryInformationProcess` with
+`ProcessBasicInformation` only — to locate the PEB — and then issues three
+`ReadProcessMemory` calls against a `PROCESS_VM_READ` handle to read the PEB, the
+`RTL_USER_PROCESS_PARAMETERS`, and the command-line buffer. Nothing asserts an
+import table, and no such assertion would pass.
+
+What this change did remove is the `Memory` flag's second handle and its
+`GetProcessMemoryInfo` call, so the per-process handle count per snapshot halves.
+What remains to declare to administrators is unchanged in kind: one
+`PROCESS_QUERY_INFORMATION | PROCESS_VM_READ` handle and a PEB read against every
+process on the box, at the shared snapshot's cadence. Moving to
+`ProcessCommandLineInformation` (Windows 8.1+, `PROCESS_QUERY_LIMITED_INFORMATION`
+only) would genuinely retire the remote read, but it is an addon patch nobody has
+written; treat it as unclaimed work, not as shipped.
### Encoded, policy-bypassing PowerShell
diff --git a/docs/reference/windows-process-enumeration.md b/docs/reference/windows-process-enumeration.md
index ef1faf5237c..87ac2a97fb1 100644
--- a/docs/reference/windows-process-enumeration.md
+++ b/docs/reference/windows-process-enumeration.md
@@ -40,6 +40,15 @@ Measured on Windows 11 with 1050 processes (p50 / p95):
| + memory + command line | 30.6 ms | 33.7 ms |
| `Get-CimInstance` via PowerShell | 706 ms | 723 ms |
+Those are the module's published figures. The flag set this module actually
+requests is `CommandLine | CreationTime` — **not** `Memory`, which cost a second
+`OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ)` plus
+`GetProcessMemoryInfo` per process (`src/process.cc:47-63`) for a value nothing
+read. Dropping it halves the handles a snapshot opens. The remaining set sits
+between the two rows above and has not been measured separately; on a real
+Windows host, `Get-Counter '\Process(Orca)\Handle Count'` sampled across a
+snapshot cadence is the check.
+
Those CIM numbers are from a 1050-process host. The scan scales with process
count: on a 1486-process Windows SSH host it measured **1.36 s** and produced
**4.8 MiB** of JSON, against the fallback's 3 s and 8 MiB limits. Both limits
@@ -220,11 +229,13 @@ ownership, and CPU accounting in the memory collector — still reads it through
its own query. Those callers are not migrated.
Committed private bytes have no equivalent either, and the one memory value the
-snapshot does carry is unusable for the sizes Orca now sees: `process.cc` stores
+snapshot _can_ carry is unusable for the sizes Orca now sees: `process.cc` stores
`pmc.WorkingSetSize` into a `DWORD`, so anything above 4 GB wraps. That is the
second reason `windows-process-resource-collector.ts` still runs its own
`Get-CimInstance` sweep — it needs `PageFileUsage` (commit) and the CPU-time
-counters in the same pass. Migrating it to the native table would cost both.
+counters in the same pass. Migrating it to the native table would cost both, and
+it is why this module no longer sets the `Memory` flag at all: the field had no
+reader, and asking for it opened a handle per process on every snapshot.
Start time is a proxy for identity, not identity. The durable answer for the
process trees Orca itself spawns is an inherited handle: a job object names the
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/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
Older versions: [GitHub Releases](https://github.com/stablyai/orca/releases).
@@ -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).
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/durable-file-write.ts b/src/main/durable-file-write.ts
index 2f40b0881e8..83b0eabc5ed 100644
--- a/src/main/durable-file-write.ts
+++ b/src/main/durable-file-write.ts
@@ -187,10 +187,15 @@ export async function removeStaleDurableWriteTempFiles(
}
/** Synchronous counterpart for quit and crash paths that cannot await. */
-export function writeFileDurableSync(tmpPath: string, finalPath: string, payload: string): void {
+export function writeFileDurableSync(
+ tmpPath: string,
+ finalPath: string,
+ payload: string | Uint8Array
+): void {
let renamed = false
try {
- writeFileSync(tmpPath, payload, 'utf-8')
+ // A Uint8Array payload is written verbatim; a string still defaults to UTF-8.
+ writeFileSync(tmpPath, payload)
const fd = openSync(tmpPath, 'r+')
try {
fsyncSync(fd)
diff --git a/src/main/git/exact-ref-probe.ts b/src/main/git/exact-ref-probe.ts
index 96bb421b8e8..ce89fac3f69 100644
--- a/src/main/git/exact-ref-probe.ts
+++ b/src/main/git/exact-ref-probe.ts
@@ -157,7 +157,11 @@ export async function probeAnyExactRefBatched(
} catch {
return { found: false, unknown: true }
}
- const lines = stdout.split('\n').filter((line) => line.trim().length > 0)
+ // Trim per line so a CRLF-translating host's `\r` does not become part of the type.
+ const lines = stdout
+ .split('\n')
+ .map((line) => line.trim())
+ .filter((line) => line.length > 0)
// One line per input, in order; a short read means the batch never answered for the rest.
if (lines.length !== safeRefs.length) {
return { found: false, unknown: true }
diff --git a/src/main/git/remote.test.ts b/src/main/git/remote.test.ts
index 11ac2c21264..feb237eb18a 100644
--- a/src/main/git/remote.test.ts
+++ b/src/main/git/remote.test.ts
@@ -193,6 +193,17 @@ describe('git remote operations', () => {
if (args[0] === 'remote' && args[1] === 'get-url' && args[2] === 'pr-pynickle-orca') {
return { stdout: 'https://github.com/pynickle/orca.git\n', stderr: '' }
}
+ if (args[0] === 'remote' && args[1] === '-v') {
+ return {
+ stdout: [
+ 'origin\thttps://github.com/stablyai/orca.git (fetch)',
+ 'origin\thttps://github.com/stablyai/orca.git (push)',
+ 'pr-pynickle-orca\thttps://github.com/pynickle/orca.git (fetch)',
+ 'pr-pynickle-orca\thttps://github.com/pynickle/orca.git (push)'
+ ].join('\n'),
+ stderr: ''
+ }
+ }
if (args[0] === 'remote') {
return { stdout: 'origin\npr-pynickle-orca\n', stderr: '' }
}
@@ -207,6 +218,54 @@ describe('git remote operations', () => {
)
})
+ // Regression: normalizing a URL-valued push remote used to run `git remote` and then a
+ // serial `git remote get-url` per remote -- 59 subprocesses on a 58-remote repo.
+ it('normalizes a URL-valued push remote from one remote table read at 58 remotes', async () => {
+ const remotes = [
+ { name: 'origin', url: 'https://github.com/stablyai/orca.git' },
+ ...Array.from({ length: 56 }, (_, index) => ({
+ name: `pr-user${index}-orca`,
+ url: `https://github.com/user${index}/orca.git`
+ })),
+ { name: 'pr-pynickle-orca', url: 'https://github.com/pynickle/orca.git' }
+ ]
+ gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
+ if (args[0] === 'symbolic-ref') {
+ return { stdout: 'imp/chinese-translation\n', stderr: '' }
+ }
+ if (args[0] === 'config' && args.includes('branch.imp/chinese-translation.remote')) {
+ return { stdout: 'https://github.com/pynickle/orca.git\n', stderr: '' }
+ }
+ if (args[0] === 'config' && args.includes('branch.imp/chinese-translation.merge')) {
+ return { stdout: 'refs/heads/imp/chinese-translation\n', stderr: '' }
+ }
+ if (args[0] === 'config') {
+ throw new Error(`config key is not set: ${args.join(' ')}`)
+ }
+ if (args[0] === 'remote' && args[1] === '-v') {
+ return {
+ stdout: remotes
+ .flatMap(({ name, url }) => [`${name}\t${url} (fetch)`, `${name}\t${url} (push)`])
+ .join('\n'),
+ stderr: ''
+ }
+ }
+ if (args[0] === 'remote') {
+ throw new Error(`unexpected remote scan: ${args.join(' ')}`)
+ }
+ return { stdout: '', stderr: '' }
+ })
+
+ await gitPush('/repo', false)
+
+ const remoteReads = gitExecFileAsyncMock.mock.calls.filter(([args]) => args[0] === 'remote')
+ expect(remoteReads.map(([args]) => args)).toEqual([['remote', '-v']])
+ expect(gitExecFileAsyncMock).toHaveBeenLastCalledWith(
+ ['push', '--set-upstream', 'pr-pynickle-orca', 'HEAD:imp/chinese-translation'],
+ { cwd: '/repo' }
+ )
+ })
+
it('uses an explicit push target even when it differs from the local branch name', async () => {
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: '', stderr: '' })
diff --git a/src/main/git/remote.ts b/src/main/git/remote.ts
index 2baf3b77137..20cf8415d04 100644
--- a/src/main/git/remote.ts
+++ b/src/main/git/remote.ts
@@ -4,6 +4,7 @@ import {
} from '../../shared/git-remote-error'
import { resolveEffectiveGitUpstream } from '../../shared/git-effective-upstream'
import { gitRefTargetsBranchOnRemote } from '../../shared/git-remote-branch-name'
+import { findGitRemoteNameByFetchUrl } from '../../shared/git-remote-url-index'
import type { GitPushTarget } from '../../shared/worktree/types'
import type { GitRuntimeOptions } from './git-runtime-options'
import { gitOptionsForWorktree } from './git-runtime-options'
@@ -84,6 +85,8 @@ type ConfiguredPushRemote = {
branchRemote: string | null
}
+// One `git remote -v` instead of `git remote` plus a serial `git remote get-url`
+// per remote; both print the same insteadOf-expanded fetch URL.
async function findRemoteNameForUrl(
worktreePath: string,
remoteUrl: string,
@@ -91,30 +94,13 @@ async function findRemoteNameForUrl(
): Promise {
try {
const { stdout } = await gitExecFileAsync(
- ['remote'],
+ ['remote', '-v'],
gitOptionsForWorktree(worktreePath, options)
)
- const remotes = stdout
- .split(/\r?\n/)
- .map((line) => line.trim())
- .filter(Boolean)
- for (const remoteName of remotes) {
- try {
- const { stdout: urlStdout } = await gitExecFileAsync(
- ['remote', 'get-url', remoteName],
- gitOptionsForWorktree(worktreePath, options)
- )
- if (urlStdout.trim() === remoteUrl) {
- return remoteName
- }
- } catch {
- // Ignore a remote that disappeared or has no fetch URL.
- }
- }
+ return findGitRemoteNameByFetchUrl(stdout, (candidateUrl) => candidateUrl === remoteUrl)
} catch {
return null
}
- return null
}
async function normalizePushRemote(
@@ -141,11 +127,17 @@ async function getConfiguredPushRemote(
if (!remote) {
return null
}
+ const normalizedRemote = await normalizePushRemote(worktreePath, remote, options)
+ // The two usually name the same URL; resolving it twice reads the remote table twice.
+ if (!branchRemote) {
+ return { remote: normalizedRemote, branchRemote: null }
+ }
return {
- remote: await normalizePushRemote(worktreePath, remote, options),
- branchRemote: branchRemote
- ? await normalizePushRemote(worktreePath, branchRemote, options)
- : null
+ remote: normalizedRemote,
+ branchRemote:
+ branchRemote === remote
+ ? normalizedRemote
+ : await normalizePushRemote(worktreePath, branchRemote, options)
}
}
diff --git a/src/main/git/repo-branch-conflict-batched-probe.test.ts b/src/main/git/repo-branch-conflict-batched-probe.test.ts
new file mode 100644
index 00000000000..e6e672c65a2
--- /dev/null
+++ b/src/main/git/repo-branch-conflict-batched-probe.test.ts
@@ -0,0 +1,102 @@
+// Why: the batched `cat-file --batch-check` conflict probe decides from stdout, so a
+// WSL login-shell fallback that prints the distro banner onto that stream desynchronizes
+// the one-line-per-ref contract. Every batch then came back undecided and fell through to
+// one `show-ref` subprocess per remote -- the cost the batch exists to remove. These tests
+// pin the fence request and the resulting subprocess count at 58 remotes.
+
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { gitExecFileAsyncMock } = vi.hoisted(() => ({ gitExecFileAsyncMock: vi.fn() }))
+
+vi.mock('./runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock }))
+
+import { getBranchConflictKind } from './repo-branch-conflict'
+
+const REMOTES = Array.from({ length: 58 }, (_, index) => `r${index}`)
+const BRANCH = 'user/feature'
+const WSL_BANNER =
+ 'Welcome to Ubuntu 24.04.1 LTS (GNU/Linux 5.15.167.4-microsoft-standard-WSL2 x86_64)\n' +
+ 'To run a command as administrator (user "root"), use "sudo ".\n'
+
+type GitExecOptions = { stdin?: string; captureWslLoginShellOutput?: boolean }
+
+/**
+ * Stand-in for a WSL-routed runner: the login shell prepends its banner to stdout unless
+ * the caller asked for the fenced form, which slices the payload back out.
+ */
+function installLoginShellRunner(): { argv: string[][] } {
+ const argv: string[][] = []
+ gitExecFileAsyncMock.mockImplementation(async (args: string[], options: GitExecOptions = {}) => {
+ argv.push(args)
+ if (args[0] === 'rev-parse') {
+ throw new Error('local branch is absent')
+ }
+ if (args[0] === 'remote') {
+ return { stdout: `${WSL_BANNER}${REMOTES.join('\n')}\n`, stderr: '' }
+ }
+ if (args[0] === 'show-ref') {
+ throw Object.assign(new Error('missing ref'), { code: 1, stderr: '' })
+ }
+ if (args[0] === 'cat-file') {
+ const payload = `${(options.stdin ?? '')
+ .split('\n')
+ .filter(Boolean)
+ .map((ref) => `${ref} missing`)
+ .join('\n')}\n`
+ return {
+ stdout: options.captureWslLoginShellOutput ? payload : `${WSL_BANNER}${payload}`,
+ stderr: ''
+ }
+ }
+ throw new Error(`unexpected git command: ${args.join(' ')}`)
+ })
+ return { argv }
+}
+
+function countSubcommand(argv: readonly string[][], subcommand: string): number {
+ return argv.filter((args) => args[0] === subcommand).length
+}
+
+describe('getBranchConflictKind batched remote probe', () => {
+ beforeEach(() => {
+ gitExecFileAsyncMock.mockReset()
+ })
+
+ it('asks the WSL login shell to fence the batch payload it parses', async () => {
+ installLoginShellRunner()
+
+ await getBranchConflictKind('/repo', BRANCH)
+
+ const batchCall = gitExecFileAsyncMock.mock.calls.find(([args]) => args[0] === 'cat-file')
+ expect(batchCall?.[1]).toMatchObject({ captureWslLoginShellOutput: true })
+ })
+
+ it('answers from one batched subprocess instead of one show-ref per remote', async () => {
+ const { argv } = installLoginShellRunner()
+
+ await expect(getBranchConflictKind('/repo', BRANCH)).resolves.toBeNull()
+
+ expect(countSubcommand(argv, 'cat-file')).toBe(1)
+ expect(countSubcommand(argv, 'show-ref')).toBe(0)
+ })
+
+ it('still falls back to per-ref probes when the batch itself fails', async () => {
+ gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
+ if (args[0] === 'rev-parse') {
+ throw new Error('local branch is absent')
+ }
+ if (args[0] === 'remote') {
+ return { stdout: `${REMOTES.join('\n')}\n`, stderr: '' }
+ }
+ if (args[0] === 'cat-file') {
+ throw new Error('cat-file is unavailable on this host')
+ }
+ if (args[0] === 'show-ref') {
+ return { stdout: '', stderr: '' }
+ }
+ throw new Error(`unexpected git command: ${args.join(' ')}`)
+ })
+
+ await expect(getBranchConflictKind('/repo', BRANCH)).resolves.toBe('remote')
+ })
+})
diff --git a/src/main/git/repo-branch-conflict.ts b/src/main/git/repo-branch-conflict.ts
index c799d3770be..5c6e03b94b0 100644
--- a/src/main/git/repo-branch-conflict.ts
+++ b/src/main/git/repo-branch-conflict.ts
@@ -147,10 +147,12 @@ export function getBranchConflictKind(
const execOptions = gitExecOptions(path, options)
const runLocalGit = (
argv: string[],
- commandOptions?: ExactRefProbeExecOptions & { stdin?: string }
+ commandOptions?: ExactRefProbeExecOptions & { stdin?: string },
+ captureWslLoginShellOutput = false
): Promise<{ stdout: string }> =>
gitExecFileAsync(argv, {
...execOptions,
+ ...(captureWslLoginShellOutput ? { captureWslLoginShellOutput: true } : {}),
...(commandOptions?.maxBuffer === undefined ? {} : { maxBuffer: commandOptions.maxBuffer }),
...(commandOptions?.timeoutMs === undefined ? {} : { timeout: commandOptions.timeoutMs }),
...(commandOptions?.stdin === undefined ? {} : { stdin: commandOptions.stdin })
@@ -160,7 +162,13 @@ export function getBranchConflictKind(
branchName,
allowedBaseRef,
{},
- (argv, commandOptions) => runLocalGit(argv, commandOptions)
+ // Why fenced: the batch decides from stdout, and a WSL login-shell fallback writes
+ // the distro's rc/motd banner to that same stream. The extra lines break the
+ // one-line-per-ref contract, so every batch came back undecided and fell through to
+ // one `show-ref` subprocess per remote -- the exact cost the batch exists to remove.
+ // `show-ref --verify --quiet` prints nothing and is read by exit code, so it needs
+ // no fence; the capture wrapper preserves the payload's exit status either way.
+ (argv, commandOptions) => runLocalGit(argv, commandOptions, true)
)
}
diff --git a/src/main/git/upstream.test.ts b/src/main/git/upstream.test.ts
index 701808a8a31..b7644ad5543 100644
--- a/src/main/git/upstream.test.ts
+++ b/src/main/git/upstream.test.ts
@@ -363,6 +363,16 @@ describe('getUpstreamStatus', () => {
if (args[0] === 'remote' && args[1] === 'get-url' && args[2] === 'pr-pynickle-orca') {
return Promise.resolve({ stdout: 'https://github.com/pynickle/orca.git\n' })
}
+ if (args[0] === 'remote' && args[1] === '-v') {
+ return Promise.resolve({
+ stdout: [
+ 'origin\thttps://github.com/stablyai/orca.git (fetch)',
+ 'origin\thttps://github.com/stablyai/orca.git (push)',
+ 'pr-pynickle-orca\thttps://github.com/pynickle/orca.git (fetch)',
+ 'pr-pynickle-orca\thttps://github.com/pynickle/orca.git (push)'
+ ].join('\n')
+ })
+ }
if (args[0] === 'remote') {
return Promise.resolve({ stdout: 'origin\npr-pynickle-orca\n' })
}
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/ipc/worktree-push-target-reconciliation.ts b/src/main/ipc/worktree-push-target-reconciliation.ts
index e59da7bdfe7..b28ae36b6f4 100644
--- a/src/main/ipc/worktree-push-target-reconciliation.ts
+++ b/src/main/ipc/worktree-push-target-reconciliation.ts
@@ -13,7 +13,7 @@ import { listWorktrees } from '../git/worktree'
import type { SshGitProvider } from '../providers/ssh-git-provider'
import type { GitPushTarget } from '../../shared/worktree/types'
import { WORKTREE_ID_SEPARATOR, worktreeIdComparisonKey } from '../../shared/worktree/id'
-import { iterateProcessOutputLines } from '../../shared/process-output-field-scanner'
+import { parseGitRemoteFetchUrls } from '../../shared/git-remote-url-index'
import {
findWorktreeMetaReferencingRemote,
hasBranchConfigUsingRemote,
@@ -44,26 +44,9 @@ async function listPrRemoteCandidates(
} catch {
return []
}
- const candidates = new Map()
- for (const line of iterateProcessOutputLines(stdout)) {
- const parsed = parseRemoteVerboseLine(line)
- if (parsed?.direction === 'fetch' && isOrcaGeneratedPrRemoteName(parsed.name)) {
- candidates.set(parsed.name, parsed.url)
- }
- }
- return [...candidates.entries()].map(([name, url]) => ({ name, url }))
-}
-
-function parseRemoteVerboseLine(
- line: string
-): { name: string; url: string; direction: 'fetch' | 'push' } | null {
- const tabIndex = line.indexOf('\t')
- if (tabIndex === -1) {
- return null
- }
- const name = line.slice(0, tabIndex)
- const match = /^(.*) \((fetch|push)\)$/.exec(line.slice(tabIndex + 1).trim())
- return match ? { name, url: match[1], direction: match[2] as 'fetch' | 'push' } : null
+ return [...parseGitRemoteFetchUrls(stdout)]
+ .filter(([name]) => isOrcaGeneratedPrRemoteName(name))
+ .map(([name, url]) => ({ name, url }))
}
async function shouldReclaimPrRemote(
diff --git a/src/main/ipc/worktree-push-target-remote-scan.test.ts b/src/main/ipc/worktree-push-target-remote-scan.test.ts
new file mode 100644
index 00000000000..f2392c5e71a
--- /dev/null
+++ b/src/main/ipc/worktree-push-target-remote-scan.test.ts
@@ -0,0 +1,178 @@
+// Why: `findRemoteForUrl` used to run `git remote` and then one serial
+// `git remote get-url` per remote. These tests pin both halves of the fix: the
+// subprocess count at 58 remotes, and result-for-result parity with the old scan
+// across the remote shapes a real repo produces.
+
+import { describe, expect, it } from 'vitest'
+import { parseGitHubOwnerRepo } from '../github/gh-utils'
+import { findRemoteForUrl } from './worktree-push-target-setup'
+import type { GitRemoteExec } from './worktree-push-target-cleanup'
+
+const SSH_FORK = 'git@github.com:contributor/orca.git'
+const HTTPS_FORK = 'https://github.com/contributor/orca.git'
+const GITLAB_FORK = 'https://gitlab.com/contributor/orca.git'
+const UPSTREAM = 'https://github.com/stablyai/orca.git'
+
+type RemoteRow = { name: string; fetchUrl: string; pushUrl?: string }
+
+type CountingExec = GitRemoteExec & { spawns: string[][] }
+
+function makeExec(remotes: readonly RemoteRow[]): CountingExec {
+ const spawns: string[][] = []
+ const exec: GitRemoteExec = async (args: string[]) => {
+ spawns.push(args)
+ if (args[0] === 'remote' && args.length === 1) {
+ return { stdout: `${remotes.map((remote) => remote.name).join('\n')}\n` }
+ }
+ if (args[0] === 'remote' && args[1] === '-v') {
+ return {
+ stdout: remotes
+ .flatMap((remote) => [
+ `${remote.name}\t${remote.fetchUrl} (fetch)`,
+ `${remote.name}\t${remote.pushUrl ?? remote.fetchUrl} (push)`
+ ])
+ .join('\n')
+ }
+ }
+ if (args[0] === 'remote' && args[1] === 'get-url') {
+ const match = remotes.find((remote) => remote.name === args[2])
+ if (!match) {
+ throw new Error(`No such remote ${args[2]}`)
+ }
+ return { stdout: `${match.fetchUrl}\n` }
+ }
+ throw new Error(`unexpected git command: ${args.join(' ')}`)
+ }
+ return Object.assign(exec, { spawns })
+}
+
+/** The pre-fix scan, kept as the oracle the batched form must reproduce exactly. */
+async function findRemoteForUrlPerRemote(
+ execGit: GitRemoteExec,
+ repoPath: string,
+ remoteUrl: string
+): Promise {
+ const target = parseGitHubOwnerRepo(remoteUrl)
+ try {
+ const { stdout } = await execGit(['remote'], repoPath)
+ for (const remote of stdout
+ .split(/\r?\n/)
+ .map((line) => line.trim())
+ .filter(Boolean)) {
+ try {
+ const { stdout: urlStdout } = await execGit(['remote', 'get-url', remote], repoPath)
+ const candidateUrl = urlStdout.trim()
+ const candidate = parseGitHubOwnerRepo(candidateUrl)
+ if (
+ target &&
+ candidate &&
+ target.owner.toLowerCase() === candidate.owner.toLowerCase() &&
+ target.repo.toLowerCase() === candidate.repo.toLowerCase()
+ ) {
+ return remote
+ }
+ if (candidateUrl === remoteUrl) {
+ return remote
+ }
+ } catch {
+ // Ignore a remote that disappeared or has no fetch URL.
+ }
+ }
+ } catch {
+ return null
+ }
+ return null
+}
+
+const fiftyEightRemotes: RemoteRow[] = [
+ { name: 'origin', fetchUrl: UPSTREAM },
+ ...Array.from({ length: 56 }, (_, index) => ({
+ name: `pr-user${index}-orca`,
+ fetchUrl: `https://github.com/user${index}/orca.git`
+ })),
+ { name: 'pr-contributor-orca', fetchUrl: SSH_FORK }
+]
+
+const matrix: { name: string; remotes: RemoteRow[]; lookupUrl: string }[] = [
+ { name: 'no remotes', remotes: [], lookupUrl: SSH_FORK },
+ {
+ name: 'one matching remote',
+ remotes: [{ name: 'origin', fetchUrl: SSH_FORK }],
+ lookupUrl: SSH_FORK
+ },
+ {
+ name: 'one non-matching remote',
+ remotes: [{ name: 'origin', fetchUrl: UPSTREAM }],
+ lookupUrl: SSH_FORK
+ },
+ { name: '58 remotes, match last', remotes: fiftyEightRemotes, lookupUrl: SSH_FORK },
+ {
+ name: '58 remotes, no match',
+ remotes: fiftyEightRemotes,
+ lookupUrl: 'https://github.com/nobody/other.git'
+ },
+ {
+ name: 'duplicate URLs on two remotes',
+ remotes: [
+ { name: 'origin', fetchUrl: UPSTREAM },
+ { name: 'fork-a', fetchUrl: SSH_FORK },
+ { name: 'fork-b', fetchUrl: SSH_FORK }
+ ],
+ lookupUrl: SSH_FORK
+ },
+ {
+ name: 'fetch and push URLs differ',
+ remotes: [{ name: 'split', fetchUrl: SSH_FORK, pushUrl: HTTPS_FORK }],
+ lookupUrl: SSH_FORK
+ },
+ {
+ name: 'SSH-form lookup against an HTTPS-form remote',
+ remotes: [
+ { name: 'origin', fetchUrl: UPSTREAM },
+ { name: 'fork', fetchUrl: HTTPS_FORK }
+ ],
+ lookupUrl: SSH_FORK
+ },
+ {
+ name: 'HTTPS-form lookup against an SSH-form remote',
+ remotes: [
+ { name: 'origin', fetchUrl: UPSTREAM },
+ { name: 'fork', fetchUrl: SSH_FORK }
+ ],
+ lookupUrl: HTTPS_FORK
+ },
+ {
+ name: 'non-GitHub provider matches only on the exact URL',
+ remotes: [{ name: 'gitlab-fork', fetchUrl: GITLAB_FORK }],
+ lookupUrl: GITLAB_FORK
+ },
+ {
+ name: 'non-GitHub provider with a different host does not match',
+ remotes: [{ name: 'gitlab-fork', fetchUrl: GITLAB_FORK }],
+ lookupUrl: 'https://bitbucket.org/contributor/orca.git'
+ }
+]
+
+describe('findRemoteForUrl', () => {
+ it.each(matrix)('matches the per-remote scan for $name', async ({ remotes, lookupUrl }) => {
+ const expected = await findRemoteForUrlPerRemote(makeExec(remotes), '/repo', lookupUrl)
+ await expect(findRemoteForUrl(makeExec(remotes), '/repo', lookupUrl)).resolves.toBe(expected)
+ })
+
+ it('answers from one subprocess at 58 remotes instead of one per remote', async () => {
+ const legacyExec = makeExec(fiftyEightRemotes)
+ await findRemoteForUrlPerRemote(legacyExec, '/repo', 'https://github.com/nobody/other.git')
+ expect(legacyExec.spawns).toHaveLength(fiftyEightRemotes.length + 1)
+
+ const exec = makeExec(fiftyEightRemotes)
+ await findRemoteForUrl(exec, '/repo', 'https://github.com/nobody/other.git')
+ expect(exec.spawns).toEqual([['remote', '-v']])
+ })
+
+ it('returns null when the remote table cannot be read', async () => {
+ const failing: GitRemoteExec = async () => {
+ throw new Error('not a git repository')
+ }
+ await expect(findRemoteForUrl(failing, '/repo', SSH_FORK)).resolves.toBeNull()
+ })
+})
diff --git a/src/main/ipc/worktree-push-target-setup.test.ts b/src/main/ipc/worktree-push-target-setup.test.ts
index 2d9670c2f9b..ef1e44aa923 100644
--- a/src/main/ipc/worktree-push-target-setup.test.ts
+++ b/src/main/ipc/worktree-push-target-setup.test.ts
@@ -16,6 +16,13 @@ const REPO = '/repo-root'
const FORK_SSH = 'git@github.com:contributor/orca.git'
const FORK_HTTPS = 'https://github.com/contributor/orca.git'
+/** Real `git remote -v` shape: a fetch row and a push row per remote, tab-separated. */
+export function renderRemoteVerbose(remotes: Record): string {
+ return Object.entries(remotes)
+ .flatMap(([name, url]) => [`${name}\t${url} (fetch)`, `${name}\t${url} (push)`])
+ .join('\n')
+}
+
// A stateful fake git: `remotes` maps name -> url. `remote add` mutates it so
// later lookups see the new remote, matching real git behavior. Defaults
// `symbolic-ref --short HEAD` to a real branch name, since a worktree's HEAD
@@ -31,6 +38,9 @@ function makeRepoExec(
if (args[0] === 'remote' && args.length === 1) {
return { stdout: Object.keys(remotes).join('\n'), stderr: '' }
}
+ if (args[0] === 'remote' && args[1] === '-v' && args.length === 2) {
+ return { stdout: renderRemoteVerbose(remotes), stderr: '' }
+ }
if (args[0] === 'remote' && args[1] === 'get-url') {
const url = remotes[args[2]!]
if (!url) {
diff --git a/src/main/ipc/worktree-push-target-setup.ts b/src/main/ipc/worktree-push-target-setup.ts
index 59ef4c8fea5..c4a82d94933 100644
--- a/src/main/ipc/worktree-push-target-setup.ts
+++ b/src/main/ipc/worktree-push-target-setup.ts
@@ -5,53 +5,33 @@
// repo. The store-aware ownership decision stays with the caller via a predicate.
import type { GitPushTarget } from '../../shared/worktree/types'
-import { parseGitHubOwnerRepo } from '../github/gh-utils'
-import type { GitRemoteExec } from './worktree-push-target-cleanup'
+import { findGitRemoteNameByFetchUrl } from '../../shared/git-remote-url-index'
+import { sameGitHubRemoteUrl, type GitRemoteExec } from './worktree-push-target-cleanup'
import {
buildNarrowForkFetchRefspec,
ensureRemoteTracksBranchNarrowly
} from '../git/fork-remote-refspec'
+// One `git remote -v` replaces `git remote` plus a serial `git remote get-url` per
+// remote -- 59 subprocesses at 58 remotes, on every push-target resolution (#17914).
export async function findRemoteForUrl(
execGit: GitRemoteExec,
repoPath: string,
remoteUrl: string
): Promise {
- const target = parseGitHubOwnerRepo(remoteUrl)
try {
- const { stdout } = await execGit(['remote'], repoPath)
- for (const remote of stdout
- .split(/\r?\n/)
- .map((line) => line.trim())
- .filter(Boolean)) {
- try {
- const { stdout: urlStdout } = await execGit(['remote', 'get-url', remote], repoPath)
- const candidateUrl = urlStdout.trim()
- const candidate = parseGitHubOwnerRepo(candidateUrl)
- if (
- target &&
- candidate &&
- target.owner.toLowerCase() === candidate.owner.toLowerCase() &&
- target.repo.toLowerCase() === candidate.repo.toLowerCase()
- ) {
- return remote
- }
- if (candidateUrl === remoteUrl) {
- return remote
- }
- } catch {
- // Ignore a remote that disappeared or has no fetch URL.
- }
- }
+ const { stdout } = await execGit(['remote', '-v'], repoPath)
+ return findGitRemoteNameByFetchUrl(stdout, (candidateUrl) =>
+ sameGitHubRemoteUrl(candidateUrl, remoteUrl)
+ )
} catch {
return null
}
- return null
}
// O(1) probe used before materializing on demand (push/pull/fetch/fast-forward):
-// a single `remote get-url ` avoids the O(remotes) `findRemoteForUrl` scan
-// once a fork remote already exists under its expected name (#17828).
+// a single `remote get-url ` skips the whole-remote-table read once a fork
+// remote already exists under its expected name (#17828).
export async function remoteAlreadyMatchesUrl(
execGit: GitRemoteExec,
repoPath: string,
@@ -60,18 +40,7 @@ export async function remoteAlreadyMatchesUrl(
): Promise {
try {
const { stdout } = await execGit(['remote', 'get-url', remoteName], repoPath)
- const candidateUrl = stdout.trim()
- if (candidateUrl === remoteUrl) {
- return true
- }
- const target = parseGitHubOwnerRepo(remoteUrl)
- const candidate = parseGitHubOwnerRepo(candidateUrl)
- return Boolean(
- target &&
- candidate &&
- target.owner.toLowerCase() === candidate.owner.toLowerCase() &&
- target.repo.toLowerCase() === candidate.repo.toLowerCase()
- )
+ return sameGitHubRemoteUrl(stdout.trim(), remoteUrl)
} catch {
return false
}
diff --git a/src/main/ipc/worktree-remote-push-target-materialization.test.ts b/src/main/ipc/worktree-remote-push-target-materialization.test.ts
index 68ed98ab35c..695baa10dec 100644
--- a/src/main/ipc/worktree-remote-push-target-materialization.test.ts
+++ b/src/main/ipc/worktree-remote-push-target-materialization.test.ts
@@ -553,6 +553,17 @@ describe('materializeWorktreePushTargetRemoteSsh', () => {
}
throw new Error('No such remote')
}
+ if (args[0] === 'remote' && args[1] === '-v') {
+ return {
+ stdout: [
+ 'origin\thttps://github.com/stablyai/orca.git (fetch)',
+ 'origin\thttps://github.com/stablyai/orca.git (push)',
+ `${SIBLING_REMOTE}\t${FORK_URL} (fetch)`,
+ `${SIBLING_REMOTE}\t${FORK_URL} (push)`
+ ].join('\n'),
+ stderr: ''
+ }
+ }
if (args[0] === 'remote' && args.length === 1) {
return { stdout: `origin\n${SIBLING_REMOTE}\n`, stderr: '' }
}
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/normalize-loaded-profile-state.ts b/src/main/persistence/loading-store/normalize-loaded-profile-state.ts
index 2286e40d54b..39d625c525a 100644
--- a/src/main/persistence/loading-store/normalize-loaded-profile-state.ts
+++ b/src/main/persistence/loading-store/normalize-loaded-profile-state.ts
@@ -35,6 +35,8 @@ export function normalizeLoadedProfileState(
const { defaults, migratedExternalVisibility, osc52ClipboardNoticePending } = terminal
const { normalizedOnboarding, normalizedProjectGroups, loadedCompactWorktreeCards } = profile
const projectCatalog = normalizeLoadedProjectCatalog(parsed, markNeedsSave)
+ // Ordered: the host partitions drop the global fields this slice already owns.
+ const workspaceSession = normalizeLoadedLocalSession(parsed, defaults, markNeedsSave)
return {
...defaults,
@@ -69,9 +71,14 @@ export function normalizeLoadedProfileState(
markNeedsSave
),
// Why: volatile schema; zod-validate workspaceSession at read so a bad payload falls to defaults, not a renderer crash.
- workspaceSession: normalizeLoadedLocalSession(parsed, defaults, markNeedsSave),
+ workspaceSession,
// Why: per-host session partitions, validated independently; 'local' stays in workspaceSession for downgrade compat.
- workspaceSessionsByHostId: normalizeLoadedHostSessions(parsed, defaults, markNeedsSave),
+ workspaceSessionsByHostId: normalizeLoadedHostSessions(
+ parsed,
+ defaults,
+ workspaceSession,
+ markNeedsSave
+ ),
sshTargets: (parsed.sshTargets ?? []).map(normalizeSshTarget),
deletedSshConfigAliases: Array.isArray(parsed.deletedSshConfigAliases)
? parsed.deletedSshConfigAliases.filter((alias): alias is string => typeof alias === 'string')
diff --git a/src/main/persistence/loading-store/normalize-loaded-state-collections.ts b/src/main/persistence/loading-store/normalize-loaded-state-collections.ts
index 2b133d486b7..16c5b424def 100644
--- a/src/main/persistence/loading-store/normalize-loaded-state-collections.ts
+++ b/src/main/persistence/loading-store/normalize-loaded-state-collections.ts
@@ -41,11 +41,13 @@ export function normalizeLoadedLocalSession(
export function normalizeLoadedHostSessions(
parsed: PersistedState,
defaults: PersistedState,
+ localSession: WorkspaceSessionState,
markNeedsSave: () => void
): PersistedState['workspaceSessionsByHostId'] {
const { partitions, repaired } = parseWorkspaceSessionsByHostId(
parsed.workspaceSessionsByHostId,
- defaults.workspaceSession
+ defaults.workspaceSession,
+ localSession
)
if (repaired) {
// Why: salvage repairs only the in-memory partitions; without a save the corrupt entries stay on disk and get re-dropped every launch.
diff --git a/src/main/persistence/loading-store/primary-state-writes.ts b/src/main/persistence/loading-store/primary-state-writes.ts
index 9d6e476ebe5..c112b4a9ba0 100644
--- a/src/main/persistence/loading-store/primary-state-writes.ts
+++ b/src/main/persistence/loading-store/primary-state-writes.ts
@@ -161,7 +161,8 @@ export async function writeToDiskAsync(owner: PrimaryStateWriteOperations): Prom
// Why: fsync before rename, then fsync the directory; see writeFileDurable.
const handle = await open(tmpFile, 'w')
try {
- await handle.writeFile(payload, 'utf-8')
+ // Already UTF-8 bytes: passing the string here would re-encode the whole state on the main thread.
+ await handle.writeFile(payload)
await handle.sync()
} finally {
await handle.close()
diff --git a/src/main/persistence/loading-store/secret-sentinel-substitution.test.ts b/src/main/persistence/loading-store/secret-sentinel-substitution.test.ts
new file mode 100644
index 00000000000..e58d0280bae
--- /dev/null
+++ b/src/main/persistence/loading-store/secret-sentinel-substitution.test.ts
@@ -0,0 +1,184 @@
+/**
+ * The bar for this change is "the bytes on disk did not move". Every case below runs the exact
+ * loop `applySecretSentinelSubstitutions` replaced — reproduced in `previousImplementation` — and
+ * compares payload bytes and guard hash, because a drifting hash silently disables the no-op write
+ * guard and a drifting payload is corrupted persisted state.
+ */
+import { createHash, randomUUID } from 'node:crypto'
+import { describe, expect, it } from 'vitest'
+import {
+ applySecretSentinelSubstitutions,
+ type SecretSentinelSubstitution
+} from './secret-sentinel-substitution'
+
+/** Verbatim from state-serialization-secret-handling.ts before this change. */
+function previousImplementation(
+ serialized: string,
+ secretSubs: readonly SecretSentinelSubstitution[],
+ degradedPrefix: string
+): { payload: Buffer; stateHash: string } {
+ let payload = serialized
+ let hashInput = serialized
+ for (const { sentinel, blob, hashValue } of secretSubs) {
+ const escapedSentinel = JSON.stringify(sentinel).slice(1, -1)
+ payload = payload.replace(escapedSentinel, () => JSON.stringify(blob).slice(1, -1))
+ hashInput = hashInput.replace(escapedSentinel, () => JSON.stringify(hashValue).slice(1, -1))
+ }
+ const stateHash = createHash('sha1').update(degradedPrefix).update(hashInput).digest('hex')
+ // `handle.writeFile(payload, 'utf-8')` is what turned the string into bytes.
+ return { payload: Buffer.from(payload, 'utf8'), stateHash }
+}
+
+function expectIdenticalToPrevious(
+ serialized: string,
+ subs: readonly SecretSentinelSubstitution[],
+ degradedPrefix = ''
+): void {
+ const before = previousImplementation(serialized, subs, degradedPrefix)
+ const after = applySecretSentinelSubstitutions(serialized, subs, degradedPrefix)
+ expect(after.payload.equals(before.payload)).toBe(true)
+ expect(after.stateHash).toBe(before.stateHash)
+}
+
+function sentinel(): string {
+ return `orca-secret-slot-${randomUUID()}`
+}
+
+describe('applySecretSentinelSubstitutions', () => {
+ it('produces bytes and a hash identical to the previous implementation', () => {
+ const subs: SecretSentinelSubstitution[] = [
+ { sentinel: sentinel(), blob: 'djEwY2lwaGVy', hashValue: 'cookie-value' },
+ {
+ sentinel: sentinel(),
+ // Regex-special *and* JSON-escapable, which is the pair that breaks a naive rewrite:
+ // `$&` would splice the match back in under string-form replace, and the backslash and
+ // quote have to survive `JSON.stringify(...).slice(1, -1)` unchanged.
+ blob: 'A+/=$&$1$`\\x "quoted" |.*?[](){}^',
+ hashValue: 'http://proxy.example:8080/?a=b&c=$&'
+ },
+ { sentinel: sentinel(), blob: '', hashValue: 'https://kagi.com/session?t=abc' }
+ ]
+ const state = {
+ settings: { opencodeSessionCookie: subs[0].sentinel, httpProxyUrl: subs[1].sentinel },
+ ui: { browserKagiSessionLink: subs[2].sentinel },
+ // Adjacent content that must not shift: a near-miss prefix, and JSON escapes either side.
+ noise: ['orca-secret-slot-', 'a\\b"c\n\t', subs[0].sentinel.slice(0, -1)]
+ }
+ expectIdenticalToPrevious(JSON.stringify(state), subs)
+ })
+
+ it('stays identical when the state holds multi-byte and escaped characters', () => {
+ const subs: SecretSentinelSubstitution[] = [
+ { sentinel: sentinel(), blob: 'blob-é', hashValue: 'plain-é' },
+ { sentinel: sentinel(), blob: '😀', hashValue: '中文' }
+ ]
+ const state = {
+ // Segment boundaries land next to these, so a wrong split would corrupt the encode.
+ before: 'é中文😀',
+ a: subs[0].sentinel,
+ between: '😀
',
+ b: subs[1].sentinel,
+ after: '😀'
+ }
+ expectIdenticalToPrevious(JSON.stringify(state), subs)
+ })
+
+ it('stays identical with no substitutions and with the degraded-storage prefix', () => {
+ const state = JSON.stringify({ settings: { httpProxyUrl: '' }, big: 'x'.repeat(4096) })
+ expectIdenticalToPrevious(state, [])
+ expectIdenticalToPrevious(state, [], 'safeStorage-degraded\0')
+
+ const subs = [{ sentinel: sentinel(), blob: 'b', hashValue: 'h' }]
+ expectIdenticalToPrevious(
+ JSON.stringify({ s: subs[0].sentinel }),
+ subs,
+ 'safeStorage-degraded\0'
+ )
+ })
+
+ it('escapes regex metacharacters in the sentinel itself', () => {
+ // Not reachable from a UUID sentinel, but the alternation must not be able to become a pattern.
+ const subs = [{ sentinel: 'a.b*c(d)|e[f]', blob: 'BLOB', hashValue: 'HASH' }]
+ const serialized = JSON.stringify({ real: subs[0].sentinel, decoy: 'axbxxcXdX_eXfX' })
+ expectIdenticalToPrevious(serialized, subs)
+ expect(
+ applySecretSentinelSubstitutions(serialized, subs, '').payload.toString('utf8')
+ ).toContain('axbxxcXdX_eXfX')
+ })
+
+ it('substitutes every occurrence when a sentinel repeats', () => {
+ // Cannot happen today (a sentinel is a UUID minted after the state is assembled, so it appears
+ // exactly once), but the old first-match-only `String.replace` would have written a raw
+ // sentinel to disk in place of a secret if it ever did. The alternation is global instead.
+ const subs = [{ sentinel: sentinel(), blob: 'CIPHER', hashValue: 'PLAIN' }]
+ const serialized = JSON.stringify({ a: subs[0].sentinel, b: subs[0].sentinel })
+ const { payload } = applySecretSentinelSubstitutions(serialized, subs, '')
+ expect(payload.toString('utf8')).toBe(JSON.stringify({ a: 'CIPHER', b: 'CIPHER' }))
+ expect(payload.toString('utf8')).not.toContain(subs[0].sentinel)
+ })
+
+ it('copies and UTF-8 encodes the full state once, not once per sentinel per side', () => {
+ const subs: SecretSentinelSubstitution[] = Array.from({ length: 3 }, () => ({
+ sentinel: sentinel(),
+ blob: 'CIPHERTEXT',
+ hashValue: 'plaintext'
+ }))
+ const serialized = JSON.stringify({
+ pad: 'x'.repeat(200_000),
+ a: subs[0].sentinel,
+ b: subs[1].sentinel,
+ c: subs[2].sentinel
+ })
+ const FULL_STATE = 100_000
+
+ // Both costs are observable at their sources: a `String.replace` whose receiver is the whole
+ // state allocates another copy of it, and every string handed to `Buffer.from` or `hash.update`
+ // is one full UTF-8 encode pass on the main thread.
+ const counted = (run: () => unknown): { fullStateReplaces: number; encodedChars: number } => {
+ const realReplace = String.prototype.replace
+ const realBufferFrom = Buffer.from
+ const hashProto = Object.getPrototypeOf(createHash('sha1')) as {
+ update: (...args: unknown[]) => unknown
+ }
+ const realUpdate = hashProto.update
+ const counts = { fullStateReplaces: 0, encodedChars: 0 }
+ String.prototype.replace = function (this: string, ...args: unknown[]) {
+ if (this.length >= FULL_STATE) {
+ counts.fullStateReplaces++
+ }
+ return realReplace.apply(this, args as never)
+ } as typeof String.prototype.replace
+ Buffer.from = function (...args: unknown[]) {
+ if (typeof args[0] === 'string') {
+ counts.encodedChars += args[0].length
+ }
+ return (realBufferFrom as (...a: unknown[]) => Buffer).apply(Buffer, args)
+ } as typeof Buffer.from
+ hashProto.update = function (this: unknown, ...args: unknown[]) {
+ if (typeof args[0] === 'string') {
+ counts.encodedChars += args[0].length
+ }
+ return realUpdate.apply(this, args)
+ }
+ try {
+ run()
+ } finally {
+ String.prototype.replace = realReplace
+ Buffer.from = realBufferFrom
+ hashProto.update = realUpdate
+ }
+ return counts
+ }
+
+ const before = counted(() => previousImplementation(serialized, subs, ''))
+ const after = counted(() => applySecretSentinelSubstitutions(serialized, subs, ''))
+
+ // Two `String.replace` calls over the whole state per sentinel — payload and hash input.
+ expect(before.fullStateReplaces).toBe(subs.length * 2)
+ expect(after.fullStateReplaces).toBe(0)
+ // The old path encoded the state twice: once for sha1, once for the file write.
+ expect(before.encodedChars).toBeGreaterThan(serialized.length * 1.9)
+ expect(after.encodedChars).toBeLessThan(serialized.length * 1.1)
+ expect(after.encodedChars).toBeGreaterThan(serialized.length * 0.9)
+ })
+})
diff --git a/src/main/persistence/loading-store/secret-sentinel-substitution.ts b/src/main/persistence/loading-store/secret-sentinel-substitution.ts
new file mode 100644
index 00000000000..afcdddafa77
--- /dev/null
+++ b/src/main/persistence/loading-store/secret-sentinel-substitution.ts
@@ -0,0 +1,78 @@
+import { createHash } from 'node:crypto'
+import { escapeRegex } from '../../../shared/string-utils'
+
+export type SecretSentinelSubstitution = {
+ /** The `orca-secret-slot-` placeholder standing in the serialized state. */
+ sentinel: string
+ /** What the on-disk payload gets: the ciphertext. */
+ blob: string
+ /** What the guard hash gets: a value stable across non-deterministic encryption. */
+ hashValue: string
+}
+
+/**
+ * Replace every secret sentinel in `serialized` in ONE pass, producing the on-disk bytes and the
+ * guard hash from the same encoded segments.
+ *
+ * Why not the obvious `payload.replace(...)` / `hashInput.replace(...)` loop it replaces: each
+ * `String.replace` returns a rope that the *next* `replace` has to flatten before it can search, so
+ * N sentinels cost 2N-1 flattened copies of the whole multi-MB state, plus one more per side when
+ * `hash.update` and the file write finally consume them. Measured on a 4.65 MB store with three
+ * sentinels: 7 full-state string allocations, 62 MB of V8 heap, 27 MB of it in large_object_space.
+ *
+ * Here the state is walked once, each literal run is UTF-8 encoded exactly once, and those same
+ * buffers feed both the payload and the hash — 1 full-state string, 1 encode.
+ *
+ * Byte-for-byte identical output to the loop: both sides read the sentinel in its JSON-escaped
+ * form, the replacements are the JSON-escaped `blob`/`hashValue`, and the hash sees the same byte
+ * sequence it saw when it was handed one concatenated string.
+ */
+export function applySecretSentinelSubstitutions(
+ serialized: string,
+ substitutions: readonly SecretSentinelSubstitution[],
+ degradedPrefix: string
+): { payload: Buffer; stateHash: string } {
+ const hash = createHash('sha1').update(degradedPrefix)
+ if (substitutions.length === 0) {
+ const payload = Buffer.from(serialized, 'utf8')
+ return { payload, stateHash: hash.update(payload).digest('hex') }
+ }
+
+ const replacementBySentinel = new Map()
+ const alternatives: string[] = []
+ for (const { sentinel, blob, hashValue } of substitutions) {
+ // Preserved from the loop this replaces: both the search key and the replacements are the
+ // JSON-escaped forms, because that is what `serialized` actually contains.
+ const escapedSentinel = JSON.stringify(sentinel).slice(1, -1)
+ if (replacementBySentinel.has(escapedSentinel)) {
+ continue
+ }
+ alternatives.push(escapeRegex(escapedSentinel))
+ replacementBySentinel.set(escapedSentinel, {
+ blob: Buffer.from(JSON.stringify(blob).slice(1, -1), 'utf8'),
+ hashValue: Buffer.from(JSON.stringify(hashValue).slice(1, -1), 'utf8')
+ })
+ }
+
+ // Global, though a sentinel is a UUID minted after the state was assembled and so occurs exactly
+ // once: a single pass that substitutes every occurrence cannot leave one behind on disk.
+ const pattern = new RegExp(alternatives.join('|'), 'g')
+ const chunks: Buffer[] = []
+ let cursor = 0
+ let match: RegExpExecArray | null
+ while ((match = pattern.exec(serialized)) !== null) {
+ // Non-null: the alternation is built from exactly the map's keys.
+ const replacement = replacementBySentinel.get(match[0])!
+ // A sliced substring, so this does not copy the state; the encode below is its only pass.
+ const literal = Buffer.from(serialized.slice(cursor, match.index), 'utf8')
+ chunks.push(literal, replacement.blob)
+ hash.update(literal)
+ hash.update(replacement.hashValue)
+ cursor = match.index + match[0].length
+ }
+ const tail = Buffer.from(serialized.slice(cursor), 'utf8')
+ chunks.push(tail)
+ hash.update(tail)
+
+ return { payload: Buffer.concat(chunks), stateHash: hash.digest('hex') }
+}
diff --git a/src/main/persistence/loading-store/state-serialization-secret-handling.ts b/src/main/persistence/loading-store/state-serialization-secret-handling.ts
index c9d81f071a5..61689ea0f13 100644
--- a/src/main/persistence/loading-store/state-serialization-secret-handling.ts
+++ b/src/main/persistence/loading-store/state-serialization-secret-handling.ts
@@ -1,4 +1,4 @@
-import { createHash, randomUUID } from 'node:crypto'
+import { randomUUID } from 'node:crypto'
import type { PersistedState } from '../../../shared/persisted-state-types'
import { collectFolderWorkspaceDiffComments } from '../../folder-workspace-diff-comments'
import {
@@ -8,6 +8,10 @@ import {
} from '../../protected-secret-persistence'
import { stripRetiredGlobalSettings } from '../applying-settings/terminal-settings-migrations'
+import {
+ applySecretSentinelSubstitutions,
+ type SecretSentinelSubstitution
+} from './secret-sentinel-substitution'
import type { StoreRuntimeState } from './store-runtime-state'
type StateSerializationSecretHandlingOperationsRuntime = Pick<
@@ -24,7 +28,7 @@ export class StateSerializationSecretHandlingOperations {
}
buildStateToSave(): {
- payload: string
+ payload: Buffer
stateHash: string
protectedSecretUpdates: ProtectedSecretRetentionUpdate[]
} {
@@ -37,7 +41,7 @@ export class StateSerializationSecretHandlingOperations {
// on deterministic-IV platforms (macOS/legacy-Linux OSCrypt). A per-slot
// random UUID can't occur anywhere else in the serialized state (the user
// sets their data before it is minted), so it appears exactly once.
- const secretSubs: { sentinel: string; blob: string; hashValue: string }[] = []
+ const secretSubs: SecretSentinelSubstitution[] = []
const protectedSecretUpdates: ProtectedSecretRetentionUpdate[] = []
let protectedStorageDegraded = false
const encryptToSentinel = (slot: string, plaintext: string): string => {
@@ -105,21 +109,14 @@ export class StateSerializationSecretHandlingOperations {
// Why compact: ~20% fewer bytes and less serialize time; all readers JSON.parse so formatting is irrelevant.
// One full-state stringify; secret slots currently hold sentinels.
const serialized = JSON.stringify(stateToSave)
- // Substitute each unique sentinel exactly once: ciphertext for the on-disk
- // payload, a stable normalized value for the guard hash. Function-form
- // replacement keeps `$` inert; both sides read the sentinel as JSON-escaped
- // in `serialized`, so each replace is byte-for-byte position-exact.
- let payload = serialized
- let hashInput = serialized
- for (const { sentinel, blob, hashValue } of secretSubs) {
- const escapedSentinel = JSON.stringify(sentinel).slice(1, -1)
- payload = payload.replace(escapedSentinel, () => JSON.stringify(blob).slice(1, -1))
- hashInput = hashInput.replace(escapedSentinel, () => JSON.stringify(hashValue).slice(1, -1))
- }
- const stateHash = createHash('sha1')
- .update(protectedStorageDegraded ? 'safeStorage-degraded\0' : '')
- .update(hashInput)
- .digest('hex')
+ // Substitute each unique sentinel: ciphertext for the on-disk payload, a stable normalized
+ // value for the guard hash. One pass builds both, so the multi-MB state is never copied per
+ // sentinel and never encoded twice.
+ const { payload, stateHash } = applySecretSentinelSubstitutions(
+ serialized,
+ secretSubs,
+ protectedStorageDegraded ? 'safeStorage-degraded\0' : ''
+ )
return { payload, stateHash, protectedSecretUpdates }
}
}
diff --git a/src/main/persistence/loading-store/state-write-round-trip.test.ts b/src/main/persistence/loading-store/state-write-round-trip.test.ts
new file mode 100644
index 00000000000..ee7f26feb3e
--- /dev/null
+++ b/src/main/persistence/loading-store/state-write-round-trip.test.ts
@@ -0,0 +1,131 @@
+/**
+ * The write path now hands the file a Buffer it built in one pass instead of a string it rebuilt
+ * per secret. Drives the real `Store` end to end — encrypted settings, a local session and a remote
+ * host partition — and reloads from the file it actually wrote, because the failure this guards
+ * against (a mis-sliced segment, a re-encoded payload, a dropped sentinel) is invisible until
+ * something reads the bytes back.
+ */
+import { mkdtempSync, readFileSync, realpathSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
+
+vi.mock('electron', () => ({
+ app: {
+ getPath: () => tmpdir(),
+ getName: () => 'orca-test',
+ getVersion: () => '0.0.0-test',
+ isPackaged: false,
+ on: () => {},
+ whenReady: () => Promise.resolve()
+ },
+ safeStorage: {
+ // Encryption ON, so the secret slots really do mint sentinels and the substitution pass runs.
+ isEncryptionAvailable: () => true,
+ encryptString: (value: string) => Buffer.from(`enc:${value}`),
+ decryptString: (value: Buffer) => value.toString().slice(4)
+ },
+ ipcMain: { on: () => {}, handle: () => {} },
+ BrowserWindow: { getAllWindows: () => [] }
+}))
+
+const { Store } = await import('./store')
+
+const HOST_ID = 'ssh:user@host'
+
+const stores: InstanceType[] = []
+afterEach(() => {
+ for (const store of stores.splice(0)) {
+ store.flush()
+ }
+ vi.restoreAllMocks()
+})
+
+function openStore(dataFile: string): InstanceType {
+ const store = new Store({ dataFile })
+ stores.push(store)
+ return store
+}
+
+function session(activeTabId: string): WorkspaceSessionState {
+ return {
+ activeRepoId: 'repo-1',
+ // Left null: the load path's deregistered-repo sweep nulls an active worktree whose repo is
+ // not registered, which would mask what this test is actually about.
+ activeWorktreeId: null,
+ activeTabId,
+ tabsByWorktree: {},
+ terminalLayoutsByTabId: {},
+ // Non-ASCII on purpose: a byte-offset mistake in the encode shows up here first.
+ browserUrlHistory: [
+ {
+ url: 'https://example.test/é😀',
+ normalizedUrl: 'https://example.test/é😀',
+ title: '中文 title',
+ lastVisitedAt: 17,
+ visitCount: 3
+ }
+ ]
+ } as WorkspaceSessionState
+}
+
+describe('persisted state survives a save/load round trip', () => {
+ it('reloads settings, secrets and both session partitions unchanged', () => {
+ const dataFile = join(
+ realpathSync(mkdtempSync(join(tmpdir(), 'orca-store-round-trip-'))),
+ 'orca-data.json'
+ )
+ const written = openStore(dataFile)
+ written.updateSettings({
+ // Three secret slots, i.e. three sentinels in one save — the case the old loop paid 7 copies for.
+ opencodeSessionCookie: 'cookie-é-value',
+ httpProxyUrl: 'http://proxy.example:8080/?a=b&c=$&'
+ })
+ written.updateUI({ browserKagiSessionLink: 'https://kagi.com/session?t=abc' })
+ written.setWorkspaceSession(session('local-tab'))
+ written.setWorkspaceSession(session('remote-tab'), HOST_ID)
+ written.flush()
+
+ const before = {
+ settings: written.getSettings(),
+ ui: written.getUI(),
+ local: written.getWorkspaceSession(),
+ remote: written.getWorkspaceSession(HOST_ID)
+ }
+
+ // The file is valid UTF-8 JSON and holds ciphertext, not the plaintext secrets.
+ const bytes = readFileSync(dataFile)
+ const onDisk = JSON.parse(bytes.toString('utf8'))
+ expect(onDisk.settings.opencodeSessionCookie).not.toBe('cookie-é-value')
+ expect(Buffer.from(onDisk.settings.opencodeSessionCookie, 'base64').toString('utf8')).toContain(
+ 'cookie-é-value'
+ )
+ expect(bytes.toString('utf8')).not.toContain('orca-secret-slot-')
+
+ const reloaded = openStore(dataFile)
+ expect(reloaded.getSettings().opencodeSessionCookie).toBe(before.settings.opencodeSessionCookie)
+ expect(reloaded.getSettings().httpProxyUrl).toBe(before.settings.httpProxyUrl)
+ expect(reloaded.getUI().browserKagiSessionLink).toBe(before.ui.browserKagiSessionLink)
+ // `toMatchObject`: the load path spreads session defaults over what was written, so the
+ // reloaded slice is a superset. Exact deep equality is asserted on the second trip below.
+ expect(reloaded.getWorkspaceSession()).toMatchObject(before.local)
+ // The remote partition keeps everything it owns; only globals local already holds are dropped,
+ // and `browserUrlHistory` comes back at its default from the same spread as before.
+ expect(reloaded.getWorkspaceSession(HOST_ID).activeTabId).toBe('remote-tab')
+ expect(reloaded.getWorkspaceSession(HOST_ID).browserUrlHistory).toEqual([])
+
+ // Deep equality of the whole reloaded state, taken across a second round trip so the assertion
+ // is not comparing against the first load's one-time settings migrations.
+ reloaded.flush()
+ const bytesAfterReload = readFileSync(dataFile)
+ const again = openStore(dataFile)
+ expect(again.getSettings()).toEqual(reloaded.getSettings())
+ expect(again.getUI()).toEqual(reloaded.getUI())
+ expect(again.getWorkspaceSession()).toEqual(reloaded.getWorkspaceSession())
+ expect(again.getWorkspaceSession(HOST_ID)).toEqual(reloaded.getWorkspaceSession(HOST_ID))
+ // ...and the bytes are stable, so a quiet app is not rewriting a 4 MB file with new content.
+ again.flush()
+ expect(readFileSync(dataFile).equals(bytesAfterReload)).toBe(true)
+ })
+})
diff --git a/src/main/persistence/loading-store/workspace-session-partitions.test.ts b/src/main/persistence/loading-store/workspace-session-partitions.test.ts
new file mode 100644
index 00000000000..f9697f77997
--- /dev/null
+++ b/src/main/persistence/loading-store/workspace-session-partitions.test.ts
@@ -0,0 +1,141 @@
+/**
+ * Global session fields live in the 'local' slice. Copies of them inside a non-local host partition
+ * are legacy residue: the split never writes them there and the merge never reads them from there
+ * unless local has nothing. These tests pin the drop to exactly that condition, keep the renderer's
+ * merge landing on the same value either way, and re-check the two safety gates that decide which
+ * global fields may be dropped at all.
+ */
+import { describe, expect, it } from 'vitest'
+import { getDefaultWorkspaceSession } from '../../../shared/constants'
+import type { BrowserHistoryEntry } from '../../../shared/browser-workspace-types'
+import type { WorkspaceDocHistoryEntry } from '../../../shared/workspace-doc-history'
+import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
+import { WORKSPACE_SESSION_FIELD_OWNERSHIP } from '../../../shared/workspace-session-host-field-ownership'
+import { WORKSPACE_SESSION_WORKTREE_REFERENCE_KIND } from '../restoring-sessions/session-worktree-ownership'
+import {
+ HOST_PARTITION_REDUNDANT_GLOBAL_FIELDS,
+ parseWorkspaceSessionsByHostId
+} from './workspace-session-partitions'
+
+const HOST = 'ssh:target-1'
+
+function history(url: string): BrowserHistoryEntry[] {
+ return [{ url, normalizedUrl: url, title: url, lastVisitedAt: 1, visitCount: 1 }]
+}
+
+function docEntry(filePath: string): WorkspaceDocHistoryEntry {
+ return {
+ docLocation: { kind: 'workspace-doc', worktreeId: 'repo-1::/tmp/a', filePath },
+ title: filePath,
+ lastVisitedAt: 2,
+ visitCount: 1
+ }
+}
+
+function localSession(overrides: Partial): WorkspaceSessionState {
+ return { ...getDefaultWorkspaceSession(), ...overrides }
+}
+
+function parse(
+ raw: Record,
+ local?: WorkspaceSessionState
+): Partial> {
+ return parseWorkspaceSessionsByHostId(raw, getDefaultWorkspaceSession(), local).partitions
+}
+
+describe('HOST_PARTITION_REDUNDANT_GLOBAL_FIELDS', () => {
+ it('only lists fields that are global AND that no worktree-ownership pass follows', () => {
+ for (const field of HOST_PARTITION_REDUNDANT_GLOBAL_FIELDS) {
+ // Gate 1: the renderer's split/merge treat it as local-owned, so a non-local copy is dead.
+ expect(WORKSPACE_SESSION_FIELD_OWNERSHIP[field]).toBe('global')
+ // Gate 2: `collectPersistedSessionWorktreeOwners` and the deregistered-repo residue sweep
+ // walk EVERY partition through this table. Anything but 'none' means dropping the field
+ // could un-own a worktree and get its metadata pruned.
+ expect(WORKSPACE_SESSION_WORKTREE_REFERENCE_KIND[field]).toBe('none')
+ }
+ })
+})
+
+describe('parseWorkspaceSessionsByHostId global-field residue', () => {
+ it('drops a non-local global field the local slice already owns', () => {
+ const local = localSession({ browserUrlHistory: history('https://local.test') })
+ const partitions = parse(
+ {
+ [HOST]: {
+ ...getDefaultWorkspaceSession(),
+ browserUrlHistory: history('https://stale.test')
+ }
+ },
+ local
+ )
+ // Back to the default from the spread, not the 65 KB stale replica. The merge reads this field
+ // from local whenever local has it, so the renderer still sees `https://local.test`
+ // (`workspace-session-host-split.test.ts` pins that half of the contract).
+ expect(partitions[HOST]?.browserUrlHistory).toEqual([])
+ })
+
+ it('retains a non-local global field the local slice does NOT have', () => {
+ // `workspaceDocHistory` is optional and absent from the defaults, so local can genuinely lack
+ // it and the merge's fallback to another slice is live.
+ const local = localSession({})
+ expect(local.workspaceDocHistory).toBeUndefined()
+ const docs = [docEntry('/repo/remote.md')]
+ const partitions = parse(
+ { [HOST]: { ...getDefaultWorkspaceSession(), workspaceDocHistory: docs } },
+ local
+ )
+ // Retained, so the merge's "fall back to any slice that has it" path still finds a value.
+ expect(partitions[HOST]?.workspaceDocHistory).toEqual(docs)
+ })
+
+ it('drops that same field once the local slice does have it', () => {
+ const localDocs = [docEntry('/repo/local.md')]
+ const local = localSession({ workspaceDocHistory: localDocs })
+ const partitions = parse(
+ {
+ [HOST]: {
+ ...getDefaultWorkspaceSession(),
+ workspaceDocHistory: [docEntry('/repo/stale.md')]
+ }
+ },
+ local
+ )
+ expect(partitions[HOST]).not.toHaveProperty('workspaceDocHistory')
+ expect(local.workspaceDocHistory).toEqual(localDocs)
+ })
+
+ it('leaves worktree-referencing globals and host-owned fields alone', () => {
+ const local = localSession({
+ browserUrlHistory: history('https://local.test'),
+ activeWorktreeId: 'repo-1::/tmp/local',
+ activeTabId: 'local-tab'
+ })
+ const tabs = { 'repo-1::/tmp/a': [] }
+ const partitions = parse(
+ {
+ [HOST]: {
+ ...getDefaultWorkspaceSession(),
+ // A `'direct'` worktree reference the residue sweep reads out of every partition.
+ activeWorktreeId: 'repo-1::/tmp/a',
+ // Read on a partition by the mobile terminal projection.
+ activeTabId: 'remote-tab',
+ tabsByWorktree: tabs,
+ terminalTopologyRevisionByRepoId: { 'repo-1': 4 }
+ }
+ },
+ local
+ )
+ expect(partitions[HOST]?.activeWorktreeId).toBe('repo-1::/tmp/a')
+ expect(partitions[HOST]?.activeTabId).toBe('remote-tab')
+ expect(partitions[HOST]?.tabsByWorktree).toEqual(tabs)
+ expect(partitions[HOST]?.terminalTopologyRevisionByRepoId).toEqual({ 'repo-1': 4 })
+ })
+
+ it('is a no-op when no local slice is supplied', () => {
+ const stale = history('https://stale.test')
+ const partitions = parse({
+ [HOST]: { ...getDefaultWorkspaceSession(), browserUrlHistory: stale }
+ })
+ expect(partitions[HOST]?.browserUrlHistory).toEqual(stale)
+ })
+})
diff --git a/src/main/persistence/loading-store/workspace-session-partitions.ts b/src/main/persistence/loading-store/workspace-session-partitions.ts
index 50e9d4ae8cc..b6f78479dbf 100644
--- a/src/main/persistence/loading-store/workspace-session-partitions.ts
+++ b/src/main/persistence/loading-store/workspace-session-partitions.ts
@@ -17,11 +17,49 @@ export function workspaceSessionSalvageLogDetails(result: {
}
}
+/**
+ * Global fields belong to the 'local' slice: the split writes them only there and the merge reads
+ * them only from there. A copy inside a non-local partition is legacy residue no read can reach —
+ * stale `browserUrlHistory` replicas alone were 589 KB, 12.7% of a 4.65 MB store, rewritten on
+ * every save and reparsed on every launch.
+ *
+ * Deliberately NOT every field in `GLOBAL_WORKSPACE_SESSION_FIELDS`. Two separate gates disqualify
+ * the rest, and both are load-bearing:
+ * - `activeWorktreeId` and `activeWorkspaceKey` are `'direct'` in
+ * `WORKSPACE_SESSION_WORKTREE_REFERENCE_KIND`, and both `collectPersistedSessionWorktreeOwners`
+ * and the deregistered-repo residue sweep read them out of EVERY partition. Dropping one
+ * un-owns a worktree, and an un-owned worktree gets its metadata pruned.
+ * - `activeTabId`, `activeConnectionIdsAtShutdown` and `activeRepoId` have live main-side readers
+ * on a partition: `isPersistedTerminalLeafActive` falls back to `activeTabId` for the mobile
+ * projection, and the runtime attach-window handoff unions `activeConnectionIdsAtShutdown`.
+ *
+ * `workspace-session-partitions.test.ts` re-checks both gates for every field listed here.
+ */
+export const HOST_PARTITION_REDUNDANT_GLOBAL_FIELDS = [
+ 'browserUrlHistory',
+ 'workspaceDocHistory'
+] as const satisfies readonly (keyof WorkspaceSessionState)[]
+
+/** Dropped only where local already holds the field — exactly when the merge's fallback to another
+ * slice cannot fire. Runs before the defaults spread, so a field the type requires comes back at
+ * its default rather than going missing. */
+function dropRedundantGlobalFields(
+ slice: Partial,
+ local: WorkspaceSessionState | undefined
+): void {
+ for (const field of HOST_PARTITION_REDUNDANT_GLOBAL_FIELDS) {
+ if (local?.[field] !== undefined) {
+ delete slice[field]
+ }
+ }
+}
+
/** Normalize non-'local' host partitions; 'local' (the legacy workspaceSession blob) is dropped so the two surfaces never diverge.
* Each partition is zod-validated independently, so one corrupt host drops to defaults without taking out the others. Idempotent. */
export function parseWorkspaceSessionsByHostId(
raw: unknown,
- defaults: WorkspaceSessionState
+ defaults: WorkspaceSessionState,
+ localSession?: WorkspaceSessionState
): { partitions: Partial>; repaired: boolean } {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
return { partitions: {}, repaired: raw !== undefined }
@@ -50,6 +88,7 @@ export function parseWorkspaceSessionsByHostId(
)
repaired = true
}
+ dropRedundantGlobalFields(result.value, localSession)
partitions[hostId] = { ...defaults, ...result.value }
}
return { partitions, repaired }
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/providers/__fixtures__/real-agent-rows.json.gz b/src/main/providers/__fixtures__/real-agent-rows.json.gz
new file mode 100644
index 00000000000..3bd62f9eda3
Binary files /dev/null and b/src/main/providers/__fixtures__/real-agent-rows.json.gz differ
diff --git a/src/main/providers/agent-foreground-process-batch.ts b/src/main/providers/agent-foreground-process-batch.ts
index ea89005d87f..ac9223a955f 100644
--- a/src/main/providers/agent-foreground-process-batch.ts
+++ b/src/main/providers/agent-foreground-process-batch.ts
@@ -1,16 +1,15 @@
import {
isAgentForegroundWrapperProcess,
- isExpectedAgentProcess,
- recognizeAgentProcessFromCommandLine
+ isExpectedAgentProcess
} from '../../shared/agent-process-recognition'
import { getFirstCommandToken } from '../../shared/command-token-scanner'
import { resolveOuterWrapperForegroundProcess } from '../../shared/foreground-wrapper-agent'
+import { selectForegroundProcessCandidate } from '../../shared/foreground-process-selection'
import type { ForegroundProcessEvidence } from '../../shared/foreground-process-evidence'
import {
buildProcessTableIndex,
getStrictProcessTableSnapshot,
lookupProcessTableIndex,
- scoreForegroundCandidateRow,
type ProcessTableIndex,
type ProcessTableIndexStats,
type ProcessTableRow
@@ -126,23 +125,15 @@ export function resolveAgentForegroundProcessesFromIndex(
if (wrapperFallback && candidates.length !== 1) {
return { available: true, processName: null }
}
- let bestCandidate: (ProcessTableRow & { depth: number }) | null = null
- let bestName: ReturnType = null
- for (const candidate of candidates) {
- const recognized = recognizeAgentProcessFromCommandLine(candidate.command)
- if (
- recognized &&
- (bestCandidate === null ||
- scoreForegroundCandidateRow(candidate) > scoreForegroundCandidateRow(bestCandidate))
- ) {
- bestCandidate = candidate
- bestName = recognized
- }
- }
- if (bestCandidate && bestName) {
+ const selected = selectForegroundProcessCandidate(candidates, allCandidates)
+ if (selected) {
return {
available: true,
- processName: resolveOuterWrapperForegroundProcess(bestName, bestCandidate, allCandidates)
+ processName: resolveOuterWrapperForegroundProcess(
+ selected.recognized,
+ selected.candidate,
+ allCandidates
+ )
}
}
return { available: true, processName: null }
diff --git a/src/main/providers/agent-foreground-process-real-rows.test.ts b/src/main/providers/agent-foreground-process-real-rows.test.ts
new file mode 100644
index 00000000000..fd86e207398
--- /dev/null
+++ b/src/main/providers/agent-foreground-process-real-rows.test.ts
@@ -0,0 +1,37 @@
+import { readFileSync } from 'node:fs'
+import { join } from 'node:path'
+import { gunzipSync } from 'node:zlib'
+import { describe, expect, it } from 'vitest'
+import type { ProcessTableRow } from '../../shared/process-table-snapshot'
+import { resolveAgentForegroundProcessFromPs } from './agent-foreground-process'
+
+type CapturedRun = {
+ agent: string
+ shellPid: number
+ rows: ProcessTableRow[]
+}
+
+describe('real foreground process captures', () => {
+ it('resolves all six agents, including omp over its deeper vendor helpers', () => {
+ const captured = JSON.parse(
+ gunzipSync(readFileSync(join(__dirname, '__fixtures__', 'real-agent-rows.json.gz'))).toString(
+ 'utf8'
+ )
+ ) as CapturedRun[]
+
+ expect(captured).toHaveLength(6)
+ expect(
+ captured.map(({ agent, shellPid, rows }) => ({
+ agent,
+ processName: resolveAgentForegroundProcessFromPs(rows, shellPid)
+ }))
+ ).toEqual([
+ { agent: 'claude', processName: 'claude' },
+ { agent: 'codex', processName: 'codex' },
+ { agent: 'opencode', processName: 'opencode' },
+ { agent: 'gemini', processName: 'gemini' },
+ { agent: 'grok', processName: 'grok' },
+ { agent: 'omp', processName: 'omp' }
+ ])
+ })
+})
diff --git a/src/main/providers/agent-foreground-process.ts b/src/main/providers/agent-foreground-process.ts
index d171e39a18e..2000d35261c 100644
--- a/src/main/providers/agent-foreground-process.ts
+++ b/src/main/providers/agent-foreground-process.ts
@@ -1,7 +1,9 @@
import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition'
import { resolveOuterWrapperForegroundProcess } from '../../shared/foreground-wrapper-agent'
import {
+ collectDescendantsFromIndex,
getFreshProcessTableSnapshot,
+ getProcessTableIndex,
getProcessTableSnapshot,
type ProcessTableRow
} from '../../shared/process-table-snapshot'
@@ -11,6 +13,7 @@ import {
type AgentForegroundResolutionOptions
} from './windows-agent-foreground-process'
import { isShellProcess } from '../../shared/shell-process-detection'
+import { selectForegroundProcessCandidate } from '../../shared/foreground-process-selection'
export type { AgentForegroundResolutionOptions } from './windows-agent-foreground-process'
export {
@@ -42,29 +45,6 @@ type ShellForegroundConfirmationOptions = {
| Promise | null>
}
-function collectDescendants(
- rows: Row[],
- rootPid: number
-): (Row & { depth: number })[] {
- const childrenByParent = new Map()
- for (const row of rows) {
- const children = childrenByParent.get(row.ppid) ?? []
- children.push(row)
- childrenByParent.set(row.ppid, children)
- }
-
- const descendants: (Row & { depth: number })[] = []
- const stack = (childrenByParent.get(rootPid) ?? []).map((row) => ({ row, depth: 1 }))
- while (stack.length > 0) {
- const { row, depth } = stack.pop()!
- descendants.push({ ...row, depth })
- for (const child of childrenByParent.get(row.pid) ?? []) {
- stack.push({ row: child, depth: depth + 1 })
- }
- }
- return descendants
-}
-
function commandExecutable(command: string): string {
const trimmed = command.trim().replace(/^[-]/, '')
if (trimmed.startsWith('"') || trimmed.startsWith("'")) {
@@ -96,12 +76,12 @@ export async function confirmShellForegroundProcess(
}
}
try {
- const rows = await getFreshProcessTableSnapshot()
- if (!rows.some((row) => row.pid === shellPid)) {
+ const index = getProcessTableIndex(await getFreshProcessTableSnapshot())
+ const root = index.byPid.get(shellPid)
+ if (!root) {
return false
}
- const root = rows.find((row) => row.pid === shellPid)!
- const tree = [{ ...root, depth: 0 }, ...collectDescendants(rows, shellPid)]
+ const tree = [{ ...root, depth: 0 }, ...collectDescendantsFromIndex(index, shellPid)]
const spawnedShellBasename = executableBasename(spawnedShellProcess)
const foregroundShell = tree
.filter(
@@ -120,13 +100,6 @@ export async function confirmShellForegroundProcess(
}
}
-function candidateScore(row: ProcessTableRow & { depth: number }): number {
- // Why: foreground descendants carry `+` in `ps stat` on Unix PTYs. Prefer
- // them, then prefer leaf/deeper wrappers so `node /path/bin/codex` beats the
- // parent shell but still lets the native child confirm the same identity.
- return (row.stat.includes('+') ? 10_000 : 0) + row.depth
-}
-
export async function resolveAgentForegroundProcess(
shellPid: number | null | undefined,
fallbackProcess: string | null,
@@ -178,7 +151,7 @@ export async function resolveAgentForegroundProcessWithAvailability(
const rows = options.fresh
? await getFreshProcessTableSnapshot()
: await getProcessTableSnapshot()
- if (options.fresh && !rows.some((row) => row.pid === shellPid)) {
+ if (options.fresh && !getProcessTableIndex(rows).byPid.has(shellPid)) {
return { available: false, processName: fallbackProcess }
}
return {
@@ -191,30 +164,32 @@ export async function resolveAgentForegroundProcessWithAvailability(
}
}
-function resolveAgentForegroundProcessFromPs(
- rows: ProcessTableRow[],
+export function resolveAgentForegroundProcessFromPs(
+ rows: readonly ProcessTableRow[],
shellPid: number
): string | null {
- const shellRow = rows.find((row) => row.pid === shellPid)
- const candidates = collectDescendants(rows, shellPid).sort(
- (a, b) => candidateScore(b) - candidateScore(a)
- )
+ // Memoized per snapshot identity, so the caller's own index build is reused.
+ const index = getProcessTableIndex(rows)
+ const shellRow = index.byPid.get(shellPid)
+ const candidates = collectDescendantsFromIndex(index, shellPid)
// Why: `+` in `ps stat` marks the process holding the terminal foreground.
// The root shell can hold it after Ctrl-Z, so use the whole PTY tree as the
// foreground gate; otherwise a stopped agent child still masquerades as live.
const foregroundIsKnown =
shellRow?.stat.includes('+') === true ||
candidates.some((candidate) => candidate.stat.includes('+'))
- for (const candidate of candidates) {
- if (foregroundIsKnown && !candidate.stat.includes('+')) {
- continue
- }
- const recognized = recognizeAgentProcessFromCommandLine(candidate.command)
- if (recognized) {
- // Why: return the outer wrapper (omp) rather than the deeper wrapped child
- // (pi) of a shell→omp→pi tree — see resolveOuterWrapperForegroundProcess.
- return resolveOuterWrapperForegroundProcess(recognized, candidate, candidates)
- }
+ const foregroundCandidates = foregroundIsKnown
+ ? candidates.filter((candidate) => candidate.stat.includes('+'))
+ : candidates
+ // Keep the complete process tree for ancestry checks. A recognized agent can
+ // sit above a non-foreground helper before another recognized process; the
+ // helper is filtered from selection but must remain traversable.
+ const ancestryCandidates = shellRow ? [{ ...shellRow, depth: 0 }, ...candidates] : candidates
+ const selected = selectForegroundProcessCandidate(foregroundCandidates, ancestryCandidates)
+ if (selected) {
+ // Why: return the outer wrapper (omp) rather than the deeper wrapped child
+ // (pi) of a shell→omp→pi tree — see resolveOuterWrapperForegroundProcess.
+ return resolveOuterWrapperForegroundProcess(selected.recognized, selected.candidate, candidates)
}
return null
}
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/providers/windows-foreground-process-inspection-cost.test.ts b/src/main/providers/windows-foreground-process-inspection-cost.test.ts
new file mode 100644
index 00000000000..f08d1b690bb
--- /dev/null
+++ b/src/main/providers/windows-foreground-process-inspection-cost.test.ts
@@ -0,0 +1,156 @@
+// Regression guard on the per-inspection cost of Windows agent foreground
+// inspection — the Windows analogue of the POSIX index memo (#6288).
+//
+// The shared TTL cache already collapses N panes into one Toolhelp32 snapshot
+// (windows-agent-foreground-process-scan-volume.test.ts). What it never
+// collapsed is the work each pane does ON that snapshot: a full
+// `native.map(toProcessRow)` projection, a `childrenByPpid` Map rebuilt from
+// scratch, and two linear scans. This file counts that work at a realistic
+// table size and pane count, and pins the flag set the snapshot asks for.
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { __setWindowsProcessTreeLoaderForTests } from '../windows/windows-process-table'
+import {
+ queryWindowsPaneProcessInventory,
+ resetWindowsProcessRowsSnapshotForTests
+} from './windows-foreground-process-rows'
+
+// 1050 processes is the host measured in windows-process-enumeration.md; 11
+// panes is the fan-out the shared snapshot exists to serve.
+const TABLE_SIZE = 1050
+const PANE_COUNT = 11
+
+const SELF_ROW = { pid: process.pid, ppid: 0, name: 'vitest.exe', commandLine: 'vitest' }
+
+const shellPid = (pane: number): number => 10_000 + pane * 10
+const agentPid = (pane: number): number => shellPid(pane) + 1
+/** A row every pane can look up, so distinct results == distinct projections. */
+const PROBE_PID = 900_000 + TABLE_SIZE - 1
+
+/** One shell + one agent child per pane, padded out to a real table size. */
+function buildNativeTable(): { pid: number; ppid: number; name: string; commandLine: string }[] {
+ const rows = [SELF_ROW]
+ for (let pane = 0; pane < PANE_COUNT; pane += 1) {
+ rows.push({ pid: shellPid(pane), ppid: 4, name: 'cmd.exe', commandLine: 'cmd.exe' })
+ rows.push({
+ pid: agentPid(pane),
+ ppid: shellPid(pane),
+ name: 'node.exe',
+ commandLine: 'node C:/Users/dev/AppData/codex/bin/codex.js'
+ })
+ }
+ for (let filler = rows.length; filler < TABLE_SIZE; filler += 1) {
+ rows.push({ pid: 900_000 + filler, ppid: 4, name: 'svchost.exe', commandLine: 'svchost.exe' })
+ }
+ return rows
+}
+
+const NATIVE_TABLE = buildNativeTable()
+
+/**
+ * Count `Map.prototype.set` calls — the primitive both the old per-call
+ * `childrenByPpid` rebuild and the shared index build are made of. Patched for
+ * one awaited region and restored in `finally`, so nothing else observes it.
+ */
+async function countMapInsertions(run: () => Promise): Promise {
+ const original = Map.prototype.set
+ let insertions = 0
+ Map.prototype.set = function patched(this: Map, key: unknown, value: unknown) {
+ insertions += 1
+ return original.call(this, key, value)
+ } as typeof Map.prototype.set
+ try {
+ await run()
+ } finally {
+ Map.prototype.set = original
+ }
+ return insertions
+}
+
+describe('windows foreground inspection cost per pane', () => {
+ const getAllProcesses = vi.fn()
+ let platform: PropertyDescriptor | undefined
+ let flagsSeen: number[] = []
+
+ beforeEach(() => {
+ flagsSeen = []
+ getAllProcesses.mockReset()
+ getAllProcesses.mockImplementation((cb: (rows: unknown) => void, flags: number) => {
+ flagsSeen.push(flags)
+ cb(NATIVE_TABLE)
+ })
+ platform = Object.getOwnPropertyDescriptor(process, 'platform')
+ Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
+ __setWindowsProcessTreeLoaderForTests(() => ({
+ ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2, CreationTime: 4 },
+ getAllProcesses
+ }))
+ resetWindowsProcessRowsSnapshotForTests()
+ vi.useFakeTimers({ toFake: ['Date'] })
+ vi.setSystemTime(0)
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ __setWindowsProcessTreeLoaderForTests()
+ if (platform) {
+ Object.defineProperty(process, 'platform', platform)
+ }
+ })
+
+ async function sweepPanes(): Promise<(number | undefined)[]> {
+ const resolved: (number | undefined)[] = []
+ for (let pane = 0; pane < PANE_COUNT; pane += 1) {
+ const inventory = await queryWindowsPaneProcessInventory(shellPid(pane), {
+ anchorPid: agentPid(pane)
+ })
+ expect(inventory?.candidates).toHaveLength(1)
+ resolved.push(inventory?.candidates[0]?.pid)
+ }
+ return resolved
+ }
+
+ it('never sets the Memory flag on the snapshot', async () => {
+ await queryWindowsPaneProcessInventory(shellPid(0))
+ expect(flagsSeen).toHaveLength(1)
+ // Memory is bit 0, and it costs the addon a second OpenProcess per process
+ // carrying PROCESS_VM_READ (process.cc `GetProcessMemoryUsage`).
+ expect(flagsSeen[0]! & 1).toBe(0)
+ // CommandLine (2) | CreationTime (4).
+ expect(flagsSeen[0]).toBe(6)
+ })
+
+ it('projects the shared snapshot once for the whole pane fan-out', async () => {
+ const probeRows: unknown[] = []
+ for (let pane = 0; pane < PANE_COUNT; pane += 1) {
+ const inventory = await queryWindowsPaneProcessInventory(shellPid(pane), {
+ anchorPid: PROBE_PID
+ })
+ probeRows.push(inventory?.anchorRow)
+ }
+ expect(probeRows.filter(Boolean)).toHaveLength(PANE_COUNT)
+ // One projection produced every pane's row object. Pre-fix each pane ran
+ // its own `native.map(toProcessRow)` over all 1050 rows, so this set held
+ // PANE_COUNT distinct objects and the sweep allocated PANE_COUNT * 1050.
+ expect(new Set(probeRows).size).toBe(1)
+ })
+
+ it('indexes the shared snapshot once for the whole pane fan-out', async () => {
+ // Prime the TTL cache and the index so the snapshot read is not in the count.
+ await queryWindowsPaneProcessInventory(shellPid(0), { anchorPid: agentPid(0) })
+
+ const insertions = await countMapInsertions(async () => {
+ await sweepPanes()
+ })
+
+ // Pre-fix every pane rebuilt a whole-table `childrenByPpid`, so this was
+ // >= PANE_COUNT * (rows with a distinct ppid). One shared index makes the
+ // whole sweep cost no table-sized Map build at all.
+ expect(insertions).toBeLessThan(TABLE_SIZE)
+ })
+
+ it('resolves the same foreground child for every pane as an unshared scan would', async () => {
+ const resolved = await sweepPanes()
+ expect(resolved).toEqual(Array.from({ length: PANE_COUNT }, (_, pane) => agentPid(pane)))
+ })
+})
diff --git a/src/main/providers/windows-foreground-process-rows.ts b/src/main/providers/windows-foreground-process-rows.ts
index f5a74bd0fe2..5be4734dddc 100644
--- a/src/main/providers/windows-foreground-process-rows.ts
+++ b/src/main/providers/windows-foreground-process-rows.ts
@@ -1,3 +1,7 @@
+import {
+ collectDescendantsFromIndex,
+ getProcessTableIndex
+} from '../../shared/process-table-snapshot'
import {
readWindowsProcessTable,
readWindowsProcessTableFresh,
@@ -25,15 +29,40 @@ function toProcessRow(row: NativeWindowsProcessRow): WindowsProcessRow {
}
}
+/**
+ * One projection per snapshot identity, mirroring `getProcessTableIndex`.
+ *
+ * The TTL cache already gives every pane the same native rows array; without
+ * this each of them still rebuilt ~1050 row objects, which also handed
+ * `getProcessTableIndex` a new array each time and defeated its memo by
+ * construction. Keyed weakly, so a projection dies with its snapshot. Rows are
+ * shared, never mutated: descendants are copied with their depth, and
+ * `anchorRow` is read-only to every caller.
+ */
+const projectedRows = new WeakMap()
+
+function projectProcessRows(native: readonly NativeWindowsProcessRow[]): WindowsProcessRow[] {
+ const cached = projectedRows.get(native)
+ if (cached) {
+ return cached
+ }
+ const rows = native.map(toProcessRow)
+ projectedRows.set(native, rows)
+ return rows
+}
+
/**
* Rows from a scan that starts after this call.
*
* PID-identity checks in teardown must not reuse a cached row — it can predate
* the very recycle it is meant to detect. Rejects when the table is unreadable,
* so "unavailable" stays distinguishable from "nothing is running".
+ *
+ * `readonly` because the projection is shared with every other reader of the
+ * same snapshot.
*/
-export async function queryWindowsProcessRowsFresh(): Promise {
- return (await readWindowsProcessTableFresh()).map(toProcessRow)
+export async function queryWindowsProcessRowsFresh(): Promise {
+ return projectProcessRows(await readWindowsProcessTableFresh())
}
export async function queryWindowsProcessDescendants(
@@ -63,21 +92,22 @@ export async function queryWindowsPaneProcessInventory(
options.fresh === true
? await readWindowsProcessTableFresh()
: await readWindowsProcessTable()
- rows = native.map(toProcessRow)
+ rows = projectProcessRows(native)
} catch {
return null
}
+ // One index per snapshot, shared by every pane inspecting inside the TTL
+ // window: `byPid` answers both lookups that used to be linear scans, and
+ // `childrenByPpid` replaces a per-call Map rebuild over the whole table.
+ const index = getProcessTableIndex(rows)
// Why: a snapshot that omitted the PTY root may be stale or permission-
// filtered; only an observed root can authoritatively have no descendants.
- if (!rows.some((row) => row.pid === rootPid)) {
+ if (!index.byPid.has(rootPid)) {
return null
}
return {
- candidates: collectDescendants(rows, rootPid).sort((a, b) => b.depth - a.depth),
- anchorRow:
- options.anchorPid !== undefined
- ? (rows.find((row) => row.pid === options.anchorPid) ?? null)
- : null
+ candidates: collectDescendantsFromIndex(index, rootPid).sort((a, b) => b.depth - a.depth),
+ anchorRow: options.anchorPid !== undefined ? (index.byPid.get(options.anchorPid) ?? null) : null
}
}
@@ -85,26 +115,3 @@ export async function queryWindowsPaneProcessInventory(
export function resetWindowsProcessRowsSnapshotForTests(): void {
resetWindowsProcessTableForTests()
}
-
-function collectDescendants(
- rows: Row[],
- rootPid: number
-): (Row & { depth: number })[] {
- const childrenByParent = new Map()
- for (const row of rows) {
- const children = childrenByParent.get(row.ppid) ?? []
- children.push(row)
- childrenByParent.set(row.ppid, children)
- }
-
- const descendants: (Row & { depth: number })[] = []
- const stack = (childrenByParent.get(rootPid) ?? []).map((row) => ({ row, depth: 1 }))
- while (stack.length > 0) {
- const { row, depth } = stack.pop()!
- descendants.push({ ...row, depth })
- for (const child of childrenByParent.get(row.pid) ?? []) {
- stack.push({ row: child, depth: depth + 1 })
- }
- }
- return descendants
-}
diff --git a/src/main/rate-limits/claude-fetcher-fable-usage.test.ts b/src/main/rate-limits/claude-fetcher-fable-usage.test.ts
index 50617d95bb4..2a84cc4f2af 100644
--- a/src/main/rate-limits/claude-fetcher-fable-usage.test.ts
+++ b/src/main/rate-limits/claude-fetcher-fable-usage.test.ts
@@ -144,51 +144,6 @@ describe('fetchClaudeRateLimits', () => {
expect(fetchViaPty).not.toHaveBeenCalled()
})
- it('maps a scoped Fable window whose display name carries a point release', async () => {
- const configDir = '/Users/test/.claude'
- const authPreparation: ClaudeRuntimeAuthPreparation = {
- configDir,
- envPatch: { CLAUDE_CONFIG_DIR: configDir },
- stripAuthEnv: false,
- provenance: 'managed:account-1'
- }
- vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce(
- JSON.stringify({ claudeAiOauth: { accessToken: 'oauth-token' } })
- )
- netFetchMock.mockResolvedValueOnce(
- new Response(
- JSON.stringify({
- five_hour: { utilization: 36 },
- seven_day: { utilization: 73 },
- // Discriminating: a passing scope match must beat this fallback.
- fable_weekly: { utilization: 12 },
- limits: [
- {
- kind: 'weekly_scoped',
- percent: 64,
- resets_at: '2026-07-17T20:00:00.099908+00:00',
- is_active: true,
- scope: { model: { display_name: 'Fable 5.1' } }
- }
- ]
- }),
- { status: 200 }
- )
- )
-
- await expect(
- fetchClaudeRateLimits({ authPreparation, allowUsagePanelSupplement: true })
- ).resolves.toMatchObject({
- provider: 'claude',
- status: 'ok',
- fableWeekly: {
- usedPercent: 64,
- resetsAt: Date.parse('2026-07-17T20:00:00.099908+00:00')
- }
- })
- expect(fetchViaPty).not.toHaveBeenCalled()
- })
-
it('surfaces inactive scoped Fable usage over the legacy OAuth fallback', async () => {
const configDir = '/Users/test/.claude'
const authPreparation: ClaudeRuntimeAuthPreparation = {
diff --git a/src/main/rate-limits/claude-oauth-usage-request.ts b/src/main/rate-limits/claude-oauth-usage-request.ts
index e29bef5f48e..9565a064514 100644
--- a/src/main/rate-limits/claude-oauth-usage-request.ts
+++ b/src/main/rate-limits/claude-oauth-usage-request.ts
@@ -32,17 +32,13 @@ async function ensureProxyFromEnvironment(): Promise {
}).catch(() => {})
}
-// Why: the scope name carries the shipped version once a point release exists
-// ("Fable 5.1"), so exact equality would drop the window.
-const FABLE_SCOPE_RE = /^fable\b/
-
function mapFableWeeklyWindow(data: OAuthUsageResponse): RateLimitWindow | null {
const scoped = Array.isArray(data.limits)
? data.limits.find(
(limit) =>
limit?.kind === 'weekly_scoped' &&
Number.isFinite(limit.percent) &&
- FABLE_SCOPE_RE.test(limit.scope?.model?.display_name?.trim().toLowerCase() ?? '')
+ limit.scope?.model?.display_name?.trim().toLowerCase() === 'fable'
)
: undefined
return (
diff --git a/src/main/runtime/decorative-title-fact-emission.test.ts b/src/main/runtime/decorative-title-fact-emission.test.ts
new file mode 100644
index 00000000000..609f2e06084
--- /dev/null
+++ b/src/main/runtime/decorative-title-fact-emission.test.ts
@@ -0,0 +1,49 @@
+import { describe, expect, it } from 'vitest'
+import {
+ DECORATIVE_TITLE_FACT_HEARTBEAT_MS,
+ shouldEmitTitleFactForFrame
+} from './decorative-title-fact-emission'
+
+const base = {
+ decorativeOnly: true,
+ staleWorkingTitleClear: false,
+ lastEmittedAtMs: 1_000,
+ nowMs: 1_000
+}
+
+describe('shouldEmitTitleFactForFrame', () => {
+ it('always emits a frame that is not a decorative repeat', () => {
+ expect(shouldEmitTitleFactForFrame({ ...base, decorativeOnly: false })).toBe(true)
+ })
+
+ it('emits the first frame of a pane', () => {
+ expect(shouldEmitTitleFactForFrame({ ...base, lastEmittedAtMs: null })).toBe(true)
+ })
+
+ it('suppresses a decorative repeat inside the heartbeat window', () => {
+ expect(
+ shouldEmitTitleFactForFrame({ ...base, nowMs: 1_000 + DECORATIVE_TITLE_FACT_HEARTBEAT_MS - 1 })
+ ).toBe(false)
+ })
+
+ it('lets a decorative repeat through once the heartbeat window elapses', () => {
+ expect(
+ shouldEmitTitleFactForFrame({ ...base, nowMs: 1_000 + DECORATIVE_TITLE_FACT_HEARTBEAT_MS })
+ ).toBe(true)
+ })
+
+ it('never throttles a timer-synthesized stale-working clear', () => {
+ // Why: it carries a staleWorkingTitleClear flag no earlier repeat can stand in for.
+ expect(shouldEmitTitleFactForFrame({ ...base, staleWorkingTitleClear: true })).toBe(true)
+ })
+
+ it('emits after a backwards clock step instead of parking until it catches up', () => {
+ expect(shouldEmitTitleFactForFrame({ ...base, nowMs: 900 })).toBe(true)
+ })
+
+ it('keeps at least three frames inside the renderer hook-done quiet window', () => {
+ // Why: observeTitle's arriving working title is what cancels a Pi/OMP milestone `done`
+ // scheduled with HOOK_DONE_QUIET_MS = 1500. Losing that would mint a false completion.
+ expect(DECORATIVE_TITLE_FACT_HEARTBEAT_MS * 3).toBeLessThanOrEqual(1_500)
+ })
+})
diff --git a/src/main/runtime/decorative-title-fact-emission.ts b/src/main/runtime/decorative-title-fact-emission.ts
new file mode 100644
index 00000000000..d8248dc12c5
--- /dev/null
+++ b/src/main/runtime/decorative-title-fact-emission.ts
@@ -0,0 +1,38 @@
+/**
+ * Why: an agent spinner re-emits a semantically identical OSC title ~12.5x/sec (Orca's own
+ * synthetic frame timer, Pi/OMP, Claude Code, Grok), and main ships every frame to the renderer
+ * as its own `pty:sideEffect` message. Both renderer store writes already discard those frames
+ * via `isDecorativeAgentTitleFrameChange`, so the message is pure cross-process cost.
+ *
+ * Why a heartbeat and not a hard drop: `agentCompletionCoordinator.observeTitle` treats an
+ * arriving *working* title as "still working" and cancels a scheduled hook-`done` completion
+ * inside `HOOK_DONE_QUIET_MS` (1500ms). That is exactly how a Pi/OMP milestone `done` emitted
+ * mid-turn is stopped from minting a completion notification, and the frames that carry it are
+ * decorative repeats. 500ms keeps 3 frames inside that window.
+ */
+export const DECORATIVE_TITLE_FACT_HEARTBEAT_MS = 500
+
+export type DecorativeTitleFactEmissionInput = {
+ /** The frame's decorative gate key matches the previous frame's. */
+ decorativeOnly: boolean
+ /** Timer-synthesized stale-working clear — carries a flag no repeat can stand in for. */
+ staleWorkingTitleClear: boolean
+ lastEmittedAtMs: number | null
+ nowMs: number
+}
+
+export function shouldEmitTitleFactForFrame({
+ decorativeOnly,
+ staleWorkingTitleClear,
+ lastEmittedAtMs,
+ nowMs
+}: DecorativeTitleFactEmissionInput): boolean {
+ if (!decorativeOnly || staleWorkingTitleClear) {
+ return true
+ }
+ if (lastEmittedAtMs === null) {
+ return true
+ }
+ // A backwards clock step must not park the heartbeat until it catches up.
+ return nowMs < lastEmittedAtMs || nowMs - lastEmittedAtMs >= DECORATIVE_TITLE_FACT_HEARTBEAT_MS
+}
diff --git a/src/main/runtime/orca-runtime-get-unpersisted-tracked-title-for-pty.ts b/src/main/runtime/orca-runtime-get-unpersisted-tracked-title-for-pty.ts
index 38bae372176..3ece144bc24 100644
--- a/src/main/runtime/orca-runtime-get-unpersisted-tracked-title-for-pty.ts
+++ b/src/main/runtime/orca-runtime-get-unpersisted-tracked-title-for-pty.ts
@@ -1,6 +1,7 @@
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
import { OrcaRuntimeWithEmitDaemonPtyTransientFact } from './orca-runtime-emit-daemon-pty-transient-fact'
import { getDecorativeAgentTitleSignature } from '../../shared/agent-decorative-title-signature'
+import { shouldEmitTitleFactForFrame } from './decorative-title-fact-emission'
import type { RuntimePtyTitleTrackerEntry } from './runtime-terminal-state-records'
import { createTerminalTitleTracker } from '../../shared/terminal-output-side-effects'
import { detectAgentStatusFromTitle } from '../../shared/agent-detection'
@@ -64,20 +65,36 @@ export class OrcaRuntimeWithGetUnpersistedTrackedTitleForPty extends OrcaRuntime
const tracker = createTerminalTitleTracker(
{
onTitle: (normalizedTitle, rawTitle, meta) => {
- this.recordTerminalSideEffectFact(ptyId, {
- kind: 'title',
- normalizedTitle,
- rawTitle,
- ...(meta?.staleWorkingTitleClear ? { staleWorkingTitleClear: true } : {})
- })
- const changed = this.applyTrackedPtyTitle(ptyId, rawTitle, normalizedTitle, meta)
- const identityOnlyTitle = this.isLiveCursorNativeTitle(rawTitle, meta)
const live = this.ptyTitleTrackersByPtyId.get(ptyId)
const gateKey = this.makeDecorativeTitleGateKey(rawTitle, normalizedTitle)
const decorativeOnly = live?.lastMobileTitleGateKey === gateKey
if (live) {
live.lastMobileTitleGateKey = gateKey
}
+ // Why: the same gate the mobile fan-out below already uses, applied one hop earlier —
+ // a spinner frame the renderer store discards should not cost a pty:sideEffect message
+ // at all. See decorative-title-fact-emission.ts for why repeats still heartbeat.
+ const nowMs = Date.now()
+ if (
+ shouldEmitTitleFactForFrame({
+ decorativeOnly,
+ staleWorkingTitleClear: meta?.staleWorkingTitleClear === true,
+ lastEmittedAtMs: live?.lastTitleFactAtMs ?? null,
+ nowMs
+ })
+ ) {
+ if (live) {
+ live.lastTitleFactAtMs = nowMs
+ }
+ this.recordTerminalSideEffectFact(ptyId, {
+ kind: 'title',
+ normalizedTitle,
+ rawTitle,
+ ...(meta?.staleWorkingTitleClear ? { staleWorkingTitleClear: true } : {})
+ })
+ }
+ const changed = this.applyTrackedPtyTitle(ptyId, rawTitle, normalizedTitle, meta)
+ const identityOnlyTitle = this.isLiveCursorNativeTitle(rawTitle, meta)
const tracksReplicatedStatus =
live?.applyingChunk === true && this.mobileSessionTabListeners.size > 0
const titleStatus = tracksReplicatedStatus ? detectAgentStatusFromTitle(rawTitle) : null
@@ -151,6 +168,7 @@ export class OrcaRuntimeWithGetUnpersistedTrackedTitleForPty extends OrcaRuntime
tracker,
applyingChunk: false,
lastMobileTitleGateKey: null,
+ lastTitleFactAtMs: null,
chunkTouchedSessionTabs: false,
pendingFacts: [],
// Why: command-code facts exist only for the pty:sideEffect channel —
diff --git a/src/main/runtime/orca-runtime-tests/decorative-title-fact-throttle.spec.ts b/src/main/runtime/orca-runtime-tests/decorative-title-fact-throttle.spec.ts
new file mode 100644
index 00000000000..a2e35baa4aa
--- /dev/null
+++ b/src/main/runtime/orca-runtime-tests/decorative-title-fact-throttle.spec.ts
@@ -0,0 +1,110 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type { TerminalSideEffectBatch } from '../../../shared/terminal-side-effect-facts'
+import { syncSinglePty } from '../orca-runtime-test-fixtures.spec'
+import { createSideEffectRuntime } from '../orca-runtime-test-scenario-builders.spec'
+import { DECORATIVE_TITLE_FACT_HEARTBEAT_MS } from '../decorative-title-fact-emission'
+
+// Orca's own synthetic agent spinner: one frame per pane every 80ms while an agent works.
+const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
+const SPINNER_INTERVAL_MS = 80
+const EPOCH = 1_700_000_000_000
+
+type TitleFact = { kind: 'title'; normalizedTitle: string; rawTitle: string }
+
+function titleFacts(batches: TerminalSideEffectBatch[]): TitleFact[] {
+ return batches.flatMap((batch) =>
+ batch.facts.filter((fact): fact is TitleFact => fact.kind === 'title')
+ )
+}
+
+describe('decorative title fact throttle', () => {
+ beforeEach(() => {
+ vi.useFakeTimers({ toFake: ['Date'] })
+ vi.setSystemTime(new Date(EPOCH))
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ it('collapses spinner ticks with an unchanged underlying title to the heartbeat rate', () => {
+ const { runtime, batches } = createSideEffectRuntime()
+ syncSinglePty(runtime)
+
+ const ticks = 125 // 10s of Orca's 80ms synthetic spinner timer
+ for (let tick = 0; tick < ticks; tick += 1) {
+ vi.setSystemTime(new Date(EPOCH + tick * SPINNER_INTERVAL_MS))
+ runtime.ingestSyntheticTitleFrame(
+ 'pty-1',
+ `\x1b]0;${SPINNER_FRAMES[tick % SPINNER_FRAMES.length]} Claude Code\x07`
+ )
+ }
+
+ const facts = titleFacts(batches)
+ // Every frame carried the same underlying title, so the renderer learns nothing new past
+ // the heartbeat: 125 pty:sideEffect messages collapse to one per heartbeat window.
+ const elapsedMs = ticks * SPINNER_INTERVAL_MS
+ expect(facts.length).toBeLessThanOrEqual(
+ Math.ceil(elapsedMs / DECORATIVE_TITLE_FACT_HEARTBEAT_MS)
+ )
+ expect(facts.length).toBeLessThan(ticks / 5)
+ // The heartbeat must not thin out below what the renderer's 1500ms hook-done quiet window
+ // needs to cancel a milestone `done` — three working frames per window.
+ expect(facts.length).toBeGreaterThanOrEqual(Math.floor(elapsedMs / 1_500) * 3)
+ for (const fact of facts) {
+ expect(fact.normalizedTitle.endsWith('Claude Code')).toBe(true)
+ }
+ })
+
+ it('propagates a real title change on the tick it arrives, mid-heartbeat', () => {
+ const { runtime, batches } = createSideEffectRuntime()
+ syncSinglePty(runtime)
+
+ runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠋ Claude Code\x07')
+ // Two more decorative ticks — still well inside the heartbeat window, so they are dropped.
+ vi.setSystemTime(new Date(EPOCH + SPINNER_INTERVAL_MS))
+ runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠙ Claude Code\x07')
+ vi.setSystemTime(new Date(EPOCH + 2 * SPINNER_INTERVAL_MS))
+ runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠹ Claude Code\x07')
+ expect(titleFacts(batches)).toHaveLength(1)
+
+ const beforeChange = batches.length
+ vi.setSystemTime(new Date(EPOCH + 3 * SPINNER_INTERVAL_MS))
+ runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;✳ Claude Code\x07')
+
+ expect(batches.length).toBeGreaterThan(beforeChange)
+ expect(titleFacts(batches.slice(beforeChange))).toEqual([
+ { kind: 'title', normalizedTitle: '✳ Claude Code', rawTitle: '✳ Claude Code' }
+ ])
+ })
+
+ it('propagates a changed working label immediately even while the spinner rotates', () => {
+ // Why: only the spinner glyph is decoration. Grok/Pi-style label churn is real content.
+ const { runtime, batches } = createSideEffectRuntime()
+ syncSinglePty(runtime)
+
+ runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠋ Claude Code\x07')
+ vi.setSystemTime(new Date(EPOCH + SPINNER_INTERVAL_MS))
+ runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠙ Reviewing diff — Claude Code\x07')
+
+ expect(titleFacts(batches).map((fact) => fact.rawTitle)).toEqual([
+ '⠋ Claude Code',
+ '⠙ Reviewing diff — Claude Code'
+ ])
+ })
+
+ it('keeps main-side tracked title state current for every suppressed frame', () => {
+ // Why: mobile/remote snapshots read the tracked record, not the fact stream — suppressing
+ // the fact must not freeze what a phone or a paired client is shown.
+ const { runtime } = createSideEffectRuntime()
+ syncSinglePty(runtime)
+
+ runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠋ Claude Code\x07')
+ vi.setSystemTime(new Date(EPOCH + SPINNER_INTERVAL_MS))
+ runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠙ Claude Code\x07')
+
+ expect(runtime.getTerminalSideEffectSnapshot('pty-1')?.facts).toEqual([
+ { kind: 'title', normalizedTitle: '⠙ Claude Code', rawTitle: '⠙ Claude Code' }
+ ])
+ })
+})
diff --git a/src/main/runtime/orca-runtime-tests/terminal-side-effect-facts.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-side-effect-facts.spec.ts
index fb07f8a3d0e..79feca3268c 100644
--- a/src/main/runtime/orca-runtime-tests/terminal-side-effect-facts.spec.ts
+++ b/src/main/runtime/orca-runtime-tests/terminal-side-effect-facts.spec.ts
@@ -8,6 +8,7 @@ import {
syncSinglePty
} from '../orca-runtime-test-fixtures.spec'
import { createSideEffectRuntime } from '../orca-runtime-test-scenario-builders.spec'
+import { DECORATIVE_TITLE_FACT_HEARTBEAT_MS } from '../decorative-title-fact-emission'
describe('terminal side-effect fact channel', () => {
it('defers desktop-only output scanners until a headless runtime is promoted', () => {
@@ -70,53 +71,66 @@ describe('terminal side-effect fact channel', () => {
expect(events).toHaveLength(1)
})
- it('bounds decorative title delivery per paired client without reducing local frames', () => {
- const { runtime, batches } = createSideEffectRuntime()
- const firstClientEvents: RuntimeClientEvent[] = []
- runtime.attachWindow(1)
- runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
- runtime.onClientEvent((event) => firstClientEvents.push(event))
+ it('bounds decorative title delivery per paired client below the local heartbeat', () => {
+ // Why the clock steps: main throttles decorative repeats on the local fact stream, so each
+ // round must clear that heartbeat for the per-client gate to be what collapses them here.
+ vi.useFakeTimers({ toFake: ['Date'] })
+ try {
+ const { runtime, batches } = createSideEffectRuntime()
+ const firstClientEvents: RuntimeClientEvent[] = []
+ runtime.attachWindow(1)
+ runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
+ runtime.onClientEvent((event) => firstClientEvents.push(event))
- const ptyIds = Array.from({ length: 64 }, (_, index) => `pty-remote-${index}`)
- const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
- for (const ptyId of ptyIds) {
- runtime.ingestSyntheticTitleFrame(ptyId, `\x1b]0;${frames[0]} Cursor Agent\x07`)
- }
- firstClientEvents.length = 0
-
- for (const frame of frames.slice(1)) {
- for (const ptyId of ptyIds) {
- runtime.ingestSyntheticTitleFrame(ptyId, `\x1b]0;${frame} Cursor Agent\x07`)
+ const ptyIds = Array.from({ length: 64 }, (_, index) => `pty-remote-${index}`)
+ const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
+ const stepPastHeartbeat = (): void => {
+ vi.setSystemTime(new Date(Date.now() + DECORATIVE_TITLE_FACT_HEARTBEAT_MS))
}
+ for (const ptyId of ptyIds) {
+ runtime.ingestSyntheticTitleFrame(ptyId, `\x1b]0;${frames[0]} Cursor Agent\x07`)
+ }
+ firstClientEvents.length = 0
+
+ for (const frame of frames.slice(1)) {
+ stepPastHeartbeat()
+ for (const ptyId of ptyIds) {
+ runtime.ingestSyntheticTitleFrame(ptyId, `\x1b]0;${frame} Cursor Agent\x07`)
+ }
+ }
+
+ expect(firstClientEvents).toEqual([])
+ expect(batches).toHaveLength(ptyIds.length * frames.length)
+
+ const bellChunk = `\x1b]0;${frames.at(-1)} Cursor Agent\x07\x07`
+ runtime.onPtyData(ptyIds[0], bellChunk, 1)
+ expect(firstClientEvents).toEqual([
+ expect.objectContaining({
+ type: 'terminalSideEffects',
+ batch: expect.objectContaining({ facts: [{ kind: 'bell' }] })
+ })
+ ])
+ firstClientEvents.length = 0
+
+ const secondClientEvents: RuntimeClientEvent[] = []
+ runtime.onClientEvent((event) => secondClientEvents.push(event))
+ stepPastHeartbeat()
+ for (const ptyId of ptyIds) {
+ runtime.ingestSyntheticTitleFrame(ptyId, `\x1b]0;${frames[0]} Cursor Agent\x07`)
+ }
+
+ expect(firstClientEvents).toEqual([])
+ expect(secondClientEvents).toHaveLength(ptyIds.length)
+
+ // A real title change is never throttled — no clock step needed.
+ for (const ptyId of ptyIds) {
+ runtime.ingestSyntheticTitleFrame(ptyId, '\x1b]0;Cursor ready\x07')
+ }
+ expect(firstClientEvents).toHaveLength(ptyIds.length)
+ expect(secondClientEvents).toHaveLength(ptyIds.length * 2)
+ } finally {
+ vi.useRealTimers()
}
-
- expect(firstClientEvents).toEqual([])
- expect(batches).toHaveLength(ptyIds.length * frames.length)
-
- const bellChunk = `\x1b]0;${frames.at(-1)} Cursor Agent\x07\x07`
- runtime.onPtyData(ptyIds[0], bellChunk, 1)
- expect(firstClientEvents).toEqual([
- expect.objectContaining({
- type: 'terminalSideEffects',
- batch: expect.objectContaining({ facts: [{ kind: 'bell' }] })
- })
- ])
- firstClientEvents.length = 0
-
- const secondClientEvents: RuntimeClientEvent[] = []
- runtime.onClientEvent((event) => secondClientEvents.push(event))
- for (const ptyId of ptyIds) {
- runtime.ingestSyntheticTitleFrame(ptyId, `\x1b]0;${frames[0]} Cursor Agent\x07`)
- }
-
- expect(firstClientEvents).toEqual([])
- expect(secondClientEvents).toHaveLength(ptyIds.length)
-
- for (const ptyId of ptyIds) {
- runtime.ingestSyntheticTitleFrame(ptyId, '\x1b]0;Cursor ready\x07')
- }
- expect(firstClientEvents).toHaveLength(ptyIds.length)
- expect(secondClientEvents).toHaveLength(ptyIds.length * 2)
})
it('omits terminalSideEffects from non-consuming listeners while other events still flow', () => {
diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts
index 054d7f09316..f8b12ba4a9d 100644
--- a/src/main/runtime/orca-runtime.test.ts
+++ b/src/main/runtime/orca-runtime.test.ts
@@ -30,6 +30,7 @@ await import('./orca-runtime-tests/pty-title-status.spec')
await import('./orca-runtime-tests/terminal-side-effect-facts.spec')
await import('./orca-runtime-tests/terminal-side-effect-facts-part-02.spec')
await import('./orca-runtime-tests/terminal-side-effect-facts-part-03.spec')
+await import('./orca-runtime-tests/decorative-title-fact-throttle.spec')
await import('./orca-runtime-tests/headless-snapshots.spec')
await import('./orca-runtime-tests/headless-snapshots-part-02.spec')
await import('./orca-runtime-tests/agent-status-and-waits.spec')
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/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/main/runtime/runtime-terminal-state-records.ts b/src/main/runtime/runtime-terminal-state-records.ts
index 68be05b8ad1..6ccb4ed82bb 100644
--- a/src/main/runtime/runtime-terminal-state-records.ts
+++ b/src/main/runtime/runtime-terminal-state-records.ts
@@ -89,6 +89,8 @@ export type RuntimePtyTitleTrackerEntry = {
tracker: TerminalTitleTracker
applyingChunk: boolean
lastMobileTitleGateKey: string | null
+ /** When the last title fact was emitted — throttles decorative-only repeats. */
+ lastTitleFactAtMs: number | null
chunkTouchedSessionTabs: boolean
pendingFacts: TerminalSideEffectFact[]
commandCodeDetector: { observe: (data: string) => boolean } | null
diff --git a/src/main/startup/desktop-startup-ordering.test.ts b/src/main/startup/desktop-startup-ordering.test.ts
index e432ed383d2..fc381f15714 100644
--- a/src/main/startup/desktop-startup-ordering.test.ts
+++ b/src/main/startup/desktop-startup-ordering.test.ts
@@ -183,6 +183,48 @@ describe('startup ordering', () => {
)
})
+ it('keeps the git-environment barrier off the PTY startup services', () => {
+ const barrierSource = readFileSync(
+ join(process.cwd(), 'src/main/startup/main-process-ipc-bootstrap.ts'),
+ 'utf8'
+ )
+ const launchSource = readFileSync(
+ join(process.cwd(), 'src/main/startup/main-process-runtime-launch.ts'),
+ 'utf8'
+ )
+ const gitBarrierStart = barrierSource.indexOf(
+ "ipcMain.handle('app:awaitGitEnvironmentStartupBarrier'"
+ )
+ const gitBarrierEnd = barrierSource.indexOf(
+ "'app:prepareTerminalStartupRestoration'",
+ gitBarrierStart
+ )
+ expect(gitBarrierStart).toBeGreaterThanOrEqual(0)
+ expect(gitBarrierEnd).toBeGreaterThan(gitBarrierStart)
+ const gitBarrier = barrierSource.slice(gitBarrierStart, gitBarrierEnd)
+ // The git environment fence is shell PATH + WSL registration; a daemon PTY provider or a
+ // hook-server bind here puts terminal startup back in front of worktree hydration.
+ expect(gitBarrier).toContain('state.shellPathReady')
+ expect(gitBarrier).toContain('state.managedWslCliStartupBarrierReady')
+ expect(gitBarrier).not.toContain('firstWindowStartupServicesReady')
+ // The published promise must be the same one the terminal startup services wait on.
+ expect(launchSource).toContain('state.shellPathReady = shellPathReady')
+ expect(launchSource.indexOf('state.shellPathReady = shellPathReady')).toBeLessThan(
+ launchSource.indexOf('await launchDesktopMode(')
+ )
+ // Terminal restoration itself must still fence on the first-window services.
+ const restorationStart = barrierSource.indexOf(
+ "ipcMain.handle('app:prepareTerminalStartupRestoration'"
+ )
+ const restorationEnd = barrierSource.indexOf(
+ "'app:recoverLegacyWorkerTerminalsForRendererStartup'",
+ restorationStart
+ )
+ expect(barrierSource.slice(restorationStart, restorationEnd)).toContain(
+ 'state.firstWindowStartupServicesReady'
+ )
+ })
+
it('reconciles retained Codex homes after authoritative daemon inventory', () => {
const source = readFileSync(
join(process.cwd(), 'src/main/startup/main-process-pty-startup.ts'),
diff --git a/src/main/startup/main-process-ipc-bootstrap.ts b/src/main/startup/main-process-ipc-bootstrap.ts
index 89be84d2119..918fd844364 100644
--- a/src/main/startup/main-process-ipc-bootstrap.ts
+++ b/src/main/startup/main-process-ipc-bootstrap.ts
@@ -11,6 +11,13 @@ export function registerMainProcessIpcHandlers(): void {
state.managedWslCliStartupBarrierReady
])
})
+ // Why separate from the first-window barrier: host Git needs the shell-PATH
+ // generation and the managed WSL CLI registration, not a daemon PTY provider
+ // or a hook-server bind. Bundling them made worktree hydration wait on a
+ // terminal service it never calls.
+ ipcMain.handle('app:awaitGitEnvironmentStartupBarrier', async () => {
+ await Promise.all([state.shellPathReady, state.managedWslCliStartupBarrierReady])
+ })
ipcMain.handle('app:prepareTerminalStartupRestoration', async () => {
await Promise.all([
state.firstWindowStartupServicesReady,
diff --git a/src/main/startup/main-process-quit.ts b/src/main/startup/main-process-quit.ts
index 61203bc3164..a4149e13ba7 100644
--- a/src/main/startup/main-process-quit.ts
+++ b/src/main/startup/main-process-quit.ts
@@ -24,6 +24,7 @@ import { shutdownObservability } from '../observability'
import { isQuittingForUpdate } from '../updater'
import { recordUpdaterLifecycle } from '../updater-lifecycle-diagnostics'
import { stopTccPromptNotice } from '../macos-tcc-prompt-notice'
+import { cancelHistoryGc } from '../terminal-history-gc'
import { shouldQuitWhenAllWindowsClosed } from './window-all-closed-quit-policy'
import { mainProcessState as state } from './main-process-state'
import { isDevParentShutdownRequested } from './configure-process'
@@ -82,6 +83,9 @@ function installBeforeQuitHandler(): void {
state.repoMaintenanceShutdown = awaitPackedRefsLockRelease()
// Why: defer PTY cleanup to will-quit so the renderer captures scrollback before PTY-exit events unmount TerminalPane (dropping its capture callbacks).
state.rateLimits?.stop()
+ // Why safe on a vetoed quit: background history GC is idempotent and re-scheduled next launch,
+ // so abandoning the walk here only costs one deferred sweep, never a half-applied prune.
+ cancelHistoryGc()
})
}
diff --git a/src/main/startup/main-process-runtime-launch.ts b/src/main/startup/main-process-runtime-launch.ts
index 7e5f278fdc5..9df28ecea8c 100644
--- a/src/main/startup/main-process-runtime-launch.ts
+++ b/src/main/startup/main-process-runtime-launch.ts
@@ -289,6 +289,9 @@ export async function initializeMainProcessRuntimeLaunch(
state.serveOptions = serveOptions
const runtimeRpc = installRuntimeRpc(runtime, serveOptions)
const shellPathReady = shellPathHydration.whenReady()
+ // Why published: the renderer's git-environment barrier must fence on the same
+ // generation the terminal startup services wait for, not a later re-read.
+ state.shellPathReady = shellPathReady
let desktopWindow: BrowserWindow | null = null
if (process.platform === 'win32' && app.isPackaged && !serveOptions) {
const desktopStartup = startWindowsDesktopBeforeShellPathReady({
diff --git a/src/main/terminal-history-gc.test.ts b/src/main/terminal-history-gc.test.ts
new file mode 100644
index 00000000000..ece0d9af25f
--- /dev/null
+++ b/src/main/terminal-history-gc.test.ts
@@ -0,0 +1,444 @@
+import {
+ existsSync,
+ mkdirSync,
+ mkdtempSync,
+ readdirSync,
+ rmSync,
+ statSync,
+ writeFileSync
+} from 'node:fs'
+import { tmpdir } from 'node:os'
+import { basename, join } from 'node:path'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup'
+
+const { removeHostTreeMock } = vi.hoisted(() => ({
+ removeHostTreeMock: vi.fn<(dir: string) => Promise>()
+}))
+
+// Why intercept rather than no-op: the tombstone path each prune produces is the decision this
+// suite reads, but the drain re-queues any tombstone still on disk after a "successful" removal,
+// so the stub has to really delete or the queue never terminates.
+vi.mock('./host-tree-removal', () => ({
+ removeHostTree: removeHostTreeMock
+}))
+
+import { readHistoryMeta } from './terminal-history'
+import {
+ cancelPendingHistoryTreeRemovalRetries,
+ flushPendingWorktreeHistoryDeletions
+} from './terminal-history-deletion'
+import { cancelHistoryGc, runHistoryGc, scheduleHistoryGc } from './terminal-history-gc'
+
+const GC_MIN_AGE_MS = 5 * 60 * 1000
+const PENDING_DELETE_DIR_NAME = '.pending-delete'
+const LIVE_WORKTREE_ID = 'repo-1::/path/live-wt'
+const DEAD_WORKTREE_ID = 'repo-1::/path/dead-wt'
+
+let userDataDir: string
+let historyRoot: string
+let originalXdgDataHome: string | undefined
+
+/**
+ * The enumeration this exercises used to be a synchronous walk. Its replacement is an async
+ * fixed-worker pass, so the whole safety net is that both reach the same prune decision over a
+ * realistic tree: over-pruning here destroys scrollback the user still expects to have.
+ *
+ * A verbatim port of the pre-change decision logic, reporting names instead of deleting.
+ */
+function referenceSyncPruneDecisions(root: string, liveWorktreeIds: Set): string[] {
+ const decisions: string[] = []
+ if (!existsSync(root)) {
+ return decisions
+ }
+ const now = Date.now()
+ for (const entry of readdirSync(root)) {
+ if (entry === PENDING_DELETE_DIR_NAME) {
+ continue
+ }
+ const entryPath = join(root, entry)
+ try {
+ const stats = statSync(entryPath)
+ if (!stats.isDirectory()) {
+ continue
+ }
+ try {
+ for (const file of readdirSync(entryPath)) {
+ statSync(join(entryPath, file))
+ }
+ } catch {
+ // Skip size estimation on error.
+ }
+ if (!existsSync(join(entryPath, 'meta.json'))) {
+ continue
+ }
+ const meta = readHistoryMeta(entryPath)
+ if (!meta?.worktreeId) {
+ continue
+ }
+ if (!liveWorktreeIds.has(meta.worktreeId)) {
+ if (meta.createdAt && now - new Date(meta.createdAt).getTime() < GC_MIN_AGE_MS) {
+ continue
+ }
+ decisions.push(entry)
+ }
+ } catch {
+ // Skip individual entries that fail.
+ }
+ }
+ return decisions
+}
+
+function seedDir(name: string, files: Record): string {
+ const dir = join(historyRoot, name)
+ mkdirSync(dir, { recursive: true })
+ for (const [file, contents] of Object.entries(files)) {
+ writeFileSync(join(dir, file), contents)
+ }
+ return dir
+}
+
+function meta(worktreeId: string | undefined, ageMs: number | null): string {
+ return JSON.stringify({
+ ...(worktreeId === undefined ? {} : { worktreeId }),
+ ...(ageMs === null ? {} : { createdAt: new Date(Date.now() - ageMs).toISOString() })
+ })
+}
+
+const OLD = GC_MIN_AGE_MS * 2
+
+/** Every decision shape the walk has to get right, including the ones that must never prune. */
+function seedDecisionMatrix(): void {
+ seedDir('live-old', { 'meta.json': meta(LIVE_WORKTREE_ID, OLD), zsh_history: 'a' })
+ seedDir('live-young', { 'meta.json': meta(LIVE_WORKTREE_ID, 0) })
+ seedDir('orphan-old', { 'meta.json': meta(DEAD_WORKTREE_ID, OLD), zsh_history: 'b' })
+ seedDir('orphan-no-createdat', { 'meta.json': meta(DEAD_WORKTREE_ID, null) })
+ seedDir('orphan-unparseable-createdat', {
+ 'meta.json': JSON.stringify({ worktreeId: DEAD_WORKTREE_ID, createdAt: 'not-a-date' })
+ })
+ seedDir('orphan-young', { 'meta.json': meta(DEAD_WORKTREE_ID, 1_000) })
+ seedDir('no-meta', { zsh_history: 'c' })
+ seedDir('malformed-meta', { 'meta.json': '{ this is not json' })
+ seedDir('truncated-meta', { 'meta.json': `{"worktreeId":"${DEAD_WORKTREE_ID}` })
+ seedDir('empty-meta', { 'meta.json': '{}' })
+ seedDir('array-meta', { 'meta.json': `["${DEAD_WORKTREE_ID}"]` })
+ seedDir('null-meta', { 'meta.json': 'null' })
+ seedDir('no-worktree-id', { 'meta.json': meta(undefined, OLD) })
+ seedDir('oversize-meta', {
+ 'meta.json': JSON.stringify({
+ worktreeId: DEAD_WORKTREE_ID,
+ createdAt: new Date(Date.now() - OLD).toISOString(),
+ pad: 'x'.repeat(64 * 1024)
+ })
+ })
+ // meta.json as a directory: stat succeeds, the read does not.
+ mkdirSync(join(historyRoot, 'meta-is-a-dir', 'meta.json'), { recursive: true })
+ seedDir('empty-dir', {})
+ // A plain file at the root is not a history directory.
+ writeFileSync(join(historyRoot, 'stray-file'), 'x')
+ mkdirSync(join(historyRoot, PENDING_DELETE_DIR_NAME), { recursive: true })
+}
+
+/** Enough entries to run several worker batches and cross the cooperative-yield boundary. */
+function seedBulk(count: number, orphanEvery: number): void {
+ for (let i = 0; i < count; i++) {
+ const orphan = i % orphanEvery === 0
+ seedDir(`bulk-${i}`, {
+ 'meta.json': meta(orphan ? `${DEAD_WORKTREE_ID}-${i}` : LIVE_WORKTREE_ID, OLD),
+ zsh_history: `entry-${i}`,
+ bash_history: `entry-${i}`
+ })
+ }
+}
+
+function survivingDirs(): Set {
+ return new Set(
+ readdirSync(historyRoot).filter(
+ (entry) =>
+ entry !== PENDING_DELETE_DIR_NAME && statSync(join(historyRoot, entry)).isDirectory()
+ )
+ )
+}
+
+beforeEach(() => {
+ userDataDir = mkdtempSync(join(tmpdir(), 'orca-history-gc-'))
+ historyRoot = join(userDataDir, 'terminal-history')
+ mkdirSync(historyRoot, { recursive: true })
+ installFakeAppEnvironment({ getPath: () => userDataDir })
+ // Why: the fish sweep resolves a real user data dir otherwise, and would delete the
+ // developer's own orca fish history files while this suite runs.
+ originalXdgDataHome = process.env.XDG_DATA_HOME
+ process.env.XDG_DATA_HOME = userDataDir
+ removeHostTreeMock.mockReset()
+ removeHostTreeMock.mockImplementation(async (dir) => {
+ rmSync(dir, { recursive: true, force: true })
+ })
+})
+
+/** Tombstone paths the pass condemned, with the `..` rename suffix stripped. */
+function tombstonedNames(): Set {
+ return new Set(
+ removeHostTreeMock.mock.calls.map(([dir]) => basename(dir).split('.').slice(0, -2).join('.'))
+ )
+}
+
+afterEach(async () => {
+ cancelHistoryGc()
+ vi.useRealTimers()
+ await flushPendingWorktreeHistoryDeletions()
+ cancelPendingHistoryTreeRemovalRetries()
+ if (originalXdgDataHome === undefined) {
+ delete process.env.XDG_DATA_HOME
+ } else {
+ process.env.XDG_DATA_HOME = originalXdgDataHome
+ }
+ rmSync(userDataDir, { recursive: true, force: true })
+})
+
+describe('history GC prune decisions', () => {
+ it('prunes exactly the set the synchronous walk chose', async () => {
+ seedDecisionMatrix()
+ seedBulk(200, 7)
+ const live = new Set([LIVE_WORKTREE_ID])
+
+ const before = survivingDirs()
+ const expected = new Set(referenceSyncPruneDecisions(historyRoot, live))
+
+ await runHistoryGc(live)
+
+ const after = survivingDirs()
+ const actual = new Set([...before].filter((entry) => !after.has(entry)))
+
+ expect(expected.size).toBeGreaterThan(0)
+ expect([...actual].sort()).toEqual([...expected].sort())
+ })
+
+ it('keeps every directory whose ownership cannot be established', async () => {
+ seedDecisionMatrix()
+
+ await runHistoryGc(new Set([LIVE_WORKTREE_ID]))
+
+ const after = survivingDirs()
+ for (const kept of [
+ 'live-old',
+ 'live-young',
+ 'orphan-young',
+ 'no-meta',
+ 'malformed-meta',
+ 'truncated-meta',
+ 'empty-meta',
+ 'array-meta',
+ 'null-meta',
+ 'no-worktree-id',
+ 'oversize-meta',
+ 'meta-is-a-dir',
+ 'empty-dir'
+ ]) {
+ expect(after.has(kept)).toBe(true)
+ }
+ expect(after.has('orphan-old')).toBe(false)
+ expect(after.has('orphan-no-createdat')).toBe(false)
+ expect(after.has('orphan-unparseable-createdat')).toBe(false)
+ expect(existsSync(join(historyRoot, 'stray-file'))).toBe(true)
+ })
+
+ it('refuses to prune anything when the live set is empty', async () => {
+ seedDecisionMatrix()
+
+ await runHistoryGc(new Set())
+
+ expect(survivingDirs().has('orphan-old')).toBe(true)
+ expect(readdirSync(join(historyRoot, PENDING_DELETE_DIR_NAME))).toEqual([])
+ })
+
+ it('does not throw when the history root does not exist', async () => {
+ rmSync(historyRoot, { recursive: true, force: true })
+ await expect(runHistoryGc(new Set([LIVE_WORKTREE_ID]))).resolves.toBeUndefined()
+ })
+
+ it('tombstones orphans instead of removing them on the calling thread', async () => {
+ seedDecisionMatrix()
+
+ await runHistoryGc(new Set([LIVE_WORKTREE_ID]))
+
+ // The recursive rm only ever sees a path already renamed into the tombstone queue.
+ for (const [dir] of removeHostTreeMock.mock.calls) {
+ expect(dir).toContain(PENDING_DELETE_DIR_NAME)
+ }
+ expect(tombstonedNames().has('orphan-old')).toBe(true)
+ })
+
+ it('drains pre-existing tombstones without scanning them as worktrees', async () => {
+ seedDecisionMatrix()
+ const leftover = join(historyRoot, PENDING_DELETE_DIR_NAME, 'abc123.1700000000000.deadbeef')
+ mkdirSync(leftover, { recursive: true })
+
+ await runHistoryGc(new Set([LIVE_WORKTREE_ID]))
+
+ expect(removeHostTreeMock).toHaveBeenCalledWith(expect.stringContaining('abc123.1700000000000'))
+ })
+
+ it('continues the pass after one orphan tombstone fails', async () => {
+ seedDir('orphan-a', { 'meta.json': meta(`${DEAD_WORKTREE_ID}-a`, OLD) })
+ seedDir('orphan-b', { 'meta.json': meta(`${DEAD_WORKTREE_ID}-b`, OLD) })
+ // A file where the tombstone root must be makes the first rename fail; mkdir cannot replace it.
+ writeFileSync(join(historyRoot, PENDING_DELETE_DIR_NAME), 'not a directory')
+
+ await expect(runHistoryGc(new Set([LIVE_WORKTREE_ID]))).resolves.toBeUndefined()
+
+ // Nothing could be tombstoned, and both entries survive for a later pass to reclaim.
+ expect(survivingDirs()).toEqual(new Set(['orphan-a', 'orphan-b']))
+ })
+})
+
+describe('history GC concurrency behaviour', () => {
+ it('joins a second call to the in-flight pass instead of walking twice', async () => {
+ seedDecisionMatrix()
+ const live = new Set([LIVE_WORKTREE_ID])
+
+ const first = runHistoryGc(live)
+ const second = runHistoryGc(live)
+ expect(second).toBe(first)
+
+ await first
+ // Each rename produces its own tombstone, so a second overlapping walk would condemn twice.
+ const orphanRemovals = removeHostTreeMock.mock.calls.filter(([dir]) =>
+ basename(dir).startsWith('orphan-old.')
+ )
+ expect(orphanRemovals).toHaveLength(1)
+ })
+
+ it('starts a fresh pass once the previous one has settled', async () => {
+ seedDecisionMatrix()
+ const live = new Set([LIVE_WORKTREE_ID])
+ await runHistoryGc(live)
+ const second = runHistoryGc(live)
+ await expect(second).resolves.toBeUndefined()
+ })
+
+ it('stops an in-flight walk on cancel without pruning', async () => {
+ seedDecisionMatrix()
+ seedBulk(300, 3)
+ const before = survivingDirs()
+
+ const pass = runHistoryGc(new Set([LIVE_WORKTREE_ID]))
+ // Cancelling before the root listing resolves means no entry is ever visited.
+ cancelHistoryGc()
+ await pass
+
+ expect(survivingDirs()).toEqual(before)
+ })
+
+ it('does not run a scheduled pass that was cancelled while resolving live worktrees', async () => {
+ seedDecisionMatrix()
+ vi.useFakeTimers()
+ let resolveLiveIds: (ids: Set) => void = () => {}
+ scheduleHistoryGc(
+ () =>
+ new Promise>((resolve) => {
+ resolveLiveIds = resolve
+ })
+ )
+
+ await vi.advanceTimersByTimeAsync(10_000)
+ cancelHistoryGc()
+ resolveLiveIds(new Set([LIVE_WORKTREE_ID]))
+ await vi.advanceTimersByTimeAsync(0)
+
+ expect(survivingDirs().has('orphan-old')).toBe(true)
+ })
+
+ it('coalesces duplicate scheduled startup GC calls', async () => {
+ vi.useFakeTimers()
+ const getLiveWorktreeIds = vi.fn().mockResolvedValue(new Set())
+
+ scheduleHistoryGc(getLiveWorktreeIds)
+ scheduleHistoryGc(getLiveWorktreeIds)
+ await vi.advanceTimersByTimeAsync(10_000)
+
+ expect(getLiveWorktreeIds).toHaveBeenCalledTimes(1)
+ })
+})
+
+describe('history GC races an async walk introduces', () => {
+ it('survives a directory removed while the walk is in flight', async () => {
+ seedDecisionMatrix()
+ seedBulk(300, 5)
+ const live = new Set([LIVE_WORKTREE_ID])
+ const vanishing = ['bulk-11', 'bulk-77', 'bulk-201']
+
+ const pass = runHistoryGc(live)
+ for (const name of vanishing) {
+ rmSync(join(historyRoot, name), { recursive: true, force: true })
+ }
+ await expect(pass).resolves.toBeUndefined()
+
+ // Every live directory the racer did not touch is still there.
+ expect(survivingDirs().has('live-old')).toBe(true)
+ expect(survivingDirs().has('bulk-1')).toBe(true)
+ for (const name of vanishing) {
+ expect(existsSync(join(historyRoot, name))).toBe(false)
+ }
+ })
+
+ it('never prunes a directory whose meta.json is half-written when the walk reads it', async () => {
+ seedDir('being-written', {})
+ seedBulk(200, 5)
+ const live = new Set([LIVE_WORKTREE_ID])
+
+ const pass = runHistoryGc(live)
+ writeFileSync(
+ join(historyRoot, 'being-written', 'meta.json'),
+ `{"worktreeId":"${DEAD_WORKTREE_ID}","created`
+ )
+ await pass
+
+ expect(survivingDirs().has('being-written')).toBe(true)
+ })
+
+ it('prunes a directory whose meta.json arrived after the directory did', async () => {
+ seedDir('late-meta', {})
+ writeFileSync(
+ join(historyRoot, 'late-meta', 'meta.json'),
+ meta(`${DEAD_WORKTREE_ID}-late`, OLD)
+ )
+
+ await runHistoryGc(new Set([LIVE_WORKTREE_ID]))
+
+ expect(survivingDirs().has('late-meta')).toBe(false)
+ })
+})
+
+describe('history GC main-thread occupancy', () => {
+ it('yields to timers throughout the walk instead of blocking on it', async () => {
+ seedBulk(1_200, 40)
+ const live = new Set([LIVE_WORKTREE_ID])
+
+ const ticks = { sync: 0, async: 0 }
+ let maxAsyncGapMs = 0
+
+ // The pre-change walk is the control: a synchronous pass over the same tree cannot tick at all.
+ const syncTimer = setInterval(() => {
+ ticks.sync += 1
+ }, 4)
+ referenceSyncPruneDecisions(historyRoot, live)
+ clearInterval(syncTimer)
+
+ let last = performance.now()
+ const asyncTimer = setInterval(() => {
+ const now = performance.now()
+ maxAsyncGapMs = Math.max(maxAsyncGapMs, now - last - 4)
+ last = now
+ ticks.async += 1
+ }, 4)
+ await runHistoryGc(live)
+ clearInterval(asyncTimer)
+
+ // A synchronous pass cannot tick at all, however long it takes.
+ expect(ticks.sync).toBe(0)
+ expect(ticks.async).toBeGreaterThan(5)
+ // Generous because shared CI runners stall an idle timer by tens of ms on their own; the
+ // failure this guards against is a whole-walk block, which is seconds.
+ expect(maxAsyncGapMs).toBeLessThan(2_000)
+ })
+})
diff --git a/src/main/terminal-history-gc.ts b/src/main/terminal-history-gc.ts
index d67e3a7dd5c..37467c89423 100644
--- a/src/main/terminal-history-gc.ts
+++ b/src/main/terminal-history-gc.ts
@@ -1,5 +1,5 @@
import { join } from 'node:path'
-import { existsSync, readdirSync, statSync } from 'node:fs'
+import { readdir, stat } from 'node:fs/promises'
import {
getHistoryRoot,
listWslHistoryRoots,
@@ -9,9 +9,11 @@ import {
schedulePendingHistoryTreeRemovals,
scheduleWorktreeHistoryTreeDeletion
} from './terminal-history-deletion'
-import { readHistoryMeta } from './terminal-history'
+import { readHistoryMetaAsync } from './terminal-history'
import { resolveFishHistoryDir, sweepOrphanedFishHistoryFiles } from './fish-history-session'
import { hashWorktreeId } from './terminal-history-id'
+import { forEachWithConcurrency } from '../shared/map-with-concurrency'
+import { yieldToEventLoop } from '../shared/event-loop-yield'
// Why 5 minutes: GC runs ~10s after startup, and the live-worktree snapshot is
// taken just before. A worktree created between the snapshot and GC execution
@@ -20,100 +22,136 @@ import { hashWorktreeId } from './terminal-history-id'
// to cover any realistic snapshot-to-scan delay.
const GC_MIN_AGE_MS = 5 * 60 * 1000
-let scheduledHistoryGcTimer: ReturnType | null = null
-let historyGcRunning = false
+// Why a fixed worker pool over a frontier and not per-entry promise fan-out: a real
+// history root holds thousands of directories, and starting every one at once queues
+// tens of thousands of libuv requests before the first completes. 16 is deep enough to
+// keep the default 4-thread pool saturated without monopolising the disk during startup.
+const HISTORY_GC_SCAN_CONCURRENCY = 16
+// Why yield at all when every step already awaits I/O: a fully cached root resolves each
+// await in a microtask, which never returns to the macrotask queue. This bounds that run.
+const HISTORY_GC_YIELD_EVERY = 32
-/** Scan a single history root directory, pruning orphaned entries.
- * Returns { totalDirs, orphaned, pruned, totalSizeKB }. */
-function gcScanRoot(
- root: string,
- liveWorktreeIds: Set
-): {
+let scheduledHistoryGcTimer: ReturnType | null = null
+let historyGcStarting = false
+let historyGcCancelled = false
+let activeHistoryGc: Promise | null = null
+let activeHistoryGcAbort: AbortController | null = null
+
+type GcRootScan = {
totalDirs: number
orphaned: number
pruned: number
totalSizeKB: number
/** Every fish data dir a meta.json in this root names, for the orphan sweep. */
fishHistoryDirs: Set
-} {
- const result = {
+}
+
+/** Inspect one history directory, tombstoning it when its worktree is gone. */
+async function gcScanEntry(
+ root: string,
+ entry: string,
+ liveWorktreeIds: Set,
+ now: number,
+ result: GcRootScan
+): Promise {
+ const entryPath = join(root, entry)
+ try {
+ const stats = await stat(entryPath)
+ if (!stats.isDirectory()) {
+ return
+ }
+ result.totalDirs++
+
+ // Estimate directory size from meta.json + history files.
+ // Why a local accumulator: `result.totalSizeKB += `
+ // reads the field before suspending and writes back a stale sum once workers interleave.
+ let dirSizeKB = 0
+ try {
+ for (const file of await readdir(entryPath)) {
+ dirSizeKB += Math.ceil((await stat(join(entryPath, file))).size / 1024)
+ }
+ } catch {
+ // Skip size estimation on error, keeping whatever was measured first.
+ }
+ result.totalSizeKB += dirSizeKB
+
+ // A missing, truncated, oversized or malformed meta.json reads back as null, and a
+ // null meta is never pruned — an entry whose ownership we cannot establish is kept.
+ const meta = await readHistoryMetaAsync(entryPath)
+ if (meta?.fishHistoryDir) {
+ result.fishHistoryDirs.add(meta.fishHistoryDir)
+ }
+ if (!meta?.worktreeId) {
+ return
+ }
+
+ if (!liveWorktreeIds.has(meta.worktreeId)) {
+ // Why: avoid a TOCTOU race where a worktree is created after the
+ // live-ID snapshot but before GC runs. Directories younger than
+ // GC_MIN_AGE_MS are presumed still live and skipped.
+ if (meta.createdAt) {
+ const ageMs = now - new Date(meta.createdAt).getTime()
+ if (ageMs < GC_MIN_AGE_MS) {
+ return
+ }
+ }
+
+ result.orphaned++
+ // Why: a large orphaned tree recursive-rm'd here would stall the main process ~10s after
+ // launch — the same freeze the explicit-delete path already tombstones its way out of.
+ if (scheduleWorktreeHistoryTreeDeletion(entryPath, root)) {
+ result.pruned++
+ console.log(`[pty:history:gc] Pruned orphaned history: ${meta.worktreeId}`)
+ }
+ }
+ } catch {
+ // Skip individual entries that fail.
+ }
+}
+
+/** Scan a single history root directory, pruning orphaned entries. */
+async function gcScanRoot(
+ root: string,
+ liveWorktreeIds: Set,
+ signal: AbortSignal
+): Promise {
+ const result: GcRootScan = {
totalDirs: 0,
orphaned: 0,
pruned: 0,
totalSizeKB: 0,
fishHistoryDirs: new Set()
}
- if (!existsSync(root)) {
+
+ let entries: string[]
+ try {
+ entries = await readdir(root)
+ } catch {
+ // Absent or unreadable root: nothing to collect.
return result
}
const now = Date.now()
+ // Why: pending-delete is a tombstone queue drained asynchronously, not a live worktree hash.
+ const frontier = entries.filter((entry) => entry !== PENDING_DELETE_DIR_NAME)
- for (const entry of readdirSync(root)) {
- // Why: pending-delete is a tombstone queue drained asynchronously, not a live worktree hash.
- if (entry === PENDING_DELETE_DIR_NAME) {
- continue
+ await forEachWithConcurrency(
+ frontier,
+ HISTORY_GC_SCAN_CONCURRENCY,
+ async (entry, index): Promise => {
+ if (signal.aborted) {
+ return
+ }
+ await gcScanEntry(root, entry, liveWorktreeIds, now, result)
+ if (index % HISTORY_GC_YIELD_EVERY === HISTORY_GC_YIELD_EVERY - 1) {
+ await yieldToEventLoop()
+ }
}
- const entryPath = join(root, entry)
- try {
- const stat = statSync(entryPath)
- if (!stat.isDirectory()) {
- continue
- }
- result.totalDirs++
-
- // Estimate directory size from meta.json + history files.
- try {
- for (const file of readdirSync(entryPath)) {
- result.totalSizeKB += Math.ceil(statSync(join(entryPath, file)).size / 1024)
- }
- } catch {
- // Skip size estimation on error.
- }
-
- const metaPath = join(entryPath, 'meta.json')
- if (!existsSync(metaPath)) {
- // No meta.json — can't determine ownership, skip.
- continue
- }
-
- const meta = readHistoryMeta(entryPath)
- if (meta?.fishHistoryDir) {
- result.fishHistoryDirs.add(meta.fishHistoryDir)
- }
- if (!meta?.worktreeId) {
- continue
- }
-
- if (!liveWorktreeIds.has(meta.worktreeId)) {
- // Why: avoid a TOCTOU race where a worktree is created after the
- // live-ID snapshot but before GC runs. Directories younger than
- // GC_MIN_AGE_MS are presumed still live and skipped.
- if (meta.createdAt) {
- const ageMs = now - new Date(meta.createdAt).getTime()
- if (ageMs < GC_MIN_AGE_MS) {
- continue
- }
- }
-
- result.orphaned++
- // Why: a large orphaned tree recursive-rm'd here would stall the main process ~10s after
- // launch — the same freeze the explicit-delete path already tombstones its way out of.
- if (scheduleWorktreeHistoryTreeDeletion(entryPath, root)) {
- result.pruned++
- console.log(`[pty:history:gc] Pruned orphaned history: ${meta.worktreeId}`)
- }
- }
- } catch {
- // Skip individual entries that fail.
- }
- }
+ )
return result
}
-/** Run background GC to prune history directories for worktrees that are no
- * longer in Orca's known live-worktree set. */
-export function runHistoryGc(liveWorktreeIds: Set): void {
+async function executeHistoryGc(liveWorktreeIds: Set, signal: AbortSignal): Promise {
try {
// Why: finish tombstones left by quit mid-rm before scanning live worktree hashes.
// Safe ahead of the guard below: these entries were already condemned by a
@@ -130,14 +168,17 @@ export function runHistoryGc(liveWorktreeIds: Set): void {
console.log('[pty:history:gc] Skipped: live worktree set is empty')
return
}
- const main = gcScanRoot(getHistoryRoot(), liveWorktreeIds)
+ const main = await gcScanRoot(getHistoryRoot(), liveWorktreeIds, signal)
// Also scan WSL history directories (each distro has its own subdirectory).
const wslTotals = { totalDirs: 0, orphaned: 0, pruned: 0, totalSizeKB: 0 }
const liveFishHistoryDirs = new Set(main.fishHistoryDirs)
for (const distroRoot of listWslHistoryRoots()) {
+ if (signal.aborted) {
+ break
+ }
schedulePendingHistoryTreeRemovals(distroRoot)
- const r = gcScanRoot(distroRoot, liveWorktreeIds)
+ const r = await gcScanRoot(distroRoot, liveWorktreeIds, signal)
wslTotals.totalDirs += r.totalDirs
wslTotals.orphaned += r.orphaned
wslTotals.pruned += r.pruned
@@ -147,6 +188,11 @@ export function runHistoryGc(liveWorktreeIds: Set): void {
}
}
+ if (signal.aborted) {
+ console.log('[pty:history:gc] Cancelled mid-scan')
+ return
+ }
+
// Why a sweep on top of per-worktree deletion: a fish history file lives in
// the user's fish data dir, so it outlives the directory that names it. A
// crash between tombstone and removal, or a hand-deleted history dir, leaves
@@ -178,28 +224,62 @@ export function runHistoryGc(liveWorktreeIds: Set): void {
}
}
+/** Run background GC to prune history directories for worktrees that are no
+ * longer in Orca's known live-worktree set. Resolves when the pass finishes. */
+export function runHistoryGc(liveWorktreeIds: Set): Promise {
+ // Why join instead of starting a second pass: two walks would race each other's
+ // tombstone renames, and the loser's `scheduleWorktreeHistoryTreeDeletion` would
+ // report a failure for a directory the winner already condemned.
+ if (activeHistoryGc) {
+ return activeHistoryGc
+ }
+ const controller = new AbortController()
+ activeHistoryGcAbort = controller
+ activeHistoryGc = executeHistoryGc(liveWorktreeIds, controller.signal).finally(() => {
+ activeHistoryGc = null
+ activeHistoryGcAbort = null
+ })
+ return activeHistoryGc
+}
+
+/** Drop a pending GC and stop an in-flight walk at its next entry. */
+export function cancelHistoryGc(): void {
+ if (scheduledHistoryGcTimer !== null) {
+ clearTimeout(scheduledHistoryGcTimer)
+ scheduledHistoryGcTimer = null
+ }
+ // Why a flag as well: the timer has already fired while the live-worktree lookup is
+ // in flight, and there is no controller to abort until the scan itself starts.
+ historyGcCancelled = true
+ activeHistoryGcAbort?.abort()
+}
+
/** Schedule GC after a delay so it runs after workspace hydration completes.
* `getLiveWorktreeIds` should use already-known IDs, not probe repo paths. */
export function scheduleHistoryGc(getLiveWorktreeIds: () => Promise>): void {
// Why: main-window services can reattach during reload/reactivation; one
// pending/running disk GC is enough and avoids duplicate startup I/O.
- if (scheduledHistoryGcTimer !== null || historyGcRunning) {
+ if (scheduledHistoryGcTimer !== null || historyGcStarting || activeHistoryGc !== null) {
return
}
+ historyGcCancelled = false
// Why 10s: avoids competing with startup-critical I/O while still running
// early enough to clean up before the user notices disk usage (§7.6).
scheduledHistoryGcTimer = setTimeout(async () => {
scheduledHistoryGcTimer = null
- historyGcRunning = true
+ historyGcStarting = true
try {
const liveIds = await getLiveWorktreeIds()
- runHistoryGc(liveIds)
+ if (historyGcCancelled) {
+ return
+ }
+ await runHistoryGc(liveIds)
} catch (err) {
console.warn(
`[pty:history:gc] Failed to enumerate live worktrees for GC: ${err instanceof Error ? err.message : String(err)}`
)
} finally {
- historyGcRunning = false
+ historyGcStarting = false
}
}, 10_000)
}
diff --git a/src/main/terminal-history.test.ts b/src/main/terminal-history.test.ts
index 1cbb5f83438..8dd25ea7ddf 100644
--- a/src/main/terminal-history.test.ts
+++ b/src/main/terminal-history.test.ts
@@ -96,8 +96,6 @@ import {
flushPendingWorktreeHistoryDeletions
} from './terminal-history-deletion'
-import { runHistoryGc, scheduleHistoryGc } from './terminal-history-gc'
-
const OTHER_WORKTREE_HASH = hashWorktreeId('repo-1::/path/other-wt')
describe('terminal-history', () => {
@@ -688,179 +686,6 @@ describe('terminal-history', () => {
})
})
- describe('runHistoryGc', () => {
- it('coalesces duplicate scheduled startup GC calls', async () => {
- vi.useFakeTimers()
- existsSyncMock.mockReturnValue(false)
- const getLiveWorktreeIds = vi.fn().mockResolvedValue(new Set())
-
- scheduleHistoryGc(getLiveWorktreeIds)
- scheduleHistoryGc(getLiveWorktreeIds)
-
- await vi.advanceTimersByTimeAsync(10_000)
-
- expect(getLiveWorktreeIds).toHaveBeenCalledTimes(1)
- })
-
- it('prunes orphaned directories', () => {
- existsSyncMock.mockImplementation((p: string) => {
- // WSL root doesn't exist, so GC skips it
- if (p.includes('terminal-history-wsl')) {
- return false
- }
- return true
- })
- readdirSyncMock.mockImplementation((dir: string) => {
- if (dir.endsWith('terminal-history')) {
- return ['dir1', 'dir2']
- }
- return ['meta.json']
- })
- statSyncMock.mockReturnValue({ isDirectory: () => true, size: 100 })
- readFileSyncMock.mockImplementation((p: string) => {
- // Use a createdAt old enough to pass the GC age threshold
- const oldDate = new Date(Date.now() - 10 * 60 * 1000).toISOString()
- if (p.includes('dir1')) {
- return JSON.stringify({ worktreeId: 'live-wt', createdAt: oldDate })
- }
- return JSON.stringify({ worktreeId: 'dead-wt', createdAt: oldDate })
- })
-
- const liveIds = new Set(['live-wt'])
- runHistoryGc(liveIds)
-
- // Should only prune dir2 (dead-wt), not dir1 (live-wt), and never recursive-rm on the main thread.
- expect(rmSyncMock).not.toHaveBeenCalled()
- expect(renameSyncMock).toHaveBeenCalledTimes(1)
- expect(renameSyncMock).toHaveBeenCalledWith(
- expect.stringContaining('dir2'),
- expect.stringContaining(`.pending-delete${sep}dir2.`)
- )
- expect(rmAsyncMock).toHaveBeenCalledWith(
- expect.stringContaining(`.pending-delete${sep}dir2.`),
- expect.objectContaining({ recursive: true, force: true })
- )
- })
-
- // Why: an empty live set is what a store that fell back to default state
- // looks like, and it is indistinguishable from a user with no worktrees —
- // who has no history to collect either. Treating it as "everything is
- // orphaned" turns a recoverable bad load into deleted shell history.
- it('refuses to prune anything when the live set is empty', () => {
- existsSyncMock.mockImplementation((p: string) => !p.includes('terminal-history-wsl'))
- readdirSyncMock.mockImplementation((dir: string) => {
- if (dir.endsWith('.pending-delete')) {
- return []
- }
- if (dir.endsWith('terminal-history')) {
- return ['dir1', 'dir2']
- }
- return ['meta.json']
- })
- statSyncMock.mockReturnValue({ isDirectory: () => true, size: 100 })
- readFileSyncMock.mockReturnValue(
- JSON.stringify({
- worktreeId: 'some-wt',
- createdAt: new Date(Date.now() - 10 * 60 * 1000).toISOString()
- })
- )
-
- runHistoryGc(new Set())
-
- expect(renameSyncMock).not.toHaveBeenCalled()
- expect(rmSyncMock).not.toHaveBeenCalled()
- expect(rmAsyncMock).not.toHaveBeenCalled()
- })
-
- it('continues GC after one orphan tombstone fails', async () => {
- existsSyncMock.mockImplementation((path: string) => !path.includes('terminal-history-wsl'))
- readdirSyncMock.mockImplementation((dir: string) => {
- if (dir.endsWith('.pending-delete')) {
- return []
- }
- if (dir.endsWith('terminal-history')) {
- return ['broken', 'healthy']
- }
- return ['meta.json']
- })
- statSyncMock.mockReturnValue({ isDirectory: () => true, size: 100 })
- readFileSyncMock.mockReturnValue(
- JSON.stringify({
- worktreeId: 'orphan',
- createdAt: new Date(Date.now() - 10 * 60 * 1000).toISOString()
- })
- )
- renameSyncMock.mockImplementationOnce(() => {
- throw new Error('busy')
- })
-
- expect(() => runHistoryGc(new Set(['live-wt']))).not.toThrow()
- expect(renameSyncMock).toHaveBeenCalledTimes(2)
- expect(rmAsyncMock).toHaveBeenCalledTimes(1)
- await flushPendingWorktreeHistoryDeletions()
- })
-
- it('skips recently-created directories to avoid TOCTOU race', () => {
- existsSyncMock.mockImplementation((p: string) => {
- if (p.includes('terminal-history-wsl')) {
- return false
- }
- return true
- })
- readdirSyncMock.mockImplementation((dir: string) => {
- if (dir.endsWith('terminal-history')) {
- return ['fresh-dir']
- }
- return ['meta.json']
- })
- statSyncMock.mockReturnValue({ isDirectory: () => true, size: 100 })
- // createdAt is just now — younger than the 5-minute GC threshold
- readFileSyncMock.mockReturnValue(
- JSON.stringify({ worktreeId: 'unknown-wt', createdAt: new Date().toISOString() })
- )
-
- runHistoryGc(new Set(['live-wt']))
-
- // Should NOT prune because the directory is too young
- expect(rmSyncMock).not.toHaveBeenCalled()
- expect(renameSyncMock).not.toHaveBeenCalled()
- })
-
- it('does not throw when history root does not exist', () => {
- existsSyncMock.mockReturnValue(false)
- expect(() => runHistoryGc(new Set(['live-wt']))).not.toThrow()
- expect(readdirSyncMock).not.toHaveBeenCalledWith('/fake/userData/terminal-history')
- })
-
- it('drains delete tombstones asynchronously instead of scanning them as worktrees', async () => {
- let tombstonePresent = true
- existsSyncMock.mockImplementation((p: string) => !String(p).includes('terminal-history-wsl'))
- readdirSyncMock.mockImplementation((dir: string) => {
- if (String(dir).endsWith('.pending-delete')) {
- return tombstonePresent ? ['abc123.1700000000000.deadbeef'] : []
- }
- if (String(dir).endsWith('terminal-history')) {
- return ['.pending-delete']
- }
- return ['meta.json']
- })
- statSyncMock.mockReturnValue({ isDirectory: () => true, size: 100 })
- rmAsyncMock.mockImplementation(async () => {
- tombstonePresent = false
- })
-
- runHistoryGc(new Set(['live-wt']))
-
- // The tombstone queue is drained off-thread; GC must never rmSync it or count it as a worktree.
- expect(rmSyncMock).not.toHaveBeenCalled()
- expect(rmAsyncMock).toHaveBeenCalledWith(
- expect.stringContaining('abc123.1700000000000.deadbeef'),
- expect.objectContaining({ recursive: true, force: true })
- )
- await flushPendingWorktreeHistoryDeletions()
- })
- })
-
describe('WSL path conversion', () => {
it('converts HISTFILE to Linux path for WSL cwd', () => {
const originalPlatform = process.platform
diff --git a/src/main/terminal-history.ts b/src/main/terminal-history.ts
index 04c772b2fc7..07ad8f12c93 100644
--- a/src/main/terminal-history.ts
+++ b/src/main/terminal-history.ts
@@ -1,5 +1,6 @@
import { join, basename } from 'node:path'
import { mkdirSync, existsSync, readFileSync, statSync, writeFileSync } from 'node:fs'
+import { readFile, stat } from 'node:fs/promises'
import {
dropInheritedOrcaFishHistory,
fishHistorySessionName,
@@ -131,7 +132,29 @@ export function readHistoryMeta(dir: string): HistoryDirMeta | null {
if (statSync(metaPath).size > MAX_HISTORY_META_BYTES) {
return null
}
- const raw: unknown = JSON.parse(readFileSync(metaPath, 'utf-8'))
+ return parseHistoryMeta(dir, readFileSync(metaPath, 'utf-8'))
+ } catch {
+ return null
+ }
+}
+
+/** `readHistoryMeta` off the main thread, for scans that walk thousands of directories. */
+export async function readHistoryMetaAsync(dir: string): Promise {
+ try {
+ const metaPath = join(dir, 'meta.json')
+ // Why stat before read: the cap must reject an oversized meta.json without loading it.
+ if ((await stat(metaPath)).size > MAX_HISTORY_META_BYTES) {
+ return null
+ }
+ return parseHistoryMeta(dir, await readFile(metaPath, 'utf-8'))
+ } catch {
+ return null
+ }
+}
+
+function parseHistoryMeta(dir: string, contents: string): HistoryDirMeta | null {
+ try {
+ const raw: unknown = JSON.parse(contents)
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
return null
}
diff --git a/src/main/windows/windows-process-table-cim-scan.ts b/src/main/windows/windows-process-table-cim-scan.ts
index 898f0c36f33..213f157f63b 100644
--- a/src/main/windows/windows-process-table-cim-scan.ts
+++ b/src/main/windows/windows-process-table-cim-scan.ts
@@ -75,8 +75,6 @@ export function parseWindowsCimProcessRows(stdout: string): WindowsProcessRow[]
return []
}
const name = fieldAsString(row.Name)
- // memoryBytes stays undefined: Win32_Process reports WorkingSetSize, but no
- // caller reads it off this table and asking widens an already costly scan.
return [{ pid, ppid, name, command: fieldAsString(row.CommandLine) || name }]
})
}
diff --git a/src/main/windows/windows-process-table.test.ts b/src/main/windows/windows-process-table.test.ts
index 609de009820..360dae4ec14 100644
--- a/src/main/windows/windows-process-table.test.ts
+++ b/src/main/windows/windows-process-table.test.ts
@@ -52,21 +52,24 @@ describe('windows process table', () => {
it('maps native rows, defaulting an unreadable command line to empty', async () => {
const rows = await readWindowsProcessTableFresh()
expect(rows).toEqual([
- { pid: process.pid, ppid: 0, name: 'vitest.exe', command: '', memoryBytes: undefined },
+ { pid: process.pid, ppid: 0, name: 'vitest.exe', command: '' },
{
pid: 100,
ppid: 4,
name: 'orca.exe',
command: '"C:/a b/orca.exe" --x',
- memoryBytes: 4096,
creationTimeMs: 1_700_000_000_000
}
])
})
- it('requests memory and command line together', async () => {
+ it('requests the command line and creation time, never memory', async () => {
await readWindowsProcessTableFresh()
- expect(getAllProcesses.mock.calls[0]?.[1]).toBe(7)
+ // CommandLine (2) | CreationTime (4). The Memory bit (1) stays clear: the
+ // addon opens a second PROCESS_VM_READ handle per process to serve it and
+ // nothing reads a working set off this table.
+ expect(getAllProcesses.mock.calls[0]?.[1]).toBe(6)
+ expect((getAllProcesses.mock.calls[0]?.[1] as number) & 1).toBe(0)
})
it('only advertises PID-safe ownership when the native creation-time field exists', () => {
@@ -400,20 +403,19 @@ describe('resolving the native reader', () => {
})
const rows = await readWindowsProcessTableFresh()
expect(rows).toEqual([
- { pid: process.pid, ppid: 0, name: 'vitest.exe', command: '', memoryBytes: undefined },
+ { pid: process.pid, ppid: 0, name: 'vitest.exe', command: '' },
{
pid: 100,
ppid: 4,
name: 'orca.exe',
command: '"C:/a b/orca.exe" --x',
- memoryBytes: 4096,
creationTimeMs: 1_700_000_000_000
}
])
expect(isWindowsProcessTableAvailable()).toBe(true)
})
- it('asks the addon for memory and command line, as the package path does', async () => {
+ it('asks the addon for the command line but not memory, as the package path does', async () => {
const addon = addonReturning(NATIVE)
__setWindowsProcessTreeRequireForTests((specifier: string) => {
if (specifier === ADDON_SPECIFIER) {
@@ -422,9 +424,10 @@ describe('resolving the native reader', () => {
throw new Error('MODULE_NOT_FOUND')
})
await readWindowsProcessTableFresh()
- // Memory | CommandLine. A bare snapshot would silently drop the command
- // line every agent-recognition caller matches on first.
- expect(addon.getProcessList).toHaveBeenCalledWith(expect.any(Function), 3)
+ // CommandLine only: a bare snapshot would silently drop the command line
+ // every agent-recognition caller matches on first, and the relay addon
+ // exposes no CreationTime bit to add.
+ expect(addon.getProcessList).toHaveBeenCalledWith(expect.any(Function), 2)
})
it('reaches the CIM scan when neither the package nor the addon is present', async () => {
diff --git a/src/main/windows/windows-process-table.ts b/src/main/windows/windows-process-table.ts
index 9308c64a9f0..42d48f4fc79 100644
--- a/src/main/windows/windows-process-table.ts
+++ b/src/main/windows/windows-process-table.ts
@@ -23,6 +23,10 @@ import { readWindowsProcessRowsWithCim } from './windows-process-table-cim-scan'
* pid+ppid+name 15.9 / 17.5 ms
* +memory +commandLine 30.6 / 33.7 ms
* PowerShell CIM 706 / 723 ms
+ *
+ * Those are the module's published figures for both extra fields together; the
+ * only flag set this module asks for is `CommandLine` (+ `CreationTime`, free),
+ * which sits between the two rows and has not been separately measured.
*/
export type WindowsProcessRow = {
@@ -31,8 +35,6 @@ export type WindowsProcessRow = {
name: string
/** Full command line. Empty when the process denied a query handle. */
command: string
- /** Working set in bytes, or undefined when not requested/queryable. */
- memoryBytes?: number
/** Process creation time in Unix milliseconds, when the native snapshot provides it. */
creationTimeMs?: number
}
@@ -41,7 +43,6 @@ type NativeProcessInfo = {
pid: number
ppid: number
name: string
- memory?: number
commandLine?: string
creationTimeMs?: number
}
@@ -49,7 +50,6 @@ type NativeProcessInfo = {
type WindowsProcessTreeModule = {
ProcessDataFlag: {
None: number
- Memory: number
CommandLine: number
CreationTime?: number
}
@@ -82,7 +82,10 @@ type WindowsProcessTreeAddon = {
) => void
}
-/** Mirrors the package's enum; the addon takes the raw bit field. */
+/**
+ * Mirrors the package's enum; the addon takes the raw bit field. `Memory` (1)
+ * is listed for completeness and is deliberately never set — see `flags` below.
+ */
const PROCESS_DATA_FLAG = { None: 0, Memory: 1, CommandLine: 2 } as const
/** Staged beside the relay bundle by build-relay; see RELAY_ARTIFACTS. */
@@ -190,16 +193,16 @@ function readNativeRows(): Promise {
}
const readId = ++readSequence
const readerEpoch = nativeReaderEpoch
- // Why always both flags: each adds an OpenProcess per process (Memory a
- // GetProcessMemoryInfo, CommandLine a PEB read), so asking for less would be
- // cheaper -- 15.9ms p50 versus 30.6ms at 1050 processes. But every read shares
- // one snapshot so a 32-wide teardown collapses into a single scan, and that
- // snapshot has to satisfy every caller. Splitting the cache per field set
- // would restore exactly the fan-out it exists to prevent.
- const flags =
- native.ProcessDataFlag.Memory |
- native.ProcessDataFlag.CommandLine |
- (native.ProcessDataFlag.CreationTime ?? 0)
+ // Why CommandLine but not Memory: each flag costs one OpenProcess per process
+ // inside the addon (process.cc), and every caller of this table matches on
+ // `command`, while nothing reads a working set off it -- the Resource Manager
+ // runs its own CIM sweep because it needs commit and CPU time in one pass, and
+ // `process.cc` truncates the working set into a DWORD anyway. Dropping Memory
+ // halves the per-snapshot handle count; the remaining flags stay in ONE flag
+ // set because every read shares one snapshot, so a 32-wide teardown collapses
+ // into a single scan. Splitting the cache per field set would restore exactly
+ // the fan-out it exists to prevent.
+ const flags = native.ProcessDataFlag.CommandLine | (native.ProcessDataFlag.CreationTime ?? 0)
return new Promise((resolve, reject) => {
// Hoisted so a synchronous throw from getAllProcesses can clear it. An
// orphaned timer would otherwise fire later and wedge a reader that had
@@ -241,7 +244,6 @@ function readNativeRows(): Promise {
ppid: row.ppid,
name: row.name,
command: row.commandLine ?? '',
- memoryBytes: row.memory,
...(typeof row.creationTimeMs === 'number'
? { creationTimeMs: row.creationTimeMs }
: {})
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/preload/api/app-api.ts b/src/preload/api/app-api.ts
index 88cb86dc32b..b2d6eed966c 100644
--- a/src/preload/api/app-api.ts
+++ b/src/preload/api/app-api.ts
@@ -38,6 +38,9 @@ export type AppApi = {
/** Resolves when the daemon PTY provider and hook receiver have either
* started or failed open for the first BrowserWindow. */
awaitFirstWindowStartupServices: () => Promise
+ /** Resolves when host Git can run: shell-PATH generation is published and the
+ * managed WSL CLI registration has reconciled. Does not wait on PTY services. */
+ awaitGitEnvironmentStartupBarrier: () => Promise
/** Inventories retained PTYs and restores durable structured ownership before renderer adoption. */
prepareTerminalStartupRestoration: () => Promise
/** Reconciles legacy worker authority around persisted terminal reconnect. */
diff --git a/src/preload/api/app-bridge.ts b/src/preload/api/app-bridge.ts
index 22d46cc40d2..2a0d50e9de4 100644
--- a/src/preload/api/app-bridge.ts
+++ b/src/preload/api/app-bridge.ts
@@ -43,6 +43,8 @@ export const appApi = {
awaitBeforeUnloadCheckpoint: () => awaitBeforeUnloadCheckpoint(),
awaitFirstWindowStartupServices: (): Promise =>
ipcRenderer.invoke('app:awaitFirstWindowStartupServices'),
+ awaitGitEnvironmentStartupBarrier: (): Promise =>
+ ipcRenderer.invoke('app:awaitGitEnvironmentStartupBarrier'),
prepareTerminalStartupRestoration: (): Promise =>
ipcRenderer.invoke('app:prepareTerminalStartupRestoration'),
recoverLegacyWorkerTerminalsForRendererStartup: (): Promise =>
diff --git a/src/preload/api/platform-bridge.test.ts b/src/preload/api/platform-bridge.test.ts
new file mode 100644
index 00000000000..5c8bdde34f9
--- /dev/null
+++ b/src/preload/api/platform-bridge.test.ts
@@ -0,0 +1,55 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type { platformApi } from './platform-bridge'
+
+const mocks = vi.hoisted(() => ({ getLinuxDisplayServer: vi.fn(() => null) }))
+
+vi.mock('../preload-runtime-support', () => ({
+ getLinuxDisplayServer: mocks.getLinuxDisplayServer
+}))
+
+// Electron declares getSystemVersion as required on NodeJS.Process; Node does not have it.
+const mutableProcess = process as unknown as { getSystemVersion?: () => string }
+
+async function loadPlatformApi(): Promise {
+ vi.resetModules()
+ return (await import('./platform-bridge')).platformApi
+}
+
+describe('platformApi.get', () => {
+ beforeEach(() => {
+ mocks.getLinuxDisplayServer.mockClear()
+ })
+
+ afterEach(() => {
+ delete mutableProcess.getSystemVersion
+ })
+
+ it('resolves the immutable payload once and returns the identical object', async () => {
+ const platformApi = await loadPlatformApi()
+ const getSystemVersion = vi.fn(() => '25.3.0')
+ mutableProcess.getSystemVersion = getSystemVersion
+
+ const first = platformApi.get()
+ for (let index = 0; index < 100; index += 1) {
+ expect(platformApi.get()).toBe(first)
+ }
+
+ expect(getSystemVersion).toHaveBeenCalledTimes(1)
+ expect(mocks.getLinuxDisplayServer).toHaveBeenCalledTimes(1)
+ expect(first.platform).toBe(process.platform)
+ expect(first.arch).toBe(process.arch)
+ expect(first.osRelease).toBe('25.3.0')
+ })
+
+ it('freezes the payload so no consumer can corrupt the shared instance', async () => {
+ const platformApi = await loadPlatformApi()
+
+ expect(Object.isFrozen(platformApi.get())).toBe(true)
+ })
+
+ it('resolves nothing before the first get, keeping preload startup free', async () => {
+ await loadPlatformApi()
+
+ expect(mocks.getLinuxDisplayServer).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/preload/api/platform-bridge.ts b/src/preload/api/platform-bridge.ts
index 8e47a4386cf..fb1aad5de4e 100644
--- a/src/preload/api/platform-bridge.ts
+++ b/src/preload/api/platform-bridge.ts
@@ -1,8 +1,14 @@
import { getLinuxDisplayServer } from '../preload-runtime-support'
import type { PreloadApi } from '../api-types'
-export const platformApi = {
- get: () => ({
+type PlatformInfo = ReturnType
+
+// Why: the renderer reads this on its render cadence, and every field below is fixed
+// for the process lifetime, so resolve once and hand back the same frozen payload.
+let platformInfo: PlatformInfo | undefined
+
+function resolvePlatformInfo(): PlatformInfo {
+ return Object.freeze({
platform: process.platform,
// Why: sandboxed preload cannot require node:os; Electron exposes the OS
// version on process.getSystemVersion when available.
@@ -14,4 +20,9 @@ export const platformApi = {
shell: process.env.SHELL?.trim() || process.env.ComSpec?.trim() || '',
displayServer: getLinuxDisplayServer()
})
+}
+
+export const platformApi = {
+ // Why: resolved lazily so preload startup keeps paying nothing for it.
+ get: () => (platformInfo ??= resolvePlatformInfo())
} satisfies PreloadApi['platform']
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-push-target.test.ts b/src/relay/git-handler-push-target.test.ts
index c040a645a16..b6fe5e96ad0 100644
--- a/src/relay/git-handler-push-target.test.ts
+++ b/src/relay/git-handler-push-target.test.ts
@@ -46,6 +46,17 @@ function gitForConfig(config: {
}
return { stdout: `${config.base ?? ''}\n`, stderr: '' }
}
+ if (args[0] === 'remote' && args[1] === '-v') {
+ return {
+ stdout: (config.remotes ?? [])
+ .flatMap((name) => {
+ const url = config.remoteUrls?.[name] ?? ''
+ return [`${name}\t${url} (fetch)`, `${name}\t${url} (push)`]
+ })
+ .join('\n'),
+ stderr: ''
+ }
+ }
if (args[0] === 'remote' && args.length === 1) {
return { stdout: `${config.remotes?.join('\n') ?? ''}\n`, stderr: '' }
}
diff --git a/src/relay/git-handler-push-target.ts b/src/relay/git-handler-push-target.ts
index 40765491c56..d81111d45d3 100644
--- a/src/relay/git-handler-push-target.ts
+++ b/src/relay/git-handler-push-target.ts
@@ -1,5 +1,6 @@
import { assertGitPushTargetShape } from '../shared/git-push-target-validation'
import { gitRefTargetsBranchOnRemote } from '../shared/git-remote-branch-name'
+import { findGitRemoteNameByFetchUrl } from '../shared/git-remote-url-index'
import type { GitPushTarget } from '../shared/worktree/types'
type RelayGit = (args: string[], cwd: string) => Promise<{ stdout: string; stderr: string }>
@@ -67,31 +68,19 @@ type ConfiguredPushRemote = {
branchRemote: string | null
}
+// Host-side twin of `src/main/git/remote.ts`: one `git remote -v` instead of
+// `git remote` plus a serial `git remote get-url` per remote.
async function findRemoteNameForUrl(
git: RelayGit,
worktreePath: string,
remoteUrl: string
): Promise {
try {
- const { stdout } = await git(['remote'], worktreePath)
- const remotes = stdout
- .split(/\r?\n/)
- .map((line) => line.trim())
- .filter(Boolean)
- for (const remoteName of remotes) {
- try {
- const { stdout: urlStdout } = await git(['remote', 'get-url', remoteName], worktreePath)
- if (urlStdout.trim() === remoteUrl) {
- return remoteName
- }
- } catch {
- // Ignore a remote that disappeared or has no fetch URL.
- }
- }
+ const { stdout } = await git(['remote', '-v'], worktreePath)
+ return findGitRemoteNameByFetchUrl(stdout, (candidateUrl) => candidateUrl === remoteUrl)
} catch {
return null
}
- return null
}
async function normalizePushRemote(
@@ -120,9 +109,17 @@ async function getConfiguredPushRemote(
if (!remote) {
return null
}
+ const normalizedRemote = await normalizePushRemote(git, worktreePath, remote)
+ // The two usually name the same URL; resolving it twice reads the remote table twice.
+ if (!branchRemote) {
+ return { remote: normalizedRemote, branchRemote: null }
+ }
return {
- remote: await normalizePushRemote(git, worktreePath, remote),
- branchRemote: branchRemote ? await normalizePushRemote(git, worktreePath, branchRemote) : null
+ remote: normalizedRemote,
+ branchRemote:
+ branchRemote === remote
+ ? normalizedRemote
+ : await normalizePushRemote(git, worktreePath, branchRemote)
}
}
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/pty-shell-utils.ts b/src/relay/pty-shell-utils.ts
index accccb9e702..e06edaeabc7 100644
--- a/src/relay/pty-shell-utils.ts
+++ b/src/relay/pty-shell-utils.ts
@@ -6,17 +6,16 @@ import { promisify } from 'node:util'
import {
isAgentForegroundWrapperProcess,
isExpectedAgentProcess,
- recognizeAgentProcess,
- recognizeAgentProcessFromCommandLine
+ recognizeAgentProcess
} from '../shared/agent-process-recognition'
import { getFirstCommandToken } from '../shared/command-token-scanner'
import {
getProcessTableIndex,
getProcessTableSnapshot,
- scoreForegroundCandidateRow,
type ProcessTableIndex,
type ProcessTableRow
} from '../shared/process-table-snapshot'
+import { selectForegroundProcessCandidate } from '../shared/foreground-process-selection'
import {
resolveOuterWrapperForegroundProcess,
shouldInspectOuterWrapperForegroundProcess
@@ -240,9 +239,7 @@ function getForegroundProcessNameFromProcessTable(
// snapshot no longer each rebuild the parent/child map over every row.
const index = getProcessTableIndex(rows)
const root = index.byPid.get(pid)
- const candidates = collectDescendants(index, pid).sort(
- (a, b) => scoreForegroundCandidateRow(b) - scoreForegroundCandidateRow(a)
- )
+ const candidates = collectDescendants(index, pid)
// Why: SSH relays do not have the daemon's async wrapper cache. Inspect the
// remote process tree so node/python agent entrypoints become real agents.
const foregroundIsKnown =
@@ -264,13 +261,12 @@ function getForegroundProcessNameFromProcessTable(
) {
return null
}
- for (const candidate of inspectionCandidates) {
- const recognized = recognizeAgentProcessFromCommandLine(candidate.command)
- if (recognized) {
- // Why: return the outer wrapper (omp) rather than the deeper wrapped child
- // (pi) of a shell→omp→pi tree — see resolveOuterWrapperForegroundProcess.
- return resolveOuterWrapperForegroundProcess(recognized, candidate, candidates)
- }
+ const ancestryCandidates = root ? [{ ...root, depth: 0 }, ...candidates] : candidates
+ const selected = selectForegroundProcessCandidate(inspectionCandidates, ancestryCandidates)
+ if (selected) {
+ // Why: return the outer wrapper (omp) rather than a deeper recognized helper
+ // in the same process lineage.
+ return resolveOuterWrapperForegroundProcess(selected.recognized, selected.candidate, candidates)
}
return null
}
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/src/renderer/src/app-shell/reconcile-hydrated-workspace-tab-models.test.ts b/src/renderer/src/app-shell/reconcile-hydrated-workspace-tab-models.test.ts
index f4940db6ab5..cf33aed8e3e 100644
--- a/src/renderer/src/app-shell/reconcile-hydrated-workspace-tab-models.test.ts
+++ b/src/renderer/src/app-shell/reconcile-hydrated-workspace-tab-models.test.ts
@@ -2,13 +2,14 @@ import { describe, expect, it, vi } from 'vitest'
import { reconcileHydratedWorkspaceTabModels } from './reconcile-hydrated-workspace-tab-models'
describe('reconcileHydratedWorkspaceTabModels', () => {
- it('reconciles every workspace the session hydrated, in session order', () => {
+ it('reconciles every workspace the session hydrated, in session order, in one call', () => {
const reconcile = vi.fn()
const reconciled = reconcileHydratedWorkspaceTabModels(
{ tabsByWorktree: { 'wt-a': [], 'wt-b': [], 'wt-c': [] } },
reconcile
)
- expect(reconcile.mock.calls.map((call) => call[0])).toEqual(['wt-a', 'wt-b', 'wt-c'])
+ expect(reconcile).toHaveBeenCalledTimes(1)
+ expect(reconcile.mock.calls[0]?.[0]).toEqual(['wt-a', 'wt-b', 'wt-c'])
expect(reconciled).toEqual(['wt-a', 'wt-b', 'wt-c'])
})
diff --git a/src/renderer/src/app-shell/reconcile-hydrated-workspace-tab-models.ts b/src/renderer/src/app-shell/reconcile-hydrated-workspace-tab-models.ts
index 84704b3eb2d..3da45244660 100644
--- a/src/renderer/src/app-shell/reconcile-hydrated-workspace-tab-models.ts
+++ b/src/renderer/src/app-shell/reconcile-hydrated-workspace-tab-models.ts
@@ -3,12 +3,13 @@ import type { WorkspaceSessionState } from '../../../shared/workspace-session-st
/** Reconcile every workspace loaded during boot so stale unified-tab subsets converge. */
export function reconcileHydratedWorkspaceTabModels(
session: Pick,
- reconcileWorktreeTabModel: (worktreeId: string) => unknown
+ // Why batched: one store write for the whole session instead of one per
+ // workspace, each fanning out to every non-React store subscriber.
+ reconcileWorktreeTabModels: (worktreeIds: readonly string[]) => void
): string[] {
- const reconciled: string[] = []
- for (const worktreeId of Object.keys(session.tabsByWorktree)) {
- reconcileWorktreeTabModel(worktreeId)
- reconciled.push(worktreeId)
+ const reconciled = Object.keys(session.tabsByWorktree)
+ if (reconciled.length > 0) {
+ reconcileWorktreeTabModels(reconciled)
}
return reconciled
}
diff --git a/src/renderer/src/app-shell/startup-actions-selector.test.ts b/src/renderer/src/app-shell/startup-actions-selector.test.ts
index 1d559eb0dcb..45a1b9cf5df 100644
--- a/src/renderer/src/app-shell/startup-actions-selector.test.ts
+++ b/src/renderer/src/app-shell/startup-actions-selector.test.ts
@@ -30,6 +30,7 @@ function makeActions(): StartupActions {
reconnectPersistedTerminals: vi.fn(),
setTerminalStartupRestorationReady: vi.fn(),
setDeferredSshReconnectTargets: vi.fn(),
+ removeDeferredSshReconnectTarget: vi.fn(),
setSshConnectionState: vi.fn(),
hydratePersistedUI: vi.fn(),
setHydrationSucceeded: vi.fn(),
diff --git a/src/renderer/src/app-shell/startup-actions-selector.ts b/src/renderer/src/app-shell/startup-actions-selector.ts
index c7dca311a03..ac18349909a 100644
--- a/src/renderer/src/app-shell/startup-actions-selector.ts
+++ b/src/renderer/src/app-shell/startup-actions-selector.ts
@@ -22,6 +22,7 @@ export type StartupActions = Pick<
| 'reconnectPersistedTerminals'
| 'setTerminalStartupRestorationReady'
| 'setDeferredSshReconnectTargets'
+ | 'removeDeferredSshReconnectTarget'
| 'setSshConnectionState'
| 'hydratePersistedUI'
| 'setHydrationSucceeded'
@@ -59,6 +60,8 @@ export function selectStartupActions(state: StartupActions): StartupActions {
cachedStartupActions.setTerminalStartupRestorationReady ===
state.setTerminalStartupRestorationReady &&
cachedStartupActions.setDeferredSshReconnectTargets === state.setDeferredSshReconnectTargets &&
+ cachedStartupActions.removeDeferredSshReconnectTarget ===
+ state.removeDeferredSshReconnectTarget &&
cachedStartupActions.setSshConnectionState === state.setSshConnectionState &&
cachedStartupActions.hydratePersistedUI === state.hydratePersistedUI &&
cachedStartupActions.setHydrationSucceeded === state.setHydrationSucceeded &&
@@ -91,6 +94,7 @@ export function selectStartupActions(state: StartupActions): StartupActions {
reconnectPersistedTerminals: state.reconnectPersistedTerminals,
setTerminalStartupRestorationReady: state.setTerminalStartupRestorationReady,
setDeferredSshReconnectTargets: state.setDeferredSshReconnectTargets,
+ removeDeferredSshReconnectTarget: state.removeDeferredSshReconnectTarget,
setSshConnectionState: state.setSshConnectionState,
hydratePersistedUI: state.hydratePersistedUI,
setHydrationSucceeded: state.setHydrationSucceeded,
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..91db232081c 100644
--- a/src/renderer/src/app-shell/use-app-startup-hydration.ts
+++ b/src/renderer/src/app-shell/use-app-startup-hydration.ts
@@ -19,6 +19,7 @@ import {
} from '../startup/startup-diagnostics'
import { recoverFromDegradedStartup } from '../startup/startup-degraded-recovery'
import { restoreSshConnectionsForStartup } from '../startup/startup-ssh-connection-restore'
+import { collectActiveWorkspaceSshTargetIds } from '../startup/active-workspace-ssh-targets'
import { publishTerminalViewAttributesAtAppStart } from '../components/terminal-pane/terminal-appearance'
import { getSystemPrefersDark } from '../lib/terminal-theme'
import {
@@ -155,9 +156,12 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta
// Why: disconnected SSH repos hydrate from local metadata; only runtime-owned repos use placeholders.
parseExecutionHostId(getRepoExecutionHostId(repo))?.kind !== 'runtime'
)
- // Why: worktree refresh can spawn host Git; wait for main's shell-PATH generation fence first.
- await timeRendererStartupStep('first-window-services-await', () =>
- window.api.app.awaitFirstWindowStartupServices()
+ // Why this barrier and not the first-window one: worktree refresh can spawn host Git,
+ // which needs the shell-PATH generation and the managed WSL CLI registration. It never
+ // needs the daemon PTY provider or the hook-server bind, and `prepare-terminal-startup-restoration`
+ // below still fences those before any terminal is restored.
+ await timeRendererStartupStep('git-environment-barrier-await', () =>
+ window.api.app.awaitGitEnvironmentStartupBarrier()
)
await timeRendererStartupStep('fetch-hydration-worktrees', () =>
mapWithConcurrency(hydrationRepos, WORKTREE_REFRESH_CONCURRENCY, (repo) =>
@@ -198,7 +202,7 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta
actions.hydrateBrowserSession(sessionRead.session, sessionHydrationOptions)
reconcileHydratedWorkspaceTabModels(
sessionRead.session,
- useAppStore.getState().reconcileWorktreeTabModel
+ useAppStore.getState().reconcileWorktreeTabModels
)
})
await timeRendererStartupStep('prepare-terminal-startup-restoration', () =>
@@ -213,9 +217,14 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta
actions.pruneLastVisitedTimestamps()
actions.seedActiveWorktreeLastVisitedIfMissing()
})
- await timeRendererStartupStep('fetch-browser-session-profiles', () =>
+ // Why started here but not awaited: on a remote runtime this is an RPC with a 15s
+ // timeout, and nothing between here and terminal restoration reads the profile list —
+ // awaiting it put that timeout on the terminal-restoration gate. Starting it at the
+ // original point keeps the profiles landing no later than they did before; the action
+ // swallows its own failures, so the `.catch` only marks the timing wrapper handled.
+ void timeRendererStartupStep('fetch-browser-session-profiles', () =>
actions.fetchBrowserSessionProfiles()
- )
+ ).catch(() => {})
const onboardingState = await onboardingPromise
if (!cancelled) {
onOnboardingLoadedRef.current(onboardingState)
@@ -228,9 +237,17 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta
)
if (connectionIds.length > 0) {
try {
+ // Why scoped: an unreachable host used to hold every restored terminal — local ones
+ // included — for the full reconnect timeout. Only the targets whose panes mount as
+ // soon as the gate opens are worth waiting for; the rest reattach on tab focus.
+ const blockingConnectionIds = collectActiveWorkspaceSshTargetIds(
+ useAppStore.getState()
+ )
await restoreSshConnectionsForStartup({
connectionIds,
+ blockingConnectionIds,
setDeferredSshReconnectTargets: actions.setDeferredSshReconnectTargets,
+ removeDeferredSshReconnectTarget: actions.removeDeferredSshReconnectTarget,
publishSshConnectionState: actions.setSshConnectionState
})
} catch (err) {
@@ -240,7 +257,8 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta
logRendererStartupDiagnostic('ssh-reconnect-skipped', { connectionIds: 0 })
}
- // first-window-services-await already fenced worktree hydration; terminal recovery reuses that ready state.
+ // Why no explicit barrier here: prepare-terminal-startup-restoration above already awaited
+ // the first-window services, and main re-awaits them inside this handler anyway.
await timeRendererStartupStep('recover-legacy-worker-terminals-pre-reconnect', () =>
window.api.app.recoverLegacyWorkerTerminalsForRendererStartup()
)
@@ -269,6 +287,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/app-startup-routing.test.ts b/src/renderer/src/app-startup-routing.test.ts
index 24a9cc75120..d1aad35829a 100644
--- a/src/renderer/src/app-startup-routing.test.ts
+++ b/src/renderer/src/app-startup-routing.test.ts
@@ -68,8 +68,11 @@ describe('renderer startup runtime routing', () => {
const hydrationWorktreesIndex = source.indexOf(
"timeRendererStartupStep('fetch-hydration-worktrees'"
)
- const servicesIndex = source.indexOf(
- "timeRendererStartupStep('first-window-services-await'",
+ // Why this barrier: worktree hydration can spawn host Git, so it must sit behind the
+ // shell-PATH + managed-WSL fence. On packaged Windows the window opens before
+ // shellPathReady resolves, so this really is the fence, not a formality.
+ const gitEnvironmentBarrierIndex = source.indexOf(
+ "timeRendererStartupStep('git-environment-barrier-await'",
sessionIndex
)
const fullWorktreesIndex = source.indexOf('await actions.fetchAllWorktrees()')
@@ -89,8 +92,11 @@ describe('renderer startup runtime routing', () => {
expect(localReposIndex).toBeLessThan(localGroupsIndex)
expect(localGroupsIndex).toBeLessThan(localFoldersIndex)
expect(localReposIndex).toBeLessThan(sessionIndex)
- expect(sessionIndex).toBeLessThan(servicesIndex)
- expect(servicesIndex).toBeLessThan(hydrationWorktreesIndex)
+ expect(sessionIndex).toBeLessThan(gitEnvironmentBarrierIndex)
+ expect(gitEnvironmentBarrierIndex).toBeLessThan(hydrationWorktreesIndex)
+ expect(source.slice(gitEnvironmentBarrierIndex, hydrationWorktreesIndex)).toContain(
+ 'window.api.app.awaitGitEnvironmentStartupBarrier()'
+ )
const hydrationWorktreeBlock = source.slice(
hydrationWorktreesIndex,
source.indexOf('await keybindingsPromise')
@@ -180,7 +186,13 @@ describe('renderer startup runtime routing', () => {
it('waits for first-window startup services before terminal reconnect', () => {
const source = readSource(STARTUP_HYDRATION_PATH)
- const servicesIndex = source.indexOf("timeRendererStartupStep('first-window-services-await'")
+ // Why this step: `app:prepareTerminalStartupRestoration` awaits
+ // firstWindowStartupServicesReady + managedWslCliStartupBarrierReady in main before it
+ // does anything else, so it is the renderer-side position of that fence.
+ // `desktop-startup-ordering.test.ts` pins the main-side await itself.
+ const servicesIndex = source.indexOf(
+ "timeRendererStartupStep('prepare-terminal-startup-restoration'"
+ )
const preReconnectRecoveryIndex = source.indexOf(
"timeRendererStartupStep('recover-legacy-worker-terminals-pre-reconnect'"
)
@@ -193,6 +205,9 @@ describe('renderer startup runtime routing', () => {
)
expect(servicesIndex).toBeGreaterThanOrEqual(0)
+ expect(source.slice(servicesIndex)).toContain(
+ 'window.api.app.prepareTerminalStartupRestoration()'
+ )
expect(preReconnectRecoveryIndex).toBeGreaterThan(servicesIndex)
expect(capabilityRefreshIndex).toBeGreaterThan(preReconnectRecoveryIndex)
expect(reconnectIndex).toBeGreaterThan(capabilityRefreshIndex)
diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css
index 1b5f40ccdb4..8187fe496c7 100644
--- a/src/renderer/src/assets/main.css
+++ b/src/renderer/src/assets/main.css
@@ -1956,7 +1956,9 @@ html.native-shell .app-layout {
transform 120ms cubic-bezier(0.2, 0.8, 0.2, 1),
width 120ms cubic-bezier(0.2, 0.8, 0.2, 1),
opacity 80ms ease-out;
- will-change: transform, width, opacity;
+ /* Why no `width`: it is not compositable, so hinting it only pins a layer that
+ has to be re-rastered every frame of the transition anyway. */
+ will-change: transform, opacity;
}
[data-workspace-board-card-drop-indicator='true']::before,
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 (
+
+ {agentsVisibleHostIds ? (
+ setAgentsVisibleHostIds(null)}
+ />
+ ) : null}
+ {hasRepoFilter ? (
+ setAgentsFilterRepoIds([])}
+ />
+ ) : null}
+
+ )
+}
diff --git a/src/renderer/src/components/activity/activity-scope-filter.test.ts b/src/renderer/src/components/activity/activity-scope-filter.test.ts
new file mode 100644
index 00000000000..45817e50a7b
--- /dev/null
+++ b/src/renderer/src/components/activity/activity-scope-filter.test.ts
@@ -0,0 +1,130 @@
+import { describe, expect, it } from 'vitest'
+import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../../shared/execution-host'
+import type { Repo } from '../../../../shared/repo-types'
+import {
+ filterThreadsByActivityScope,
+ resolveActivityScopeRepoIds,
+ threadMatchesActivityScope,
+ type ActivityScopeFilter
+} from './activity-scope-filter'
+import type { AgentPaneThread } from './activity-thread-types'
+import {
+ makeRepo,
+ makeTabWithIds,
+ makeWorktree,
+ PANE_KEY
+} from './ActivityPrototypePage-test-fixtures'
+
+const SSH_HOST = 'ssh:devbox' as ExecutionHostId
+
+function makeThread(overrides: Partial = {}): AgentPaneThread {
+ const worktree = makeWorktree()
+ return {
+ paneKey: PANE_KEY,
+ paneTitle: 'Test Agent',
+ agentType: 'claude',
+ worktree,
+ repo: makeRepo(),
+ tab: makeTabWithIds('tab-1', worktree.id),
+ events: [],
+ latestEvent: null,
+ latestTimestamp: 1000,
+ currentAgentState: 'working',
+ currentAgentEntry: null,
+ unread: false,
+ responsePreview: '',
+ ...overrides
+ }
+}
+
+function makeScope(overrides: Partial = {}): ActivityScopeFilter {
+ return {
+ visibleHostIds: null,
+ filterRepoIds: [],
+ defaultHostId: LOCAL_EXECUTION_HOST_ID,
+ ...overrides
+ }
+}
+
+describe('threadMatchesActivityScope', () => {
+ it('matches everything when no scope is active', () => {
+ expect(threadMatchesActivityScope(makeThread(), makeScope())).toBe(true)
+ expect(threadMatchesActivityScope(makeThread({ repo: null }), makeScope())).toBe(true)
+ })
+
+ it('filters by execution host, falling back to the default host for local worktrees', () => {
+ const local = makeThread()
+ const remote = makeThread({
+ worktree: { ...makeWorktree(), hostId: SSH_HOST }
+ })
+ const localOnly = makeScope({ visibleHostIds: [LOCAL_EXECUTION_HOST_ID] })
+ expect(threadMatchesActivityScope(local, localOnly)).toBe(true)
+ expect(threadMatchesActivityScope(remote, localOnly)).toBe(false)
+ const remoteOnly = makeScope({ visibleHostIds: [SSH_HOST] })
+ expect(threadMatchesActivityScope(local, remoteOnly)).toBe(false)
+ expect(threadMatchesActivityScope(remote, remoteOnly)).toBe(true)
+ })
+
+ it('filters by project and hides repo-less threads under a project scope', () => {
+ const scope = makeScope({ filterRepoIds: ['repo-1'] })
+ expect(threadMatchesActivityScope(makeThread(), scope)).toBe(true)
+ expect(
+ threadMatchesActivityScope(makeThread({ repo: { ...makeRepo(), id: 'repo-2' } }), scope)
+ ).toBe(false)
+ expect(threadMatchesActivityScope(makeThread({ repo: null }), scope)).toBe(false)
+ })
+})
+
+describe('filterThreadsByActivityScope', () => {
+ it('returns the input array by identity when the scope is inactive', () => {
+ const threads = [makeThread(), makeThread({ paneKey: 'pane-2', repo: null })]
+ const result = filterThreadsByActivityScope({
+ threads,
+ scope: makeScope(),
+ exemptPaneKey: null
+ })
+ expect(result.threads).toBe(threads)
+ expect(result.matchingThreads).toBe(threads)
+ expect(result.hiddenCount).toBe(0)
+ })
+
+ it('returns the input array by identity when an active scope hides nothing', () => {
+ const threads = [makeThread()]
+ const result = filterThreadsByActivityScope({
+ threads,
+ scope: makeScope({ visibleHostIds: [LOCAL_EXECUTION_HOST_ID] }),
+ exemptPaneKey: null
+ })
+ expect(result.threads).toBe(threads)
+ expect(result.matchingThreads).toBe(threads)
+ expect(result.hiddenCount).toBe(0)
+ })
+
+ it('hides scoped-out threads but keeps the exempt pane, counting only real hides', () => {
+ const local = makeThread()
+ const remote = makeThread({
+ paneKey: 'pane-remote',
+ worktree: { ...makeWorktree(), hostId: SSH_HOST }
+ })
+ const exemptRemote = makeThread({
+ paneKey: 'pane-exempt',
+ worktree: { ...makeWorktree(), hostId: SSH_HOST }
+ })
+ const result = filterThreadsByActivityScope({
+ threads: [local, remote, exemptRemote],
+ scope: makeScope({ visibleHostIds: [LOCAL_EXECUTION_HOST_ID] }),
+ exemptPaneKey: 'pane-exempt'
+ })
+ expect(result.threads).toEqual([local, exemptRemote])
+ expect(result.matchingThreads).toEqual([local])
+ expect(result.hiddenCount).toBe(1)
+ })
+})
+
+describe('resolveActivityScopeRepoIds', () => {
+ it('drops stale repo ids so they cannot count as an active filter', () => {
+ const repoMap = new Map([['repo-1', makeRepo()]])
+ expect(resolveActivityScopeRepoIds(['repo-1', 'gone-repo'], repoMap)).toEqual(['repo-1'])
+ expect(resolveActivityScopeRepoIds(['gone-repo'], repoMap)).toEqual([])
+ })
+})
diff --git a/src/renderer/src/components/activity/activity-scope-filter.ts b/src/renderer/src/components/activity/activity-scope-filter.ts
new file mode 100644
index 00000000000..36dfafa8cf6
--- /dev/null
+++ b/src/renderer/src/components/activity/activity-scope-filter.ts
@@ -0,0 +1,80 @@
+import type { ExecutionHostId } from '../../../../shared/execution-host'
+import { getWorktreeExecutionHostId } from '../../../../shared/execution-host'
+import type { Repo } from '../../../../shared/repo-types'
+import type { AgentPaneThread } from './activity-thread-types'
+
+/** Host/project scope for the Agents activity surfaces. Persisted and separate
+ * from the workspace-nav filters; hosts `null` = all, repoIds empty = all. */
+export type ActivityScopeFilter = {
+ visibleHostIds: readonly ExecutionHostId[] | null
+ filterRepoIds: readonly string[]
+ defaultHostId: ExecutionHostId
+}
+
+/** Repo ids that still exist; stale persisted ids must not count as an active filter. */
+export function resolveActivityScopeRepoIds(
+ filterRepoIds: readonly string[],
+ repoMap: ReadonlyMap
+): string[] {
+ return filterRepoIds.filter((repoId) => repoMap.has(repoId))
+}
+
+/**
+ * Apply the scope to a thread list. Returns the input array by identity when
+ * the scope is inactive or hides nothing, so downstream memos (visible threads,
+ * grouping) see an unchanged dep instead of re-running on every rebuild.
+ */
+export function filterThreadsByActivityScope(args: {
+ threads: AgentPaneThread[]
+ scope: ActivityScopeFilter
+ /** Kept visible even when scoped out, so changing scope can't vanish the open row. */
+ exemptPaneKey: string | null
+}): {
+ threads: AgentPaneThread[]
+ /** Strict matches for bulk actions; excludes a selected row kept visible only by the exemption. */
+ matchingThreads: AgentPaneThread[]
+ hiddenCount: number
+} {
+ const { threads, scope, exemptPaneKey } = args
+ if (!scope.visibleHostIds && scope.filterRepoIds.length === 0) {
+ return { threads, matchingThreads: threads, hiddenCount: 0 }
+ }
+ const matchingThreads: AgentPaneThread[] = []
+ const visibleThreads: AgentPaneThread[] = []
+ for (const thread of threads) {
+ if (threadMatchesActivityScope(thread, scope)) {
+ matchingThreads.push(thread)
+ visibleThreads.push(thread)
+ } else if (thread.paneKey === exemptPaneKey) {
+ visibleThreads.push(thread)
+ }
+ }
+ return {
+ threads: visibleThreads.length === threads.length ? threads : visibleThreads,
+ matchingThreads: matchingThreads.length === threads.length ? threads : matchingThreads,
+ hiddenCount: threads.length - visibleThreads.length
+ }
+}
+
+export function threadMatchesActivityScope(
+ thread: AgentPaneThread,
+ scope: ActivityScopeFilter
+): boolean {
+ if (scope.visibleHostIds) {
+ const hostId = getWorktreeExecutionHostId(
+ thread.worktree,
+ thread.repo ?? undefined,
+ scope.defaultHostId
+ )
+ if (!scope.visibleHostIds.includes(hostId)) {
+ return false
+ }
+ }
+ // Why: repo-less terminal buckets have no project, so a project scope hides them.
+ if (scope.filterRepoIds.length > 0) {
+ if (!thread.repo || !scope.filterRepoIds.includes(thread.repo.id)) {
+ return false
+ }
+ }
+ return true
+}
diff --git a/src/renderer/src/components/activity/activity-standalone-worktree.ts b/src/renderer/src/components/activity/activity-standalone-worktree.ts
new file mode 100644
index 00000000000..9844fbb9896
--- /dev/null
+++ b/src/renderer/src/components/activity/activity-standalone-worktree.ts
@@ -0,0 +1,67 @@
+import { i18n, translate } from '@/i18n/i18n'
+import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
+import type { Worktree } from '../../../../shared/worktree/types'
+import type { ExecutionHostId } from '../../../../shared/execution-host'
+
+const STANDALONE_ACTIVITY_WORKTREE_REPO_ID = '__activity_standalone__'
+const STANDALONE_ACTIVITY_WORKTREES_CAP = 200
+const standaloneActivityWorktrees = new Map()
+// The cached rows carry a localized displayName, so they cannot outlive a language switch.
+let cachedDisplayNameLocale: string | undefined
+
+function buildStandaloneActivityWorktree(
+ worktreeId: string,
+ executionHostId?: ExecutionHostId
+): Worktree {
+ const displayName =
+ worktreeId === FLOATING_TERMINAL_WORKTREE_ID
+ ? translate(
+ 'auto.components.activity.standaloneWorktree.floatingTerminal',
+ 'Floating terminal'
+ )
+ : translate(
+ 'auto.components.activity.standaloneWorktree.standaloneTerminal',
+ 'Standalone terminal'
+ )
+ return {
+ id: worktreeId,
+ ...(executionHostId ? { hostId: executionHostId } : {}),
+ 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
+ }
+}
+
+/** Return a stable synthetic worktree for terminal-only activity. */
+export function standaloneActivityWorktree(
+ worktreeId: string,
+ executionHostId?: ExecutionHostId
+): Worktree {
+ if (cachedDisplayNameLocale !== i18n.language) {
+ cachedDisplayNameLocale = i18n.language
+ standaloneActivityWorktrees.clear()
+ }
+ const cacheKey = `${worktreeId}\0${executionHostId ?? ''}`
+ let worktree = standaloneActivityWorktrees.get(cacheKey)
+ if (!worktree) {
+ if (standaloneActivityWorktrees.size >= STANDALONE_ACTIVITY_WORKTREES_CAP) {
+ standaloneActivityWorktrees.clear()
+ }
+ worktree = buildStandaloneActivityWorktree(worktreeId, executionHostId)
+ standaloneActivityWorktrees.set(cacheKey, worktree)
+ }
+ return worktree
+}
diff --git a/src/renderer/src/components/activity/activity-tab-projection.ts b/src/renderer/src/components/activity/activity-tab-projection.ts
new file mode 100644
index 00000000000..028d474d519
--- /dev/null
+++ b/src/renderer/src/components/activity/activity-tab-projection.ts
@@ -0,0 +1,66 @@
+import type { Tab } from '../../../../shared/tab-types'
+
+/**
+ * Stable view of the unified tab map for the activity pipeline.
+ *
+ * Why: the store rewrites the focused tab object (lastFocusedAt) on every focus, which would
+ * rebuild every activity thread even though nothing the pipeline reads changed. Keep only the
+ * tab kinds the pipeline consults and reuse prior tab objects when their relevant fields match,
+ * so downstream identity-keyed caches keep hitting.
+ */
+export type ActivityTabProjection = Record
+
+function isActivityRelevantTab(tab: Tab): boolean {
+ return tab.contentType === 'terminal' || tab.contentType === 'agent-session'
+}
+
+function activityTabFieldsEqual(a: Tab, b: Tab): boolean {
+ return (
+ a.id === b.id &&
+ a.entityId === b.entityId &&
+ a.worktreeId === b.worktreeId &&
+ a.executionHostId === b.executionHostId &&
+ a.contentType === b.contentType &&
+ a.label === b.label &&
+ a.generatedLabel === b.generatedLabel &&
+ a.customLabel === b.customLabel &&
+ a.color === b.color &&
+ a.isPinned === b.isPinned &&
+ a.sortOrder === b.sortOrder &&
+ a.createdAt === b.createdAt
+ )
+}
+
+export function projectActivityTabs(
+ unifiedTabsByWorktree: Record | undefined,
+ previous: ActivityTabProjection | null
+): ActivityTabProjection {
+ const next: ActivityTabProjection = {}
+ let changed = previous === null
+ for (const [worktreeId, tabs] of Object.entries(unifiedTabsByWorktree ?? {})) {
+ const relevant = tabs.filter(isActivityRelevantTab)
+ if (relevant.length === 0) {
+ continue
+ }
+ const prior = previous?.[worktreeId]
+ let reusable = prior !== undefined && prior.length === relevant.length
+ const projected = relevant.map((tab, index) => {
+ const priorTab = prior?.[index]
+ if (priorTab && activityTabFieldsEqual(priorTab, tab)) {
+ return priorTab
+ }
+ reusable = false
+ return tab
+ })
+ if (reusable && prior) {
+ next[worktreeId] = prior
+ } else {
+ next[worktreeId] = projected
+ changed = true
+ }
+ }
+ if (!changed && previous && Object.keys(previous).length === Object.keys(next).length) {
+ return previous
+ }
+ return next
+}
diff --git a/src/renderer/src/components/activity/activity-thread-actions.test.ts b/src/renderer/src/components/activity/activity-thread-actions.test.ts
new file mode 100644
index 00000000000..ce2de01fe02
--- /dev/null
+++ b/src/renderer/src/components/activity/activity-thread-actions.test.ts
@@ -0,0 +1,162 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { makeRepo, makeTab, makeWorktree } from './ActivityPrototypePage-test-fixtures'
+import type { AgentPaneThread } from './activity-thread-types'
+
+const mocks = vi.hoisted(() => ({
+ getState: vi.fn(),
+ activateTabAndFocusPane: vi.fn(),
+ activateStructuredAgentSessionTab: vi.fn(),
+ activateAndRevealWorkspace: vi.fn()
+}))
+
+vi.mock('@/store', () => ({ useAppStore: { getState: mocks.getState } }))
+vi.mock('@/lib/activate-tab-and-focus-pane', () => ({
+ activateTabAndFocusPane: mocks.activateTabAndFocusPane
+}))
+vi.mock('@/lib/structured-agent-session-tab-activation', () => ({
+ activateStructuredAgentSessionTab: mocks.activateStructuredAgentSessionTab
+}))
+vi.mock('@/lib/worktree-activation', () => ({
+ activateAndRevealWorkspace: mocks.activateAndRevealWorkspace
+}))
+
+import { createActivityThreadActions, hasActivityThreadWorkspace } from './activity-thread-actions'
+
+const REMOTE_HOST = 'ssh:devbox' as const
+
+function makeRemoteThread(): AgentPaneThread {
+ const worktree = { ...makeWorktree(), hostId: REMOTE_HOST }
+ return {
+ paneKey: 'tab-1:11111111-1111-4111-8111-111111111111',
+ paneTitle: 'Remote agent',
+ agentType: 'claude',
+ worktree,
+ repo: makeRepo(),
+ tab: makeTab(),
+ events: [],
+ latestEvent: null,
+ latestTimestamp: 1_000,
+ currentAgentState: 'working',
+ currentAgentEntry: null,
+ unread: true,
+ responsePreview: ''
+ }
+}
+
+describe('activity thread host routing', () => {
+ const thread = makeRemoteThread()
+ const getKnownWorktreeById = vi.fn()
+ const setActiveWorktree = vi.fn()
+ const acknowledgeAgents = vi.fn()
+ const setSelectedPaneKey = vi.fn()
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.activateStructuredAgentSessionTab.mockReturnValue(false)
+ getKnownWorktreeById.mockReturnValue(thread.worktree)
+ mocks.getState.mockReturnValue({
+ getKnownWorktreeById,
+ worktreesByRepo: { [thread.worktree.repoId]: [thread.worktree] },
+ detectedWorktreesByRepo: {},
+ folderWorkspaces: [],
+ showSleepingWorkspaces: true,
+ filterRepoIds: [],
+ hideDefaultBranchWorkspace: false,
+ hideAutomationGeneratedWorkspaces: false,
+ hideCliCreatedWorkspaces: false,
+ hideDetachedHeadWorkspaces: false,
+ hideWorkspacesFromOtherDevices: false,
+ alwaysShowDefaultBranchWorkspace: true,
+ visibleWorkspaceHostIds: null,
+ workspaceHostScope: 'all',
+ tabsByWorktree: { [thread.worktree.id]: [thread.tab] },
+ unifiedTabsByWorktree: {},
+ activeRepoId: thread.worktree.repoId,
+ activeWorktreeId: thread.worktree.id,
+ activeWorkspaceExecutionHostId: 'local',
+ setActiveRepo: vi.fn(),
+ setActiveWorktree,
+ setActiveTabType: vi.fn()
+ })
+ })
+
+ it('selects the matching host when the same workspace id is active elsewhere', () => {
+ const actions = createActivityThreadActions({
+ getMarkAllReadThreads: () => [thread],
+ acknowledgeAgents,
+ unacknowledgeAgents: vi.fn(),
+ setSelectedPaneKey
+ })
+
+ actions.selectThread(thread)
+
+ expect(getKnownWorktreeById).toHaveBeenCalledWith(thread.worktree.id, REMOTE_HOST)
+ expect(setActiveWorktree).toHaveBeenCalledWith(thread.worktree.id, REMOTE_HOST)
+ expect(mocks.activateTabAndFocusPane).toHaveBeenCalledWith(
+ thread.tab.id,
+ '11111111-1111-4111-8111-111111111111',
+ { flashFocusedPane: true, scrollToBottomIfOutputSinceLastView: true }
+ )
+ })
+
+ it('activates a structured agent session instead of looking for a terminal pane', () => {
+ mocks.activateStructuredAgentSessionTab.mockReturnValue(true)
+ mocks.getState.mockReturnValue({
+ ...mocks.getState(),
+ tabsByWorktree: { [thread.worktree.id]: [] },
+ unifiedTabsByWorktree: {
+ [thread.worktree.id]: [{ id: thread.tab.id, contentType: 'agent-session' }]
+ }
+ })
+ const actions = createActivityThreadActions({
+ getMarkAllReadThreads: () => [thread],
+ acknowledgeAgents,
+ unacknowledgeAgents: vi.fn(),
+ setSelectedPaneKey
+ })
+
+ actions.selectThread(thread)
+
+ expect(mocks.activateStructuredAgentSessionTab).toHaveBeenCalledWith({
+ worktreeId: thread.worktree.id,
+ tabId: thread.tab.id
+ })
+ expect(mocks.activateTabAndFocusPane).not.toHaveBeenCalled()
+ })
+
+ it('jumps to and probes the matching host-qualified workspace', () => {
+ expect(hasActivityThreadWorkspace(thread)).toBe(true)
+ const actions = createActivityThreadActions({
+ getMarkAllReadThreads: () => [thread],
+ acknowledgeAgents,
+ unacknowledgeAgents: vi.fn(),
+ setSelectedPaneKey
+ })
+
+ actions.jumpToWorkspace(thread)
+
+ expect(acknowledgeAgents).toHaveBeenCalledWith([thread.paneKey])
+ expect(mocks.activateAndRevealWorkspace).toHaveBeenCalledWith(thread.worktree.id, {
+ executionHostId: REMOTE_HOST
+ })
+ })
+
+ it('marks all unread threads in the mark-all set, reading it at call time', () => {
+ const readThread = { ...makeRemoteThread(), paneKey: 'tab-2:read', unread: false }
+ let markAllSet = [readThread]
+ const actions = createActivityThreadActions({
+ getMarkAllReadThreads: () => markAllSet,
+ acknowledgeAgents,
+ unacknowledgeAgents: vi.fn(),
+ setSelectedPaneKey
+ })
+
+ actions.markAllThreadsRead()
+ expect(acknowledgeAgents).not.toHaveBeenCalled()
+
+ // The handler keeps one identity while the set changes underneath it.
+ markAllSet = [thread, readThread]
+ actions.markAllThreadsRead()
+ expect(acknowledgeAgents).toHaveBeenCalledWith([thread.paneKey])
+ })
+})
diff --git a/src/renderer/src/components/activity/activity-thread-actions.ts b/src/renderer/src/components/activity/activity-thread-actions.ts
index f1e1ff6ae5b..b0971da7ea7 100644
--- a/src/renderer/src/components/activity/activity-thread-actions.ts
+++ b/src/renderer/src/components/activity/activity-thread-actions.ts
@@ -1,22 +1,65 @@
import { activateTabAndFocusPane } from '@/lib/activate-tab-and-focus-pane'
-import { activateAndRevealWorktree } from '@/lib/worktree-activation'
+import { activateStructuredAgentSessionTab } from '@/lib/structured-agent-session-tab-activation'
+import { jumpToWorktreeFromSidebar } from '@/lib/worktree-jump-navigation'
import { useAppStore } from '@/store'
-import { getWorktreeMapFromState } from '@/store/selectors'
+import {
+ getSettingsFocusedExecutionHostId,
+ getWorktreeExecutionHostId,
+ type ExecutionHostId
+} from '../../../../shared/execution-host'
import { parsePaneKey } from '../../../../shared/stable-pane-id'
+import { findKnownWorktreeById } from '@/store/slices/worktrees/listing/detected-worktree-meta'
+import type { AppState } from '@/store/types'
import type { AgentPaneThread } from './activity-thread-types'
+// Same focused-host fallback the Agents scope filter uses; defaulting to `local` here would
+// look up a hostless runtime-owned workspace on the wrong host and silently drop the jump.
+function getActivityThreadExecutionHostId(
+ thread: AgentPaneThread,
+ defaultHostId: ExecutionHostId
+): ExecutionHostId {
+ return getWorktreeExecutionHostId(thread.worktree, thread.repo ?? undefined, defaultHostId)
+}
+
+type ActivityThreadWorkspaceCatalog = Pick<
+ AppState,
+ 'worktreesByRepo' | 'detectedWorktreesByRepo' | 'folderWorkspaces'
+> & { defaultHostId: ExecutionHostId }
+
+function readActivityThreadWorkspaceCatalog(): ActivityThreadWorkspaceCatalog {
+ const state = useAppStore.getState()
+ return { ...state, defaultHostId: getSettingsFocusedExecutionHostId(state.settings) }
+}
+
+export function hasActivityThreadWorkspace(
+ thread: AgentPaneThread,
+ catalog: ActivityThreadWorkspaceCatalog = readActivityThreadWorkspaceCatalog()
+): boolean {
+ return Boolean(
+ findKnownWorktreeById(
+ catalog,
+ thread.worktree.id,
+ getActivityThreadExecutionHostId(thread, catalog.defaultHostId)
+ )
+ )
+}
+
export function createActivityThreadActions({
- allThreads,
+ getMarkAllReadThreads,
acknowledgeAgents,
unacknowledgeAgents,
setSelectedPaneKey
}: {
- allThreads: AgentPaneThread[]
+ /** Getter (not a snapshot) so the handlers keep one identity for the row memo
+ * bail-outs while bulk actions still see the current thread set. This is the
+ * badge-coherent set (child-filter only), not the search/scope-narrowed one,
+ * so Mark all read always drives the Agents badge to zero. */
+ getMarkAllReadThreads: () => AgentPaneThread[]
acknowledgeAgents: (paneKeys: string[]) => void
unacknowledgeAgents: (paneKeys: string[]) => void
setSelectedPaneKey: (paneKey: string | null) => void
}): {
- hasUnreadThreads: boolean
+ markThreadRead: (thread: AgentPaneThread) => void
markThreadUnread: (thread: AgentPaneThread) => void
selectThread: (thread: AgentPaneThread) => void
jumpToWorkspace: (thread: AgentPaneThread) => void
@@ -30,51 +73,67 @@ export function createActivityThreadActions({
unacknowledgeAgents([thread.paneKey])
}
- const activateThreadTerminal = (thread: AgentPaneThread): void => {
+ const activateThreadTarget = (thread: AgentPaneThread): void => {
const state = useAppStore.getState()
- const worktree = getWorktreeMapFromState(state).get(thread.worktree.id)
+ const executionHostId = getActivityThreadExecutionHostId(
+ thread,
+ getSettingsFocusedExecutionHostId(state.settings)
+ )
+ const worktree = state.getKnownWorktreeById(thread.worktree.id, executionHostId)
if (!worktree) {
return
}
- // Why: retained-agent threads can outlive their tab; without a live tab, reorienting the workspace and focusing a dead tab id would just confuse the user.
const liveTabs = state.tabsByWorktree[worktree.id] ?? []
- const hasLiveTab = liveTabs.some((t) => t.id === thread.tab.id)
- if (!hasLiveTab) {
+ const hasLiveTerminal = liveTabs.some((tab) => tab.id === thread.tab.id)
+ const hasLiveAgentSession = (state.unifiedTabsByWorktree?.[worktree.id] ?? []).some(
+ (tab) => tab.id === thread.tab.id && tab.contentType === 'agent-session'
+ )
+ // Why: retained threads can outlive their target; reorienting the workspace for a
+ // dead terminal or structured session would just confuse the user.
+ if (!hasLiveTerminal && !hasLiveAgentSession) {
return
}
if (state.activeRepoId !== worktree.repoId) {
state.setActiveRepo(worktree.repoId)
}
- if (state.activeWorktreeId !== worktree.id) {
- state.setActiveWorktree(worktree.id)
+ if (
+ state.activeWorktreeId !== worktree.id ||
+ state.activeWorkspaceExecutionHostId !== executionHostId
+ ) {
+ state.setActiveWorktree(worktree.id, executionHostId)
+ }
+ if (activateStructuredAgentSessionTab({ worktreeId: worktree.id, tabId: thread.tab.id })) {
+ return
}
state.setActiveTabType('terminal')
const parsed = parsePaneKey(thread.paneKey)
activateTabAndFocusPane(
thread.tab.id,
parsed && parsed.tabId === thread.tab.id ? parsed.leafId : null,
- { scrollToBottomIfOutputSinceLastView: true }
+ { flashFocusedPane: true, scrollToBottomIfOutputSinceLastView: true }
)
}
const selectThread = (thread: AgentPaneThread): void => {
setSelectedPaneKey(thread.paneKey)
- activateThreadTerminal(thread)
+ activateThreadTarget(thread)
}
const jumpToWorkspace = (thread: AgentPaneThread): void => {
- const state = useAppStore.getState()
- if (!getWorktreeMapFromState(state).has(thread.worktree.id)) {
+ const catalog = readActivityThreadWorkspaceCatalog()
+ if (!hasActivityThreadWorkspace(thread, catalog)) {
return
}
markThreadRead(thread)
- activateAndRevealWorktree(thread.worktree.id)
+ jumpToWorktreeFromSidebar(thread.worktree.id, {
+ executionHostId: getActivityThreadExecutionHostId(thread, catalog.defaultHostId)
+ })
}
- const hasUnreadThreads = allThreads.some((thread) => thread.unread)
-
const markAllThreadsRead = (): void => {
- const unreadKeys = allThreads.filter((t) => t.unread).map((t) => t.paneKey)
+ const unreadKeys = getMarkAllReadThreads()
+ .filter((t) => t.unread)
+ .map((t) => t.paneKey)
if (unreadKeys.length === 0) {
return
}
@@ -82,7 +141,7 @@ export function createActivityThreadActions({
}
return {
- hasUnreadThreads,
+ markThreadRead,
markThreadUnread,
selectThread,
jumpToWorkspace,
diff --git a/src/renderer/src/components/activity/activity-thread-builder.ts b/src/renderer/src/components/activity/activity-thread-builder.ts
index 8ba6ab4f5fd..a1076ea0022 100644
--- a/src/renderer/src/components/activity/activity-thread-builder.ts
+++ b/src/renderer/src/components/activity/activity-thread-builder.ts
@@ -9,11 +9,68 @@ import type {
AgentPaneThread
} from './activity-thread-types'
-export function buildAgentPaneThreads(args: {
- events: ActivityEvent[]
- liveAgentByPaneKey: Record
- generatedTitlesEnabled?: boolean
-}): AgentPaneThread[] {
+/**
+ * Caller-owned reuse cache: threads whose derived content is unchanged keep their
+ * previous object (and the whole list keeps its array) identity, so memo'd rows and
+ * the search-text cache survive unrelated store writes.
+ */
+export type AgentPaneThreadReuseCache = {
+ previousByPaneKey: Map
+ previousList: AgentPaneThread[]
+}
+
+export function createAgentPaneThreadReuseCache(): AgentPaneThreadReuseCache {
+ return { previousByPaneKey: new Map(), previousList: [] }
+}
+
+function arrayItemsEqual(a: readonly T[], b: readonly T[]): boolean {
+ if (a.length !== b.length) {
+ return false
+ }
+ for (let i = 0; i < a.length; i += 1) {
+ if (a[i] !== b[i]) {
+ return false
+ }
+ }
+ return true
+}
+
+// Why: event and live-snapshot identities are preserved upstream (activity-event-builder
+// cache), so identity comparison on the referenced objects is a correct change detector.
+function reuseThreadIfEqual(
+ previous: AgentPaneThread | undefined,
+ next: AgentPaneThread
+): AgentPaneThread {
+ if (
+ previous !== undefined &&
+ previous.paneKey === next.paneKey &&
+ previous.paneTitle === next.paneTitle &&
+ previous.worktree === next.worktree &&
+ previous.repo === next.repo &&
+ previous.tab === next.tab &&
+ previous.agentType === next.agentType &&
+ previous.currentAgentState === next.currentAgentState &&
+ previous.currentAgentEntry === next.currentAgentEntry &&
+ previous.responsePreview === next.responsePreview &&
+ previous.latestTimestamp === next.latestTimestamp &&
+ previous.latestEvent === next.latestEvent &&
+ previous.migrationUnsupportedPtyId === next.migrationUnsupportedPtyId &&
+ previous.unread === next.unread &&
+ arrayItemsEqual(previous.events, next.events)
+ ) {
+ return previous
+ }
+ return next
+}
+
+export function buildAgentPaneThreads(
+ args: {
+ events: ActivityEvent[]
+ liveAgentByPaneKey: Record
+ generatedTitlesEnabled?: boolean
+ },
+ reuseCache?: AgentPaneThreadReuseCache
+): AgentPaneThread[] {
const generatedTitlesEnabled = args.generatedTitlesEnabled === true
const byPaneKey = new Map()
for (const event of args.events) {
@@ -92,10 +149,22 @@ export function buildAgentPaneThreads(args: {
existing.latestTimestamp = liveAgent.timestamp
}
- return Array.from(byPaneKey.values())
- .map((thread) => ({
- ...thread,
- events: [...thread.events].sort((a, b) => b.timestamp - a.timestamp)
- }))
+ const built = Array.from(byPaneKey.values())
+ .map((thread) => {
+ const next: AgentPaneThread = {
+ ...thread,
+ events: [...thread.events].sort((a, b) => b.timestamp - a.timestamp)
+ }
+ return reuseThreadIfEqual(reuseCache?.previousByPaneKey.get(thread.paneKey), next)
+ })
.sort((a, b) => b.latestTimestamp - a.latestTimestamp)
+
+ if (!reuseCache) {
+ return built
+ }
+ // Why: keep the list's array identity too, so downstream memos keyed on the list bail out.
+ const result = arrayItemsEqual(reuseCache.previousList, built) ? reuseCache.previousList : built
+ reuseCache.previousList = result
+ reuseCache.previousByPaneKey = new Map(result.map((thread) => [thread.paneKey, thread]))
+ return result
}
diff --git a/src/renderer/src/components/activity/activity-thread-child-agent.test.ts b/src/renderer/src/components/activity/activity-thread-child-agent.test.ts
new file mode 100644
index 00000000000..7c687c72037
--- /dev/null
+++ b/src/renderer/src/components/activity/activity-thread-child-agent.test.ts
@@ -0,0 +1,171 @@
+import { describe, expect, it } from 'vitest'
+import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
+import { collectChildAgentPaneKeys } from './activity-thread-child-agent'
+import type { ActivityEvent, AgentPaneThread } from './activity-thread-types'
+import {
+ makeRepo,
+ makeTabWithIds,
+ makeWorkingEntryWithoutHistory,
+ makeWorktree,
+ PANE_KEY,
+ PANE_KEY_2,
+ PANE_KEY_3
+} from './ActivityPrototypePage-test-fixtures'
+
+function makeTestEntry(
+ paneKey: string,
+ overrides: Partial = {}
+): AgentStatusEntry {
+ return {
+ ...makeWorkingEntryWithoutHistory(),
+ paneKey,
+ state: 'done',
+ prompt: 'test prompt',
+ stateHistory: [],
+ ...overrides
+ }
+}
+
+function makeTestThread(
+ paneKey: string,
+ overrides: Partial = {}
+): AgentPaneThread {
+ const worktree = makeWorktree()
+ return {
+ paneKey,
+ paneTitle: 'Test Agent',
+ agentType: 'claude',
+ worktree,
+ repo: makeRepo(),
+ tab: makeTabWithIds('tab-1', worktree.id),
+ events: [],
+ latestEvent: null,
+ latestTimestamp: 1000,
+ currentAgentState: 'working',
+ currentAgentEntry: makeTestEntry(paneKey),
+ unread: false,
+ responsePreview: '',
+ ...overrides
+ }
+}
+
+function makeEventFor(entry: AgentStatusEntry): ActivityEvent {
+ const worktree = makeWorktree()
+ return {
+ id: `event-${entry.paneKey}`,
+ state: 'done',
+ timestamp: 1000,
+ unread: false,
+ worktree,
+ repo: null,
+ tab: makeTabWithIds('tab-1', worktree.id),
+ agentType: 'claude',
+ agentAlive: true,
+ entry
+ }
+}
+
+describe('collectChildAgentPaneKeys', () => {
+ it('returns an empty set when no thread carries orchestration', () => {
+ const threads = [makeTestThread(PANE_KEY), makeTestThread(PANE_KEY_2)]
+ expect(collectChildAgentPaneKeys(threads).size).toBe(0)
+ })
+
+ it('classifies a thread whose parent pane is listed as a child', () => {
+ const parent = makeTestThread(PANE_KEY)
+ const child = makeTestThread(PANE_KEY_2, {
+ currentAgentEntry: makeTestEntry(PANE_KEY_2, {
+ orchestration: { parentPaneKey: PANE_KEY, taskId: 'task-1', dispatchId: 'ctx-1' }
+ })
+ })
+ expect(collectChildAgentPaneKeys([parent, child])).toEqual(new Set([PANE_KEY_2]))
+ })
+
+ it('promotes an orphan whose parent pane is no longer listed', () => {
+ const orphan = makeTestThread(PANE_KEY_2, {
+ currentAgentEntry: makeTestEntry(PANE_KEY_2, {
+ orchestration: { parentPaneKey: PANE_KEY, taskId: 'task-1', dispatchId: 'ctx-1' }
+ })
+ })
+ expect(collectChildAgentPaneKeys([orphan]).size).toBe(0)
+ })
+
+ it('ignores a self-referencing parentPaneKey', () => {
+ const thread = makeTestThread(PANE_KEY, {
+ currentAgentEntry: makeTestEntry(PANE_KEY, {
+ orchestration: { parentPaneKey: PANE_KEY, taskId: 'task-1', dispatchId: 'ctx-1' }
+ })
+ })
+ expect(collectChildAgentPaneKeys([thread]).size).toBe(0)
+ })
+
+ it('resolves coordinatorHandle through a listed thread terminal handle', () => {
+ const coordinator = makeTestThread(PANE_KEY, {
+ currentAgentEntry: makeTestEntry(PANE_KEY, { terminalHandle: 'terminal-coord' })
+ })
+ const worker = makeTestThread(PANE_KEY_2, {
+ currentAgentEntry: makeTestEntry(PANE_KEY_2, {
+ terminalHandle: 'terminal-worker',
+ orchestration: {
+ coordinatorHandle: 'terminal-coord',
+ taskId: 'task-1',
+ dispatchId: 'ctx-1'
+ }
+ })
+ })
+ expect(collectChildAgentPaneKeys([coordinator, worker])).toEqual(new Set([PANE_KEY_2]))
+ })
+
+ it('promotes a worker whose coordinator handle matches no listed thread', () => {
+ const worker = makeTestThread(PANE_KEY_2, {
+ currentAgentEntry: makeTestEntry(PANE_KEY_2, {
+ terminalHandle: 'terminal-worker',
+ orchestration: { coordinatorHandle: 'terminal-gone', taskId: 'task-1', dispatchId: 'ctx-1' }
+ })
+ })
+ expect(collectChildAgentPaneKeys([worker]).size).toBe(0)
+ })
+
+ it('keeps child classification from an older event while the parent is listed', () => {
+ const parent = makeTestThread(PANE_KEY)
+ const childEntry = makeTestEntry(PANE_KEY_2, {
+ orchestration: { parentPaneKey: PANE_KEY, taskId: 'task-1', dispatchId: 'ctx-1' }
+ })
+ const child = makeTestThread(PANE_KEY_2, {
+ currentAgentEntry: makeTestEntry(PANE_KEY_2),
+ events: [makeEventFor(childEntry)]
+ })
+ expect(collectChildAgentPaneKeys([parent, child])).toEqual(new Set([PANE_KEY_2]))
+ })
+
+ it('classifies a grandchild chained through a listed child', () => {
+ const root = makeTestThread(PANE_KEY)
+ const child = makeTestThread(PANE_KEY_2, {
+ currentAgentEntry: makeTestEntry(PANE_KEY_2, {
+ orchestration: { parentPaneKey: PANE_KEY, taskId: 'task-1', dispatchId: 'ctx-1' }
+ })
+ })
+ const grandchild = makeTestThread(PANE_KEY_3, {
+ currentAgentEntry: makeTestEntry(PANE_KEY_3, {
+ orchestration: { parentPaneKey: PANE_KEY_2, taskId: 'task-2', dispatchId: 'ctx-2' }
+ })
+ })
+ expect(collectChildAgentPaneKeys([root, child, grandchild])).toEqual(
+ new Set([PANE_KEY_2, PANE_KEY_3])
+ )
+ })
+
+ it('promotes every member of a parent cycle instead of hiding them all', () => {
+ const a = makeTestThread(PANE_KEY, {
+ currentAgentEntry: makeTestEntry(PANE_KEY, {
+ orchestration: { parentPaneKey: PANE_KEY_2, taskId: 'task-1', dispatchId: 'ctx-1' }
+ })
+ })
+ const b = makeTestThread(PANE_KEY_2, {
+ currentAgentEntry: makeTestEntry(PANE_KEY_2, {
+ orchestration: { parentPaneKey: PANE_KEY, taskId: 'task-2', dispatchId: 'ctx-2' }
+ })
+ })
+ expect(collectChildAgentPaneKeys([a, b]).size).toBe(0)
+ })
+})
diff --git a/src/renderer/src/components/activity/activity-thread-child-agent.ts b/src/renderer/src/components/activity/activity-thread-child-agent.ts
new file mode 100644
index 00000000000..4fd0a3dbd10
--- /dev/null
+++ b/src/renderer/src/components/activity/activity-thread-child-agent.ts
@@ -0,0 +1,90 @@
+import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
+import {
+ buildAgentRowLineageTree,
+ resolveAgentRowParentPaneKey,
+ type AgentLineageSourceRow
+} from '../dashboard/agent-row-lineage-model'
+
+type ChildAgentLineageEntry = Pick
+
+/** Minimal structural input: a full AgentPaneThread satisfies it (pinned by the
+ * classifier tests), and count/badge callers can feed synthetic rows uncast. */
+export type ChildAgentClassifiableThread = {
+ paneKey: string
+ currentAgentEntry?: ChildAgentLineageEntry | null
+ latestEvent?: { entry: ChildAgentLineageEntry } | null
+ events?: readonly { entry: ChildAgentLineageEntry }[]
+}
+
+/** Every entry that can carry the pane's orchestration lineage, newest first. */
+function candidateEntries(thread: ChildAgentClassifiableThread): ChildAgentLineageEntry[] {
+ const entries: ChildAgentLineageEntry[] = []
+ if (thread.currentAgentEntry) {
+ entries.push(thread.currentAgentEntry)
+ }
+ if (thread.latestEvent?.entry) {
+ entries.push(thread.latestEvent.entry)
+ }
+ for (const event of thread.events ?? []) {
+ entries.push(event.entry)
+ }
+ return entries
+}
+
+function firstReportedTerminalHandle(thread: ChildAgentClassifiableThread): string | undefined {
+ for (const entry of candidateEntries(thread)) {
+ if (entry.terminalHandle) {
+ return entry.terminalHandle
+ }
+ }
+ return undefined
+}
+
+/**
+ * Pane keys of threads that are children of another currently listed thread.
+ * Delegates to the dashboard's lineage model so both surfaces classify the same
+ * pane identically: a parent reference only counts while the parent thread is
+ * still listed, so orphaned workers (their coordinator pane closed) and cycle
+ * members are promoted to top level instead of staying hidden behind the
+ * child-agent filter. Classification is sticky across a thread's older events:
+ * the newest entry whose parent still resolves wins.
+ */
+export function collectChildAgentPaneKeys(
+ threads: readonly ChildAgentClassifiableThread[]
+): Set {
+ const baseRows: AgentLineageSourceRow[] = threads.map((thread) => ({
+ paneKey: thread.paneKey,
+ entry: { terminalHandle: firstReportedTerminalHandle(thread) }
+ }))
+ const rowsByPaneKey = new Map()
+ for (const row of baseRows) {
+ if (!rowsByPaneKey.has(row.paneKey)) {
+ rowsByPaneKey.set(row.paneKey, row)
+ }
+ }
+ const paneKeyByTerminalHandle = new Map()
+ for (const row of baseRows) {
+ if (row.entry.terminalHandle && !paneKeyByTerminalHandle.has(row.entry.terminalHandle)) {
+ paneKeyByTerminalHandle.set(row.entry.terminalHandle, row.paneKey)
+ }
+ }
+
+ const rows = threads.map((thread, index) => {
+ const base = baseRows[index]
+ for (const entry of candidateEntries(thread)) {
+ if (!entry.orchestration) {
+ continue
+ }
+ const probe: AgentLineageSourceRow = {
+ paneKey: thread.paneKey,
+ entry: { terminalHandle: base.entry.terminalHandle, orchestration: entry.orchestration }
+ }
+ if (resolveAgentRowParentPaneKey(probe, rowsByPaneKey, paneKeyByTerminalHandle)) {
+ return probe
+ }
+ }
+ return base
+ })
+
+ return buildAgentRowLineageTree(rows).childPaneKeys
+}
diff --git a/src/renderer/src/components/activity/activity-thread-collapse-context.ts b/src/renderer/src/components/activity/activity-thread-collapse-context.ts
new file mode 100644
index 00000000000..070b395174d
--- /dev/null
+++ b/src/renderer/src/components/activity/activity-thread-collapse-context.ts
@@ -0,0 +1,13 @@
+import { createContext } from 'react'
+
+export type ActivityThreadCollapseState = {
+ collapsedGroupKeys: ReadonlySet
+ onToggleGroupCollapse: (groupKey: string) => void
+}
+
+/**
+ * Caller-owned collapse state for ActivityThreadListPane hosts that unmount the
+ * pane (sidebar body switches) but should keep the user's collapsed groups.
+ * Explicit collapsedGroupKeys/onToggleGroupCollapse props take precedence.
+ */
+export const ActivityThreadCollapseContext = createContext(null)
diff --git a/src/renderer/src/components/activity/activity-thread-controls.tsx b/src/renderer/src/components/activity/activity-thread-controls.tsx
index f6cb249c9fc..ad52cd9c51c 100644
--- a/src/renderer/src/components/activity/activity-thread-controls.tsx
+++ b/src/renderer/src/components/activity/activity-thread-controls.tsx
@@ -1,17 +1,11 @@
import React from 'react'
-import { MoreVertical } from 'lucide-react'
+import { ChevronDown } from 'lucide-react'
import { AgentStateDot } from '@/components/AgentStateDot'
-import { Button } from '@/components/ui/button'
-import {
- DropdownMenu,
- DropdownMenuCheckboxItem,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuSeparator,
- DropdownMenuTrigger
-} from '@/components/ui/dropdown-menu'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { translate } from '@/i18n/i18n'
+import { useNow } from '@/hooks/use-now'
+import { cn } from '@/lib/utils'
+import { formatShortTimeAgo } from '@/lib/short-time-ago'
import { RepoBadgeMark } from '@/components/repo/RepoBadgeLabel'
import type { Repo } from '../../../../shared/repo-types'
import {
@@ -22,18 +16,33 @@ import {
} from './activity-thread-presentation'
import type { ActivityThreadGroup, AgentPaneThread } from './activity-thread-types'
-export function EventTime({ timestamp }: { timestamp: number }): React.JSX.Element {
+export { ActivityThreadOptionsMenu } from './activity-thread-options-menu'
+
+export function EventTime({
+ timestamp,
+ compact = false
+}: {
+ timestamp: number
+ compact?: boolean
+}): React.JSX.Element {
+ // Why: rows reuse their identity across store writes, so this label can't rely on
+ // incidental re-renders to stay honest; the shared visibility-gated clock re-renders
+ // only this leaf (memo'd rows stay bailed out). 30s cadence matches WorktreeCardAgents.
+ const now = useNow(30_000)
const absolute = formatAbsoluteDate(timestamp)
return (
event.stopPropagation()}
>
- {formatRelativeTime(timestamp)}
+ {compact ? formatShortTimeAgo(timestamp, now) : formatRelativeTime(timestamp, now)}
@@ -43,60 +52,6 @@ export function EventTime({ timestamp }: { timestamp: number }): React.JSX.Eleme
)
}
-export function ActivityThreadOptionsMenu({
- compactMode,
- hasUnreadThreads,
- onCompactModeChange,
- onMarkAllThreadsRead
-}: {
- compactMode: boolean
- hasUnreadThreads: boolean
- onCompactModeChange: (compactMode: boolean) => void
- onMarkAllThreadsRead: () => void
-}): React.JSX.Element {
- return (
-
-
-
- {/* Why: keep Tooltip and Dropdown from composing refs onto the same button (Radix setRef crash loop). */}
-
-
-
-
-
-
-
-
-
- {translate('auto.components.activity.ActivityPrototypePage.a472a14700', 'More options')}
-
-
-
- onCompactModeChange(checked === true)}
- onSelect={(event) => event.preventDefault()}
- >
- {translate('auto.components.activity.ActivityPrototypePage.f70e4bec47', 'Compact mode')}
-
-
-
- {translate('auto.components.activity.ActivityPrototypePage.023ff75afe', 'Mark all read')}
-
-
-
- )
-}
-
export function ActivityProjectLabel({ repo }: { repo: Repo | null }): React.JSX.Element {
const label =
repo?.displayName?.trim() ||
@@ -150,21 +105,54 @@ export function ThreadAgentStateIndicator({
}
export function ActivityStatusGroupHeader({
- group
+ group,
+ collapsed = false,
+ onToggle,
+ className
}: {
group: ActivityThreadGroup
+ collapsed?: boolean
+ onToggle?: () => void
+ className?: string
}): React.JSX.Element {
+ const isInteractive = Boolean(onToggle)
return (
-
+
{
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault()
+ onToggle?.()
+ }
+ }
+ : undefined
+ }
+ className={cn(
+ 'group flex h-7 select-none items-center gap-1.5 rounded-md px-1.5 py-1 text-muted-foreground transition-colors',
+ isInteractive && 'cursor-pointer hover:bg-accent/60 hover:text-foreground',
+ className
+ )}
+ >
+
{group.state ? (
) : null}
-
+
{group.label}
-
+
{group.threads.length}
diff --git a/src/renderer/src/components/activity/activity-thread-grouping.search-cache.test.ts b/src/renderer/src/components/activity/activity-thread-grouping.search-cache.test.ts
new file mode 100644
index 00000000000..4c5b003eac4
--- /dev/null
+++ b/src/renderer/src/components/activity/activity-thread-grouping.search-cache.test.ts
@@ -0,0 +1,53 @@
+import { describe, expect, it } from 'vitest'
+import {
+ activityThreadMatchesSearchQuery,
+ getThreadSearchTextComputeCount
+} from './activity-thread-grouping'
+import type { AgentPaneThread } from './activity-thread-types'
+import { makeTab, makeWorktree } from './ActivityPrototypePage-test-fixtures'
+
+function makeThread(paneKey: string, paneTitle: string): AgentPaneThread {
+ return {
+ paneKey,
+ tab: makeTab(),
+ worktree: makeWorktree(),
+ repo: null,
+ currentAgentState: null,
+ currentAgentEntry: null,
+ latestEvent: null,
+ latestTimestamp: 1_000,
+ agentType: 'claude',
+ unread: false,
+ paneTitle,
+ responsePreview: 'x'.repeat(2_000),
+ events: []
+ }
+}
+
+describe('activity thread search text cache', () => {
+ it('builds a thread searchable text once per thread identity across keystrokes', () => {
+ const thread = makeThread('tab-1:leaf-1', 'Refactor billing pipeline')
+ const before = getThreadSearchTextComputeCount()
+ // Simulate typing a query letter by letter against the same thread objects.
+ for (const searchQuery of ['r', 're', 'ref', 'refa', 'refac']) {
+ expect(activityThreadMatchesSearchQuery({ thread, searchQuery })).toBe(true)
+ }
+ expect(getThreadSearchTextComputeCount() - before).toBe(1)
+ })
+
+ it('recomputes when thread data changes (new thread identity)', () => {
+ const before = getThreadSearchTextComputeCount()
+ const first = makeThread('tab-1:leaf-1', 'First title')
+ const rebuilt = makeThread('tab-1:leaf-1', 'Second title')
+ expect(activityThreadMatchesSearchQuery({ thread: first, searchQuery: 'first' })).toBe(true)
+ expect(activityThreadMatchesSearchQuery({ thread: rebuilt, searchQuery: 'second' })).toBe(true)
+ expect(getThreadSearchTextComputeCount() - before).toBe(2)
+ })
+
+ it('keeps match semantics: state labels, workspace, and previews still match', () => {
+ const thread = makeThread('tab-1:leaf-1', 'My task')
+ expect(activityThreadMatchesSearchQuery({ thread, searchQuery: 'feature' })).toBe(true)
+ expect(activityThreadMatchesSearchQuery({ thread, searchQuery: 'zzz-no-match' })).toBe(false)
+ expect(activityThreadMatchesSearchQuery({ thread, searchQuery: '' })).toBe(true)
+ })
+})
diff --git a/src/renderer/src/components/activity/activity-thread-grouping.ts b/src/renderer/src/components/activity/activity-thread-grouping.ts
index 1744c0b84f6..0730aa68b5d 100644
--- a/src/renderer/src/components/activity/activity-thread-grouping.ts
+++ b/src/renderer/src/components/activity/activity-thread-grouping.ts
@@ -18,19 +18,23 @@ import type {
AgentPaneThread
} from './activity-thread-types'
+// Attention-needing groups first (interrupted included: it's stopped and awaiting the user) so they're never buried under Working/Done.
const ACTIVITY_STATUS_GROUP_ORDER: ActivityStatusGroupId[] = [
+ 'waiting',
+ 'blocked',
+ 'interrupted',
'working',
'monitoring',
- 'blocked',
- 'waiting',
- 'done',
- 'interrupted'
+ 'done'
]
export function getActivityThreadGroup(
thread: AgentPaneThread,
groupBy: ActivityGroupBy
): { key: string; label: string } {
+ if (groupBy === 'none') {
+ return { key: 'all', label: '' }
+ }
if (groupBy === 'status') {
const state = threadAgentState(thread)
if (!thread.currentAgentState && state === 'done' && thread.latestEvent?.entry.interrupted) {
@@ -59,6 +63,9 @@ export function buildActivityThreadGroups(
threads: AgentPaneThread[],
groupBy: ActivityGroupBy
): ActivityThreadGroup[] {
+ if (groupBy === 'none') {
+ return threads.length > 0 ? [{ key: 'all', label: '', threads }] : []
+ }
const groups: ActivityThreadGroup[] = []
const groupIndexByKey = new Map
()
for (const thread of threads) {
@@ -74,7 +81,7 @@ export function buildActivityThreadGroups(
return groups
}
-function threadStatusGroupId(thread: AgentPaneThread): ActivityStatusGroupId {
+export function threadStatusGroupId(thread: AgentPaneThread): ActivityStatusGroupId {
const state = threadAgentState(thread)
if (!thread.currentAgentState && state === 'done' && thread.latestEvent?.entry.interrupted) {
return 'interrupted'
@@ -118,7 +125,7 @@ export function groupActivityThreadsByStatus(threads: AgentPaneThread[]): Activi
})
}
-function threadSearchText(thread: AgentPaneThread): string {
+function buildThreadSearchText(thread: AgentPaneThread): string {
const latest = thread.latestEvent
const stateLabel = threadAgentStateLabel(thread)
const currentPrompt = thread.currentAgentEntry
@@ -132,6 +139,28 @@ function threadSearchText(thread: AgentPaneThread): string {
return `${thread.paneTitle} ${getActivityThreadWorkspaceTitle(thread.worktree)} ${thread.worktree.branch ?? ''} ${thread.repo?.displayName ?? ''} ${formatAgentTypeLabel(thread.agentType)} ${stateLabel} ${currentPrompt} ${rawCurrentPrompt} ${currentSummary} ${thread.responsePreview} ${latestEventText}`.toLowerCase()
}
+// Why: thread objects are rebuilt only when the underlying store data changes, so their
+// identity is a correct cache key; without this every keystroke re-lowercases a large
+// string per thread. WeakMap so dropped threads release their text.
+const threadSearchTextCache = new WeakMap()
+let threadSearchTextComputeCount = 0
+
+/** Test hook: how many times search text was actually (re)built. */
+export function getThreadSearchTextComputeCount(): number {
+ return threadSearchTextComputeCount
+}
+
+function threadSearchText(thread: AgentPaneThread): string {
+ const cached = threadSearchTextCache.get(thread)
+ if (cached !== undefined) {
+ return cached
+ }
+ threadSearchTextComputeCount += 1
+ const text = buildThreadSearchText(thread)
+ threadSearchTextCache.set(thread, text)
+ return text
+}
+
export const ACTIVITY_SEARCH_QUERY_MAX_BYTES = 2 * 1024
export function isActivitySearchQueryTooLarge(
diff --git a/src/renderer/src/components/activity/activity-thread-hover-card-summary.tsx b/src/renderer/src/components/activity/activity-thread-hover-card-summary.tsx
new file mode 100644
index 00000000000..37b0658f0d9
--- /dev/null
+++ b/src/renderer/src/components/activity/activity-thread-hover-card-summary.tsx
@@ -0,0 +1,230 @@
+import React, { useCallback, useMemo } from 'react'
+import { Cloud, Copy, FolderGit2, GitBranch, Laptop, LocateFixed, Server } from 'lucide-react'
+import { toast } from 'sonner'
+import { useAppStore } from '@/store'
+import { RepoBadgeMark } from '@/components/repo/RepoBadgeLabel'
+import { AgentIcon } from '@/lib/agent-catalog'
+import { agentTypeToIconAgent, formatAgentTypeLabel } from '@/lib/agent-status'
+import { getWorktreeGitIdentityDisplay } from '@/lib/worktree-git-identity-display'
+import { jumpToWorktreeFromSidebar } from '@/lib/worktree-jump-navigation'
+import { translate } from '@/i18n/i18n'
+import { cn } from '@/lib/utils'
+import {
+ getExecutionHostLabel,
+ getWorktreeExecutionHostId,
+ parseExecutionHostId
+} from '../../../../shared/execution-host'
+import type { GlobalSettings } from '../../../../shared/global-settings-types'
+import { getHostDisplayLabelOverrides } from '../../../../shared/host-setting-overrides'
+import CommentMarkdown from '../sidebar/CommentMarkdown'
+import { DetailHeader, MetadataActionIcon } from '../sidebar/WorktreeCardMetadataControls'
+import {
+ WorktreeCardDetailSection,
+ WorktreeCardDetailSectionContent
+} from '../sidebar/WorktreeCardDetailSection'
+import { EventTime, ThreadAgentStateIndicator } from './activity-thread-controls'
+import { activityThreadRowCopy, threadAgentStateLabel } from './activity-thread-presentation'
+import { getActivityThreadWorkspaceTitle } from '@/lib/activity-thread-display'
+import type { AgentPaneThread } from './activity-thread-types'
+
+export function ActivityThreadHoverCardSummary({
+ thread,
+ settings,
+ onJumpToWorkspace,
+ canJumpToWorkspace
+}: {
+ thread: AgentPaneThread
+ settings: GlobalSettings | null | undefined
+ onJumpToWorkspace?: (event: React.MouseEvent) => void
+ canJumpToWorkspace?: boolean
+}): React.JSX.Element {
+ const { worktree, repo } = thread
+ const sshTargetLabels = useAppStore((s) => s.sshTargetLabels)
+ const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments)
+ const executionHostId = getWorktreeExecutionHostId(worktree, repo ?? undefined)
+ const parsedHost = parseExecutionHostId(executionHostId)
+ const hostLabelOverrides = useMemo(() => getHostDisplayLabelOverrides(settings), [settings])
+ const hostDisplayLabel = useMemo(() => {
+ const override = hostLabelOverrides.get(executionHostId)
+ if (override) {
+ return override
+ }
+ if (parsedHost?.kind === 'runtime') {
+ const environment = runtimeEnvironments.find((entry) => entry.id === parsedHost.environmentId)
+ if (environment?.name) {
+ return environment.name
+ }
+ }
+ if (parsedHost?.kind === 'ssh') {
+ const target = sshTargetLabels.get(parsedHost.targetId)
+ if (target) {
+ return target
+ }
+ }
+ return getExecutionHostLabel(executionHostId)
+ }, [executionHostId, hostLabelOverrides, parsedHost, runtimeEnvironments, sshTargetLabels])
+ const branchIdentityDisplay = useMemo(() => getWorktreeGitIdentityDisplay(worktree), [worktree])
+ const { taskTitle, needsAttention } = activityThreadRowCopy(thread)
+ const workspaceTitle = getActivityThreadWorkspaceTitle(worktree)
+ const copyPathLabel = translate(
+ 'auto.components.activity.ActivityThreadHoverCard.copyPath',
+ 'Copy path'
+ )
+
+ const isKnownWorktree = useAppStore((s) =>
+ Boolean(s.getKnownWorktreeById(worktree.id, executionHostId))
+ )
+ const canJump = canJumpToWorkspace ?? isKnownWorktree
+
+ const handleJumpToWorkspace = useCallback(
+ (event: React.MouseEvent) => {
+ event.stopPropagation()
+ if (onJumpToWorkspace) {
+ onJumpToWorkspace(event)
+ } else {
+ const state = useAppStore.getState()
+ if (state.getKnownWorktreeById(worktree.id, executionHostId)) {
+ state.acknowledgeAgents([thread.paneKey])
+ jumpToWorktreeFromSidebar(worktree.id, { executionHostId })
+ }
+ }
+ },
+ [executionHostId, onJumpToWorkspace, thread.paneKey, worktree.id]
+ )
+
+ const handleCopyPath = useCallback(async () => {
+ if (!worktree.path) {
+ return
+ }
+ try {
+ await window.api.ui.writeClipboardText(worktree.path)
+ toast.success(
+ translate(
+ 'auto.components.activity.ActivityThreadHoverCard.pathCopied',
+ 'Path copied to clipboard'
+ )
+ )
+ } catch {
+ toast.error(
+ translate(
+ 'auto.components.activity.ActivityThreadHoverCard.copyPathFailed',
+ 'Failed to copy path'
+ )
+ )
+ }
+ }, [worktree.path])
+
+ return (
+ <>
+
+
+
+
+
+
+
+ {formatAgentTypeLabel(thread.agentType)}
+
+
+
+
+
+ {threadAgentStateLabel(thread)}
+
+ •
+
+
+
+
+
+ {taskTitle}
+
+
+ {thread.responsePreview ? (
+
+
+
+ ) : null}
+
+
+
+ }
+ label={translate(
+ 'auto.components.activity.ActivityThreadHoverCard.workspace',
+ 'Workspace'
+ )}
+ actions={
+ canJump ? (
+
+
+
+ ) : null
+ }
+ />
+
+
+ {repo ? (
+
+
+
+ {repo.displayName}
+
+
+ ) : null}
+
+ {workspaceTitle}
+
+
+
+ {branchIdentityDisplay ? (
+
+
+
+ {branchIdentityDisplay.kind === 'branch'
+ ? branchIdentityDisplay.branchName
+ : branchIdentityDisplay.sidebarLabel}
+
+
+ ) : null}
+
+
+ {parsedHost?.kind === 'runtime' ? (
+
+ ) : parsedHost?.kind === 'ssh' ? (
+
+ ) : (
+
+ )}
+ {hostDisplayLabel}
+
+
+ {worktree.path ? (
+
+
+ {worktree.path}
+
+
+
+
+
+ ) : null}
+
+
+ >
+ )
+}
diff --git a/src/renderer/src/components/activity/activity-thread-hover-card.test.tsx b/src/renderer/src/components/activity/activity-thread-hover-card.test.tsx
new file mode 100644
index 00000000000..0e604a16098
--- /dev/null
+++ b/src/renderer/src/components/activity/activity-thread-hover-card.test.tsx
@@ -0,0 +1,238 @@
+// @vitest-environment happy-dom
+
+import React, { act, type ReactElement, type ReactNode } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { TooltipProvider } from '@/components/ui/tooltip'
+import type { Worktree } from '../../../../shared/worktree/types'
+import { ActivityThreadHoverCard } from './activity-thread-hover-card'
+import { ActivityThreadRow } from './activity-thread-row'
+import type { AgentPaneThread } from './activity-thread-types'
+import { makeRepo, makeTab, makeWorktree } from './ActivityPrototypePage-test-fixtures'
+
+vi.mock('@/components/ui/hover-card', () => ({
+ HoverCard: ({
+ children,
+ onOpenChange
+ }: {
+ children: ReactNode
+ onOpenChange?: (open: boolean) => void
+ }) => {
+ React.useEffect(() => onOpenChange?.(true), [onOpenChange])
+ return <>{children}>
+ },
+ HoverCardContent: ({ children, className }: { children: ReactNode; className?: string }) => (
+
+ {children}
+
+ ),
+ HoverCardTrigger: ({ children }: { children: ReactNode }) => <>{children}>
+}))
+
+globalThis.IS_REACT_ACT_ENVIRONMENT = true
+
+function createTestThread(overrides: Partial = {}): AgentPaneThread {
+ const repo = makeRepo()
+ const worktree: Worktree = {
+ ...makeWorktree(),
+ displayName: 'm4air-audit',
+ branch: 'feat/m4air-performance',
+ path: '/Users/test/projects/orca/worktrees/m4air-audit',
+ comment: 'Notes for performance audit',
+ hostId: 'runtime:m4air-env-id' as const
+ }
+ const tab = makeTab()
+
+ return {
+ paneKey: 'tab-1:leaf-1',
+ tab,
+ worktree,
+ repo,
+ agentType: 'claude',
+ latestEvent: null,
+ currentAgentState: 'working',
+ currentAgentEntry: null,
+ events: [],
+ unread: false,
+ paneTitle: 'Audit current HEAD on m4air environment',
+ responsePreview: 'Auditing terminal PTY and IME bug categories...',
+ latestTimestamp: 1700000000000,
+ ...overrides
+ }
+}
+
+function Harness({
+ thread,
+ selected = false,
+ onSelect = vi.fn(),
+ onJump = vi.fn(),
+ onMarkRead = vi.fn(),
+ onMarkUnread = vi.fn()
+}: {
+ thread: AgentPaneThread
+ selected?: boolean
+ onSelect?: () => void
+ onJump?: () => void
+ onMarkRead?: () => void
+ onMarkUnread?: () => void
+}): ReactElement {
+ return (
+
+
+
+ )
+}
+
+describe('ActivityThreadHoverCard and ActivityThreadRow', () => {
+ let container: HTMLDivElement
+ let root: Root
+
+ beforeEach(() => {
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+ })
+
+ afterEach(() => {
+ act(() => root.unmount())
+ container.remove()
+ document.body.replaceChildren()
+ })
+
+ it('renders the thread row with task title and workspace label', async () => {
+ const thread = createTestThread()
+
+ await act(async () => {
+ root.render( )
+ })
+
+ const card = container.querySelector('[data-worktree-card-surface="true"]')
+ expect(card).not.toBeNull()
+ expect(card?.getAttribute('role')).toBe('listitem')
+ expect(
+ card?.querySelector('button[aria-label="Audit current HEAD on m4air environment"]')
+ ).not.toBeNull()
+ expect(card?.textContent).toContain('Audit current HEAD on m4air environment')
+ expect(card?.textContent).toContain('m4air-audit')
+ })
+
+ it('marks an unread thread as read from its bell without selecting the row', async () => {
+ const thread = createTestThread({ unread: true })
+ const onMarkRead = vi.fn()
+ const onSelect = vi.fn()
+
+ await act(async () => {
+ root.render( )
+ })
+
+ const markReadButton = container.querySelector(
+ 'button[aria-label="Mark thread as read"]'
+ )
+ expect(markReadButton).not.toBeNull()
+
+ act(() => {
+ markReadButton?.click()
+ })
+
+ expect(onMarkRead).toHaveBeenCalledWith(thread)
+ expect(onSelect).not.toHaveBeenCalled()
+ })
+
+ it('renders hover card content with workspace info, host, task details, and notes', async () => {
+ const thread = createTestThread({
+ worktree: {
+ ...makeWorktree(),
+ displayName: 'm4air-audit',
+ branch: 'feat/m4air-performance',
+ path: '/Users/test/projects/orca/worktrees/m4air-audit',
+ comment: 'Performance investigation notes',
+ hostId: 'runtime:m4air-env' as const
+ }
+ })
+
+ await act(async () => {
+ root.render(
+
+
+ Hover Target
+
+
+ )
+ })
+
+ const content = container.querySelector('[data-testid="hover-card-content"]')?.textContent ?? ''
+
+ // Workspace & Host info
+ expect(content).toContain('Workspace')
+ expect(content).toContain('m4air-audit')
+ expect(content).toContain('feat/m4air-performance')
+ expect(content).toContain('/Users/test/projects/orca/worktrees/m4air-audit')
+
+ // Agent & Task details
+ expect(content).toContain('Claude')
+ expect(content).toContain('Audit current HEAD on m4air environment')
+ expect(content).toContain('Auditing terminal PTY and IME bug categories...')
+
+ // Notes
+ expect(content).toContain('Notes')
+ expect(content).toContain('Performance investigation notes')
+ })
+
+ it('allows clicking row while preventing inner hover interactions from bubbling', async () => {
+ const onSelect = vi.fn()
+ const thread = createTestThread()
+
+ await act(async () => {
+ root.render( )
+ })
+
+ const card = container.querySelector('[data-worktree-card-surface="true"]')
+ expect(card).not.toBeNull()
+
+ await act(async () => {
+ card?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
+ })
+
+ expect(onSelect).toHaveBeenCalledTimes(1)
+ })
+
+ it('triggers jump to workspace from the hover card crosshair locator button', async () => {
+ const onJump = vi.fn()
+ const thread = createTestThread()
+
+ await act(async () => {
+ root.render(
+
+
+ Hover Target
+
+
+ )
+ })
+
+ const jumpButton = container.querySelector(
+ 'button[aria-label="Jump to workspace"]'
+ )
+ expect(jumpButton).not.toBeNull()
+
+ act(() => {
+ jumpButton?.click()
+ })
+
+ expect(onJump).toHaveBeenCalledWith(thread)
+ })
+})
diff --git a/src/renderer/src/components/activity/activity-thread-hover-card.tsx b/src/renderer/src/components/activity/activity-thread-hover-card.tsx
new file mode 100644
index 00000000000..9bb4ed83887
--- /dev/null
+++ b/src/renderer/src/components/activity/activity-thread-hover-card.tsx
@@ -0,0 +1,407 @@
+import React, { useCallback } from 'react'
+import { ExternalLink, MonitorUp, Pencil, StickyNote } from 'lucide-react'
+import { toast } from 'sonner'
+import { Badge } from '@/components/ui/badge'
+import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'
+import { LinearIcon } from '@/components/icons/LinearIcon'
+import { JiraIcon } from '@/components/icons/JiraIcon'
+import { SelectedTextCopyMenu } from '@/components/SelectedTextCopyMenu'
+import { translate } from '@/i18n/i18n'
+import CommentMarkdown from '../sidebar/CommentMarkdown'
+import { DetailHeader, MetadataActionIcon } from '../sidebar/WorktreeCardMetadataControls'
+import {
+ WorktreeCardDetailSection,
+ WorktreeCardDetailSectionContent
+} from '../sidebar/WorktreeCardDetailSection'
+import { LinearStateBadge } from '../sidebar/WorktreeCardMetadataStatusBadges'
+import { WorktreeCardIssueDetailSection } from '../sidebar/WorktreeCardIssueDetailSection'
+import { WorktreeCardReviewDetailSection } from '../sidebar/WorktreeCardReviewDetailSection'
+import { WorktreeCardAutomationDetailSection } from '../sidebar/WorktreeCardAutomationDetailSection'
+import { WorktreeCardCliDetailSection } from '../sidebar/WorktreeCardCliDetailSection'
+import { WorktreeCardPortsDetails } from '../sidebar/WorktreeCardPorts'
+import { WORKTREE_NATIVE_CONTEXT_MENU_ATTR } from '../sidebar/WorktreeContextMenu'
+import { useWorktreeCardDetailsHoverControl } from '../sidebar/worktree-card-details-hover-state'
+import { useWorktreeCardFoundation } from '../sidebar/use-worktree-card-foundation'
+import { useWorktreeCardReviewDetails } from '../sidebar/use-worktree-card-review-details'
+import { useWorktreeCardLinkedDetails } from '../sidebar/use-worktree-card-linked-details'
+import { useWorktreeCardLifecycleEffects } from '../sidebar/use-worktree-card-lifecycle-effects'
+import { useWorktreeCardSecondaryDetails } from '../sidebar/use-worktree-card-secondary-details'
+import { getReviewLabel } from '../sidebar/worktree-review-helpers'
+import { ActivityThreadHoverCardSummary } from './activity-thread-hover-card-summary'
+import type { AgentPaneThread } from './activity-thread-types'
+
+export type ActivityThreadHoverCardProps = {
+ thread: AgentPaneThread
+ children: React.ReactElement
+ openDelay?: number
+ closeDelay?: number
+ onJumpToWorkspace?: (thread: AgentPaneThread) => void
+ canJumpToWorkspace?: boolean
+}
+
+export function ActivityThreadHoverCard({
+ thread,
+ children,
+ openDelay = 200,
+ closeDelay = 120,
+ onJumpToWorkspace,
+ canJumpToWorkspace
+}: ActivityThreadHoverCardProps): React.JSX.Element {
+ const detailsHoverControl = useWorktreeCardDetailsHoverControl()
+
+ return (
+
+ {children}
+ {detailsHoverControl.hoverOpen ? (
+
+ ) : null}
+
+ )
+}
+
+function ActivityThreadHoverCardContent({
+ thread,
+ detailsHoverControl,
+ onJumpToWorkspace,
+ canJumpToWorkspace
+}: {
+ thread: AgentPaneThread
+ detailsHoverControl: ReturnType
+ onJumpToWorkspace?: (thread: AgentPaneThread) => void
+ canJumpToWorkspace?: boolean
+}): React.JSX.Element {
+ const { worktree, repo } = thread
+ const foundation = useWorktreeCardFoundation({ worktree, repo: repo ?? undefined })
+ const review = useWorktreeCardReviewDetails({
+ worktree,
+ repo: repo ?? undefined,
+ settings: foundation.settings,
+ projectGroups: foundation.projectGroups,
+ cardProps: foundation.cardProps,
+ newCardStyle: foundation.newCardStyle
+ })
+ const linked = useWorktreeCardLinkedDetails({
+ worktree,
+ newCardStyle: foundation.newCardStyle,
+ deleteState: foundation.deleteState,
+ branch: review.branch,
+ issueEntry: review.issueEntry,
+ linearIssueEntry: review.linearIssueEntry,
+ linearIssueFallbackEntry: review.linearIssueFallbackEntry,
+ prDisplay: review.prDisplay
+ })
+
+ const hoverDetailsOpen = detailsHoverControl.hoverOpen
+
+ useWorktreeCardLifecycleEffects({
+ worktree,
+ repo: repo ?? undefined,
+ isFolder: review.isFolder,
+ hostedReviewCacheKey: review.hostedReviewCacheKey,
+ cachedBranchFallbackGitHubPRNumber: review.cachedBranchFallbackGitHubPRNumber,
+ linkedGitLabMR: review.linkedGitLabMR,
+ linkedBitbucketPR: review.linkedBitbucketPR,
+ linkedAzureDevOpsPR: review.linkedAzureDevOpsPR,
+ linkedGiteaPR: review.linkedGiteaPR,
+ branch: review.branch,
+ fetchHostedReviewForBranch: foundation.fetchHostedReviewForBranch,
+ shouldRefreshHostedReview: false,
+ newCardStyle: true,
+ hoverDetailsOpen,
+ showIssue: true,
+ issueCacheKey: review.issueCacheKey,
+ fetchIssue: foundation.fetchIssue,
+ showLinearIssue: true,
+ fetchLinearIssue: foundation.fetchLinearIssue
+ })
+
+ const secondary = useWorktreeCardSecondaryDetails({
+ worktree,
+ repo: repo ?? undefined,
+ statusPrDisplay: null,
+ showStatus: true,
+ showIssue: true,
+ showLinearIssue: true,
+ showJiraIssue: true,
+ showPR: true,
+ showAutomation: true,
+ showCli: true,
+ showComment: true,
+ showPorts: true,
+ issueDisplay: linked.issueDisplay,
+ linearIssue: linked.linearIssue,
+ linearIssueDisplay: linked.linearIssueDisplay,
+ jiraIssueDisplay: linked.jiraIssueDisplay,
+ prDisplay: review.prDisplay,
+ linkedGitLabMR: review.linkedGitLabMR,
+ linkedBitbucketPR: review.linkedBitbucketPR,
+ linkedAzureDevOpsPR: review.linkedAzureDevOpsPR,
+ linkedGiteaPR: review.linkedGiteaPR,
+ cardProps: foundation.cardProps,
+ newCardStyle: foundation.newCardStyle,
+ compactCards: foundation.compactCards,
+ agentActivityDisplayMode: foundation.agentActivityDisplayMode,
+ workspacePorts: foundation.workspacePorts,
+ openTaskPage: foundation.openTaskPage,
+ updateWorktreeMeta: foundation.updateWorktreeMeta,
+ settings: foundation.settings
+ })
+
+ const copyLinkedWorkItemLink = useCallback(async (url: string, label: string) => {
+ try {
+ await window.api.ui.writeClipboardText(url)
+ toast.success(
+ translate('auto.components.sidebar.WorktreeCardMeta.copyLinkSuccess', '{{value0}} copied', {
+ value0: label
+ })
+ )
+ } catch {
+ toast.error(
+ translate('auto.components.sidebar.WorktreeCardMeta.copyLinkFailure', 'Failed to copy link')
+ )
+ }
+ }, [])
+
+ const handleCopyIssueLink = useCallback(() => {
+ if (!secondary.hoverIssue?.url) {
+ return
+ }
+ detailsHoverControl.closeHover()
+ void copyLinkedWorkItemLink(
+ secondary.hoverIssue.url,
+ translate('auto.components.sidebar.WorktreeCardMeta.issueLinkLabel', 'Issue link')
+ )
+ }, [copyLinkedWorkItemLink, detailsHoverControl, secondary.hoverIssue?.url])
+
+ const handleCopyReviewLink = useCallback(() => {
+ if (!secondary.hoverReview?.url) {
+ return
+ }
+ void copyLinkedWorkItemLink(
+ secondary.hoverReview.url,
+ translate('auto.components.sidebar.WorktreeCardMeta.reviewLinkLabel', '{{value0}} link', {
+ value0: getReviewLabel(secondary.hoverReview)
+ })
+ )
+ }, [copyLinkedWorkItemLink, secondary.hoverReview])
+
+ const dismissAndRun = useCallback(
+ (handler: ((event: React.MouseEvent) => void) | undefined) => (event: React.MouseEvent) => {
+ detailsHoverControl.closeHover()
+ handler?.(event)
+ },
+ [detailsHoverControl]
+ )
+
+ return (
+ event.stopPropagation()}
+ onDoubleClick={(event) => event.stopPropagation()}
+ >
+
+ onJumpToWorkspace(thread)) : undefined
+ }
+ canJumpToWorkspace={canJumpToWorkspace}
+ />
+
+ {/* GitHub / GitLab Issue */}
+ {
+ detailsHoverControl.closeHover()
+ secondary.handleOpenIssueInBrowser(url)
+ }
+ : undefined
+ }
+ />
+
+ {/* Linear Issue */}
+ {secondary.hoverLinearIssue && (
+
+ }
+ label={translate(
+ 'auto.components.sidebar.WorktreeCardMeta.5e982e6128',
+ 'Linear {{value0}}',
+ { value0: secondary.hoverLinearIssue.identifier }
+ )}
+ actions={
+ <>
+ {secondary.hoverLinearIssue.url && secondary.handleOpenLinearIssueInOrca && (
+
+
+
+ )}
+ {secondary.hoverLinearIssue.url && (
+
+
+
+ )}
+ >
+ }
+ />
+
+
+ {secondary.hoverLinearIssue.title}
+
+ {((secondary.hoverLinearIssue.labels &&
+ secondary.hoverLinearIssue.labels.length > 0) ||
+ secondary.hoverLinearIssue.stateName) && (
+
+ {secondary.hoverLinearIssue.stateName && (
+
+ )}
+ {(secondary.hoverLinearIssue.labels ?? []).map((label) => (
+
+ {label}
+
+ ))}
+
+ )}
+
+
+ )}
+
+ {/* Jira Issue */}
+ {secondary.hoverJiraIssue && (
+
+ }
+ label={translate(
+ 'auto.components.sidebar.WorktreeCardMeta.jiraIssue',
+ 'Jira {{value0}}',
+ { value0: secondary.hoverJiraIssue.identifier }
+ )}
+ actions={
+
+
+
+ }
+ />
+
+
+ {secondary.hoverJiraIssue.title}
+
+
+
+ )}
+
+ {/* Pull Request / Review */}
+
+
+ {/* Automation Provenance */}
+ {secondary.metaAutomationProvenance && (
+
+ )}
+
+ {/* CLI Provenance */}
+ {secondary.metaCliProvenance && (
+
+ )}
+
+ {/* Notes / Comment */}
+ {(secondary.hoverComment ?? '').trim().length > 0 && (
+
+ }
+ label={translate('auto.components.sidebar.WorktreeCardMeta.93cbea12c2', 'Notes')}
+ actions={
+
+
+
+ }
+ />
+
+
+
+
+ )}
+
+ {/* Ports */}
+ {foundation.workspacePorts.length > 0 && (
+
+ )}
+
+
+ )
+}
diff --git a/src/renderer/src/components/activity/activity-thread-list-pane-collapsible.test.tsx b/src/renderer/src/components/activity/activity-thread-list-pane-collapsible.test.tsx
new file mode 100644
index 00000000000..6db25785dc6
--- /dev/null
+++ b/src/renderer/src/components/activity/activity-thread-list-pane-collapsible.test.tsx
@@ -0,0 +1,292 @@
+// @vitest-environment happy-dom
+
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { TooltipProvider } from '@/components/ui/tooltip'
+import { ActivityStatusGroupHeader } from './activity-thread-controls'
+import { ActivityThreadListPane } from './activity-thread-list-pane'
+import type { ActivityThreadGroup, AgentPaneThread } from './activity-thread-types'
+import { makeTab, makeWorktree } from './ActivityPrototypePage-test-fixtures'
+
+globalThis.IS_REACT_ACT_ENVIRONMENT = true
+
+const mockThread: AgentPaneThread = {
+ paneKey: 'tab-1:agent-1',
+ tab: makeTab(),
+ worktree: makeWorktree(),
+ repo: null,
+ currentAgentState: null,
+ currentAgentEntry: null,
+ latestEvent: null,
+ latestTimestamp: 1000,
+ agentType: 'claude',
+ unread: false,
+ paneTitle: 'Test agent',
+ responsePreview: 'Done testing',
+ events: []
+}
+
+const mockGroup: ActivityThreadGroup = {
+ key: 'done',
+ label: 'Done',
+ state: 'done',
+ threads: [mockThread]
+}
+
+describe('ActivityStatusGroupHeader', () => {
+ let container: HTMLDivElement
+ let root: Root
+
+ beforeEach(() => {
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+ })
+
+ afterEach(() => {
+ act(() => root.unmount())
+ container.remove()
+ document.body.replaceChildren()
+ })
+
+ it('renders group label, count, and expanded state', () => {
+ const onToggle = vi.fn()
+ act(() => {
+ root.render(
+
+
+
+ )
+ })
+
+ const header = container.querySelector('[role="button"]')
+ expect(header).not.toBeNull()
+ expect(header?.getAttribute('aria-expanded')).toBe('true')
+ expect(header?.textContent).toContain('Done')
+ expect(header?.textContent).toContain('1')
+ })
+
+ it('triggers onToggle on click and on keydown (Enter / Space)', () => {
+ const onToggle = vi.fn()
+ act(() => {
+ root.render(
+
+
+
+ )
+ })
+
+ const header = container.querySelector('[role="button"]') as HTMLElement
+ act(() => {
+ header.dispatchEvent(new MouseEvent('click', { bubbles: true }))
+ })
+ expect(onToggle).toHaveBeenCalledTimes(1)
+
+ act(() => {
+ header.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
+ })
+ expect(onToggle).toHaveBeenCalledTimes(2)
+
+ act(() => {
+ header.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true }))
+ })
+ expect(onToggle).toHaveBeenCalledTimes(3)
+ })
+
+ it('renders collapsed state with aria-expanded false', () => {
+ const onToggle = vi.fn()
+ act(() => {
+ root.render(
+
+
+
+ )
+ })
+
+ const header = container.querySelector('[role="button"]')
+ expect(header?.getAttribute('aria-expanded')).toBe('false')
+ })
+
+ it('supports custom className and uses accessible contrast tokens without hardcoded white background', () => {
+ act(() => {
+ root.render(
+
+
+
+ )
+ })
+
+ const header = container.querySelector('div')
+ expect(header?.className).toContain('custom-header-class')
+ expect(header?.className).not.toContain('bg-background/95')
+ })
+})
+
+describe('ActivityThreadListPane collapsible sections', () => {
+ let container: HTMLDivElement
+ let root: Root
+
+ beforeEach(() => {
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+ })
+
+ afterEach(() => {
+ act(() => root.unmount())
+ container.remove()
+ document.body.replaceChildren()
+ })
+
+ it('toggles thread visibility when header is clicked in uncontrolled mode', () => {
+ const inputRef = { current: null }
+ act(() => {
+ root.render(
+
+ true}
+ showFilterControls={false}
+ showOptionsMenu={false}
+ />
+
+ )
+ })
+
+ // Initially open: thread row should be visible
+ expect(container.textContent).toContain('Test agent')
+
+ // Click header to collapse
+ const header = container.querySelector('[role="button"]') as HTMLElement
+ act(() => {
+ header.dispatchEvent(new MouseEvent('click', { bubbles: true }))
+ })
+
+ // Thread row should now be hidden
+ expect(container.textContent).not.toContain('Test agent')
+ expect(header.getAttribute('aria-expanded')).toBe('false')
+
+ // Click header again to re-expand
+ act(() => {
+ header.dispatchEvent(new MouseEvent('click', { bubbles: true }))
+ })
+
+ expect(container.textContent).toContain('Test agent')
+ expect(header.getAttribute('aria-expanded')).toBe('true')
+ })
+
+ it('respects controlled collapsedGroupKeys and invokes onToggleGroupCollapse', () => {
+ const inputRef = { current: null }
+ const onToggleGroup = vi.fn()
+ act(() => {
+ root.render(
+
+ true}
+ showFilterControls={false}
+ showOptionsMenu={false}
+ collapsedGroupKeys={new Set(['done'])}
+ onToggleGroupCollapse={onToggleGroup}
+ />
+
+ )
+ })
+
+ // Controlled as collapsed: thread row should not be rendered
+ expect(container.textContent).not.toContain('Test agent')
+
+ // Click header
+ const header = container.querySelector('[role="button"]') as HTMLElement
+ act(() => {
+ header.dispatchEvent(new MouseEvent('click', { bubbles: true }))
+ })
+
+ expect(onToggleGroup).toHaveBeenCalledWith('done')
+ })
+
+ it('keeps mark-unread enabled for the thread whose terminal pane is selected', () => {
+ const inputRef = { current: null }
+ const onMarkThreadUnread = vi.fn()
+ act(() => {
+ root.render(
+
+ true}
+ allowMarkUnreadWhenSelected
+ showFilterControls={false}
+ showOptionsMenu={false}
+ />
+
+ )
+ })
+
+ const markUnreadButton = container.querySelector(
+ 'button[aria-label="Mark thread unread"]'
+ ) as HTMLButtonElement | null
+ expect(markUnreadButton).not.toBeNull()
+ expect(markUnreadButton?.disabled).toBe(false)
+
+ act(() => {
+ markUnreadButton?.click()
+ })
+ expect(onMarkThreadUnread).toHaveBeenCalledWith(mockThread)
+ })
+})
diff --git a/src/renderer/src/components/activity/activity-thread-list-pane.tsx b/src/renderer/src/components/activity/activity-thread-list-pane.tsx
index 4411d385e0b..9c2beee6e0c 100644
--- a/src/renderer/src/components/activity/activity-thread-list-pane.tsx
+++ b/src/renderer/src/components/activity/activity-thread-list-pane.tsx
@@ -1,19 +1,29 @@
-import React from 'react'
-import { BellDot, Search } from 'lucide-react'
-import { Input } from '@/components/ui/input'
+import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue
-} from '@/components/ui/select'
-import { Toggle } from '@/components/ui/toggle'
-import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
+ defaultRangeExtractor,
+ measureElement as measureVirtualElementSize,
+ observeElementRect,
+ useVirtualizer,
+ type Range
+} from '@tanstack/react-virtual'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
-import { ActivityStatusGroupHeader, ActivityThreadOptionsMenu } from './activity-thread-controls'
-import { ActivityThreadRow } from './activity-thread-row'
+import { ActivityThreadListToolbar } from './activity-thread-list-toolbar'
+import {
+ getActiveStickyHeaderIndex,
+ getActiveStickyHeaderIndexForScroll,
+ getPreviousStickyHeaderIndex
+} from '../sidebar/worktree-list/viewport/virtual-rows'
+import { ActivityThreadVirtualRow } from './activity-thread-virtual-row'
+import { ActivityThreadListResizeHandle } from './activity-thread-list-resize-handle'
+import {
+ buildActivityVirtualItems,
+ estimateActivityVirtualItemSize,
+ findActivityThreadItemIndex,
+ getActivityHeaderItemIndexes,
+ getActivityVirtualItemKey
+} from './activity-thread-virtual-items'
+import { ActivityThreadCollapseContext } from './activity-thread-collapse-context'
import type {
ActivityGroupBy,
ActivityThreadGroup,
@@ -21,6 +31,16 @@ import type {
ThreadReadFilter
} from './activity-thread-types'
+const ZERO_RECT_FALLBACK_VIEWPORT = { width: 320, height: 600 }
+const observeActivityListRect: typeof observeElementRect = (instance, cb) =>
+ observeElementRect(instance, (rect) => {
+ cb(rect.height > 0 ? rect : ZERO_RECT_FALLBACK_VIEWPORT)
+ })
+
+// A saved offset the content cannot contain yet is restored once it can; past
+// this window it is stale (the list shrank) and restoring would yank the viewport.
+const DEFERRED_SCROLL_RESTORE_WINDOW_MS = 3000
+
export function ActivityThreadListPane({
threadListRef,
threadListWidth,
@@ -32,21 +52,34 @@ export function ActivityThreadListPane({
readFilter,
onReadFilterChange,
compactMode,
+ showChildAgents,
hasUnreadThreads,
onCompactModeChange,
+ onShowChildAgentsChange,
onMarkAllThreadsRead,
+ hasCompletedThreads,
+ onClearCompleted,
visibleThreadGroups,
visibleThreadCount,
selectedPaneKey,
onSelectThread,
onJumpToWorkspace,
+ onMarkThreadRead,
onMarkThreadUnread,
canJumpToWorkspace,
+ allowMarkUnreadWhenSelected = false,
+ showJumpAction = true,
isThreadListResizing,
- onResizeStart
+ onResizeStart,
+ showFilterControls = true,
+ showOptionsMenu = true,
+ scopeFilterRow,
+ collapsedGroupKeys,
+ onToggleGroupCollapse,
+ scrollTopRef
}: {
- threadListRef: React.RefObject
- threadListWidth: number
+ threadListRef?: React.RefObject
+ threadListWidth?: number
activityFilterInputRef: React.RefObject
query: string
onQueryChange: (query: string) => void
@@ -55,163 +88,308 @@ export function ActivityThreadListPane({
readFilter: ThreadReadFilter
onReadFilterChange: (readFilter: ThreadReadFilter) => void
compactMode: boolean
+ showChildAgents?: boolean
hasUnreadThreads: boolean
onCompactModeChange: (compactMode: boolean) => void
- onMarkAllThreadsRead: () => void
+ onShowChildAgentsChange?: (showChildAgents: boolean) => void
+ onMarkAllThreadsRead?: () => void
+ hasCompletedThreads?: boolean
+ onClearCompleted?: () => void
visibleThreadGroups: ActivityThreadGroup[]
visibleThreadCount: number
selectedPaneKey: string | null
onSelectThread: (thread: AgentPaneThread) => void
onJumpToWorkspace: (thread: AgentPaneThread) => void
+ onMarkThreadRead: (thread: AgentPaneThread) => void
onMarkThreadUnread: (thread: AgentPaneThread) => void
canJumpToWorkspace: (thread: AgentPaneThread) => boolean
- isThreadListResizing: boolean
- onResizeStart: React.MouseEventHandler
+ allowMarkUnreadWhenSelected?: boolean
+ showJumpAction?: boolean
+ isThreadListResizing?: boolean
+ onResizeStart?: React.MouseEventHandler
+ showFilterControls?: boolean
+ showOptionsMenu?: boolean
+ /** Rendered between the toolbar and the list; carries the active-scope chips row. */
+ scopeFilterRow?: React.ReactNode
+ collapsedGroupKeys?: ReadonlySet
+ onToggleGroupCollapse?: (groupKey: string) => void
+ /** Optional view-local scroll memory; updated without triggering React renders. */
+ scrollTopRef?: React.MutableRefObject
}): React.JSX.Element {
+ const [internalCollapsedGroupKeys, setInternalCollapsedGroupKeys] = useState>(
+ () => new Set()
+ )
+ // Precedence: explicit props, then a caller-owned context (hosts that unmount
+ // the pane on body switches), then pane-local state.
+ const contextCollapse = useContext(ActivityThreadCollapseContext)
+ const isControlled = collapsedGroupKeys !== undefined && onToggleGroupCollapse !== undefined
+ const effectiveCollapsedGroupKeys = isControlled
+ ? collapsedGroupKeys
+ : (contextCollapse?.collapsedGroupKeys ?? internalCollapsedGroupKeys)
+ const handleToggleGroup = isControlled
+ ? onToggleGroupCollapse
+ : (contextCollapse?.onToggleGroupCollapse ??
+ ((groupKey: string) => {
+ setInternalCollapsedGroupKeys((prev) => {
+ const next = new Set(prev)
+ if (next.has(groupKey)) {
+ next.delete(groupKey)
+ } else {
+ next.add(groupKey)
+ }
+ return next
+ })
+ }))
+
+ const scrollContainerRef = useRef(null)
+ const hasRestoredScrollRef = useRef(false)
+ const handleScroll = useCallback(
+ (event: React.UIEvent) => {
+ if (!scrollTopRef) {
+ return
+ }
+ const scrollTop = event.currentTarget.scrollTop
+ // A clamp-to-0 fired before the deferred restore must not wipe the saved offset.
+ if (!hasRestoredScrollRef.current) {
+ if (scrollTop === 0) {
+ return
+ }
+ hasRestoredScrollRef.current = true
+ }
+ scrollTopRef.current = scrollTop
+ },
+ [scrollTopRef]
+ )
+ const virtualItems = useMemo(
+ () =>
+ buildActivityVirtualItems({
+ groups: visibleThreadGroups,
+ groupBy,
+ collapsedGroupKeys: effectiveCollapsedGroupKeys
+ }),
+ [visibleThreadGroups, groupBy, effectiveCollapsedGroupKeys]
+ )
+ const headerItemIndexes = useMemo(
+ () => getActivityHeaderItemIndexes(virtualItems),
+ [virtualItems]
+ )
+ const selectedItemIndex = useMemo(
+ () => findActivityThreadItemIndex(virtualItems, selectedPaneKey),
+ [virtualItems, selectedPaneKey]
+ )
+
+ // Why keyed on virtualItems: getItemKey identity is a measurement-memo input in tanstack
+ // virtual. A per-render closure recomputes every row on unrelated re-renders; a fully stable
+ // one would miss same-count reorders. Changing exactly with the items is the correct middle.
+ const getItemKey = useCallback(
+ (index: number) => {
+ const item = virtualItems[index]
+ return item ? getActivityVirtualItemKey(item) : `__stale_${index}`
+ },
+ [virtualItems]
+ )
+ const virtualizer = useVirtualizer({
+ count: virtualItems.length,
+ getScrollElement: () => scrollContainerRef.current,
+ estimateSize: (index) => estimateActivityVirtualItemSize(virtualItems[index], compactMode),
+ getItemKey,
+ measureElement: (element, entry, instance) => {
+ const measured = measureVirtualElementSize(element, entry, instance)
+ if (measured > 0) {
+ return measured
+ }
+ const index = Number.parseInt(element.getAttribute('data-index') ?? '', 10)
+ return estimateActivityVirtualItemSize(
+ Number.isNaN(index) ? undefined : virtualItems[index],
+ compactMode
+ )
+ },
+ rangeExtractor: useCallback(
+ (range: Range) => {
+ const activeStickyIndex =
+ groupBy !== 'none'
+ ? getActiveStickyHeaderIndex(headerItemIndexes, range.startIndex)
+ : null
+ const previousStickyIndex =
+ activeStickyIndex !== null
+ ? getPreviousStickyHeaderIndex(headerItemIndexes, activeStickyIndex)
+ : null
+ const indexSet = new Set(defaultRangeExtractor(range))
+ if (activeStickyIndex !== null) {
+ indexSet.add(activeStickyIndex)
+ }
+ if (previousStickyIndex !== null) {
+ indexSet.add(previousStickyIndex)
+ }
+ if (selectedItemIndex !== null && selectedItemIndex >= 0) {
+ indexSet.add(selectedItemIndex)
+ }
+ return Array.from(indexSet).sort((a, b) => a - b)
+ },
+ [groupBy, headerItemIndexes, selectedItemIndex]
+ ),
+ overscan: 8,
+ observeElementRect: observeActivityListRect,
+ useFlushSync: false
+ })
+
+ // Row heights differ between densities; drop stale measurements on toggle (not on mount).
+ const measuredCompactModeRef = useRef(compactMode)
+ useEffect(() => {
+ if (measuredCompactModeRef.current === compactMode) {
+ return
+ }
+ measuredCompactModeRef.current = compactMode
+ virtualizer.measure()
+ }, [virtualizer, compactMode])
+
+ // Restore only once the (estimated) content can contain the saved offset, so a
+ // pre-hydration mount doesn't clamp the restore to 0.
+ const totalSize = virtualizer.getTotalSize()
+ const restoreArmedAtRef = useRef(null)
+ useEffect(() => {
+ if (!scrollTopRef || hasRestoredScrollRef.current) {
+ return
+ }
+ if (restoreArmedAtRef.current === null) {
+ restoreArmedAtRef.current = Date.now()
+ } else if (Date.now() - restoreArmedAtRef.current > DEFERRED_SCROLL_RESTORE_WINDOW_MS) {
+ // The list stayed too small for the saved offset (it shrank); firing the
+ // restore on some later growth would yank the viewport out from the user.
+ hasRestoredScrollRef.current = true
+ scrollTopRef.current = 0
+ return
+ }
+ const scrollContainer = scrollContainerRef.current
+ if (!scrollContainer) {
+ return
+ }
+ // Against the max offset, not the content height: a viewport taller than the
+ // remaining content clamps the assignment to 0 and burns the one restore.
+ const maxScrollTop = Math.max(0, totalSize - scrollContainer.clientHeight)
+ if (scrollTopRef.current > maxScrollTop) {
+ return
+ }
+ scrollContainer.scrollTop = scrollTopRef.current
+ hasRestoredScrollRef.current = true
+ }, [scrollTopRef, totalSize])
+
+ const scrollOffset = virtualizer.scrollOffset ?? 0
+ const activeStickyHeaderIndex =
+ groupBy !== 'none'
+ ? getActiveStickyHeaderIndexForScroll({
+ rangeStartIndex: virtualizer.range?.startIndex ?? 0,
+ scrollOffset,
+ stickyHeaderIndexes: headerItemIndexes,
+ virtualItems: virtualizer.getVirtualItems()
+ })
+ : null
+
+ const resizable = onResizeStart !== undefined
return (
-
-
-
-
- onQueryChange(event.target.value)}
- placeholder={translate(
- 'auto.components.activity.ActivityPrototypePage.795cbf26e2',
- 'Filter...'
- )}
- className="h-8 w-full pl-7 text-xs"
- />
-
-
onGroupByChange(value as ActivityGroupBy)}
+
+ {scopeFilterRow}
+
+
+
-
-
-
-
-
- {translate('auto.components.activity.ActivityPrototypePage.4a3986b200', 'Status')}
-
-
- {translate('auto.components.activity.ActivityPrototypePage.8c3b621ddf', 'Project')}
-
-
- {translate('auto.components.activity.ActivityPrototypePage.b29191b3e0', 'Worktree')}
-
-
- {translate('auto.components.activity.ActivityPrototypePage.f6396e1f85', 'Agent')}
-
-
-
-
-
- onReadFilterChange(pressed ? 'unread' : 'all')}
- variant="outline"
- size="sm"
- className={cn(
- 'size-8 shrink-0 p-0',
- readFilter === 'unread'
- ? '!border-primary !bg-primary !text-primary-foreground shadow-xs ring-2 ring-primary/35 hover:!bg-primary/90 hover:!text-primary-foreground'
- : 'text-muted-foreground hover:text-foreground'
- )}
- aria-label={translate(
- 'auto.components.activity.ActivityPrototypePage.d1a88df9a8',
- 'Show unread threads only'
- )}
- >
-
-
-
-
+ {virtualizer.getVirtualItems().map((virtualRow) => {
+ const item = virtualItems[virtualRow.index]
+ if (!item) {
+ return null
+ }
+ const isActiveSticky =
+ item.type === 'header' && virtualRow.index === activeStickyHeaderIndex
+ return (
+
+ )
+ })}
+
+ {visibleThreadCount === 0 ? (
+
{translate(
- 'auto.components.activity.ActivityPrototypePage.d1a88df9a8',
- 'Show unread threads only'
+ 'auto.components.activity.ActivityPrototypePage.7cd632006b',
+ 'No agent activity matches these filters.'
)}
-
-
- {/* Why (overflow menu): "Mark all read" is low-frequency and destructive-feeling; behind `…` keeps the toolbar on the frequent Filter + unread toggle. */}
-
+
+ ) : null}
-
- {visibleThreadGroups.map((group) => (
-
-
- {group.threads.map((thread) => (
- onSelectThread(thread)}
- onJump={() => onJumpToWorkspace(thread)}
- onMarkUnread={() => onMarkThreadUnread(thread)}
- canJump={canJumpToWorkspace(thread)}
- compactMode={compactMode}
- />
- ))}
-
- ))}
- {visibleThreadCount === 0 ? (
-
- {translate(
- 'auto.components.activity.ActivityPrototypePage.7cd632006b',
- 'No agent activity matches these filters.'
- )}
-
- ) : null}
-
-
+ ) : null}
)
}
diff --git a/src/renderer/src/components/activity/activity-thread-list-pane.virtualization.test.tsx b/src/renderer/src/components/activity/activity-thread-list-pane.virtualization.test.tsx
new file mode 100644
index 00000000000..5b531570444
--- /dev/null
+++ b/src/renderer/src/components/activity/activity-thread-list-pane.virtualization.test.tsx
@@ -0,0 +1,237 @@
+// @vitest-environment happy-dom
+
+import { act, type MutableRefObject } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { TooltipProvider } from '@/components/ui/tooltip'
+import { ActivityThreadListPane } from './activity-thread-list-pane'
+import {
+ ActivityThreadCollapseContext,
+ type ActivityThreadCollapseState
+} from './activity-thread-collapse-context'
+import type { ActivityThreadGroup, AgentPaneThread } from './activity-thread-types'
+import { makeTab, makeWorktree } from './ActivityPrototypePage-test-fixtures'
+
+globalThis.IS_REACT_ACT_ENVIRONMENT = true
+
+const THREAD_COUNT = 300
+
+function makeThread(index: number): AgentPaneThread {
+ return {
+ paneKey: `tab-${index}:leaf-${index}`,
+ tab: makeTab(),
+ worktree: makeWorktree(),
+ repo: null,
+ currentAgentState: null,
+ currentAgentEntry: null,
+ latestEvent: null,
+ latestTimestamp: 1_000_000 - index,
+ agentType: 'claude',
+ unread: false,
+ paneTitle: `Virtual agent ${index}`,
+ responsePreview: '',
+ events: []
+ }
+}
+
+function makeManyThreads(): AgentPaneThread[] {
+ return Array.from({ length: THREAD_COUNT }, (_, index) => makeThread(index))
+}
+
+function makeGroups(threads: AgentPaneThread[]): ActivityThreadGroup[] {
+ return [{ key: 'done', label: 'Done', state: 'done', threads }]
+}
+
+function renderPane(
+ root: Root,
+ args: {
+ threads: AgentPaneThread[]
+ selectedPaneKey?: string | null
+ scrollTopRef?: MutableRefObject
+ collapseState?: ActivityThreadCollapseState
+ }
+): void {
+ act(() => {
+ root.render(
+
+
+ true}
+ showFilterControls={false}
+ showOptionsMenu={false}
+ scrollTopRef={args.scrollTopRef}
+ />
+
+
+ )
+ })
+}
+
+describe('ActivityThreadListPane virtualization', () => {
+ let container: HTMLDivElement
+ let root: Root
+
+ beforeEach(() => {
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+ })
+
+ afterEach(() => {
+ act(() => root.unmount())
+ container.remove()
+ document.body.replaceChildren()
+ })
+
+ function mountedRowCount(): number {
+ return container.querySelectorAll('[data-worktree-card-surface="true"]').length
+ }
+
+ it('mounts a viewport-bounded number of rows, not one per thread', () => {
+ renderPane(root, { threads: makeManyThreads() })
+ const mounted = mountedRowCount()
+ expect(mounted).toBeGreaterThan(0)
+ // Viewport (600px fallback) / ~96px rows + 2x overscan(8); far below THREAD_COUNT.
+ expect(mounted).toBeLessThanOrEqual(40)
+ // Off-screen rows are not in the DOM at all.
+ expect(container.textContent).not.toContain('Virtual agent 250')
+ })
+
+ it('keeps the selected off-screen row mounted so activation stays accessible', () => {
+ renderPane(root, {
+ threads: makeManyThreads(),
+ selectedPaneKey: 'tab-250:leaf-250'
+ })
+ const selected = container.querySelector('[data-worktree-card-active="primary"]')
+ expect(selected).not.toBeNull()
+ expect(selected?.textContent).toContain('Virtual agent 250')
+ // Still virtualized: pinning the selection must not mount the rest of the list.
+ expect(mountedRowCount()).toBeLessThanOrEqual(41)
+ })
+
+ it('does not mount an off-screen row when its thread data updates', () => {
+ const threads = makeManyThreads()
+ renderPane(root, { threads })
+ const before = mountedRowCount()
+
+ const updated = [...threads]
+ updated[250] = { ...threads[250], paneTitle: 'Virtual agent 250 UPDATED', unread: true }
+ renderPane(root, { threads: updated })
+
+ expect(container.textContent).not.toContain('Virtual agent 250 UPDATED')
+ expect(mountedRowCount()).toBe(before)
+ })
+
+ it('renders every row for a short list', () => {
+ renderPane(root, { threads: [makeThread(0), makeThread(1), makeThread(2)] })
+ expect(mountedRowCount()).toBe(3)
+ expect(container.textContent).toContain('Virtual agent 0')
+ expect(container.textContent).toContain('Virtual agent 2')
+ })
+
+ it('keeps the saved scroll offset when the pane mounts before threads hydrate', () => {
+ const scrollTopRef = { current: 360 }
+ renderPane(root, { threads: [], scrollTopRef })
+ const scrollContainer = container.querySelector('.overflow-y-auto')
+ // Empty list cannot contain the offset: restore is deferred, not clamped to 0.
+ act(() => {
+ if (scrollContainer) {
+ scrollContainer.scrollTop = 0
+ scrollContainer.dispatchEvent(new Event('scroll', { bubbles: true }))
+ }
+ })
+ expect(scrollTopRef.current).toBe(360)
+
+ renderPane(root, { threads: makeManyThreads(), scrollTopRef })
+ expect(container.querySelector('.overflow-y-auto')?.scrollTop).toBe(360)
+ })
+
+ it('restores caller-held collapse state across remounts', () => {
+ // Models the sidebar: the caller owns the Set and provides it via context.
+ let collapsed: ReadonlySet = new Set()
+ const collapseState = (): ActivityThreadCollapseState => ({
+ collapsedGroupKeys: collapsed,
+ onToggleGroupCollapse: (groupKey: string) => {
+ const next = new Set(collapsed)
+ if (next.has(groupKey)) {
+ next.delete(groupKey)
+ } else {
+ next.add(groupKey)
+ }
+ collapsed = next
+ }
+ })
+ renderPane(root, { threads: makeManyThreads(), collapseState: collapseState() })
+ const header = container.querySelector('[role="button"]') as HTMLElement
+ act(() => {
+ header.dispatchEvent(new MouseEvent('click', { bubbles: true }))
+ })
+ renderPane(root, { threads: makeManyThreads(), collapseState: collapseState() })
+ expect(container.querySelector('[role="button"]')?.getAttribute('aria-expanded')).toBe('false')
+
+ act(() => root.unmount())
+ root = createRoot(container)
+ renderPane(root, { threads: makeManyThreads(), collapseState: collapseState() })
+ const remountedHeader = container.querySelector('[role="button"]')
+ expect(remountedHeader?.getAttribute('aria-expanded')).toBe('false')
+ })
+
+ it('disarms a stale deferred restore instead of yanking the viewport on late growth', () => {
+ vi.useFakeTimers()
+ vi.setSystemTime(1_000_000)
+ try {
+ const scrollTopRef = { current: 360 }
+ // Mounts with a list too small to contain the saved offset (it shrank).
+ renderPane(root, { threads: [makeThread(0)], scrollTopRef })
+ const scrollContainer = container.querySelector('.overflow-y-auto')
+ expect(scrollContainer?.scrollTop).toBe(0)
+
+ // Well past the restore window, the list grows beyond the saved offset.
+ vi.setSystemTime(1_010_000)
+ renderPane(root, { threads: makeManyThreads(), scrollTopRef })
+
+ expect(container.querySelector('.overflow-y-auto')?.scrollTop).toBe(0)
+ expect(scrollTopRef.current).toBe(0)
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ it('restores the Agents scroll position without storing it in React state', () => {
+ const scrollTopRef = { current: 240 }
+ renderPane(root, { threads: makeManyThreads(), scrollTopRef })
+ const scrollContainer = container.querySelector('.overflow-y-auto')
+ expect(scrollContainer?.scrollTop).toBe(240)
+
+ act(() => {
+ if (scrollContainer) {
+ scrollContainer.scrollTop = 360
+ scrollContainer.dispatchEvent(new Event('scroll', { bubbles: true }))
+ }
+ })
+ expect(scrollTopRef.current).toBe(360)
+
+ act(() => root.unmount())
+ root = createRoot(container)
+ renderPane(root, { threads: makeManyThreads(), scrollTopRef })
+ expect(container.querySelector('.overflow-y-auto')?.scrollTop).toBe(360)
+ })
+})
diff --git a/src/renderer/src/components/activity/activity-thread-list-resize-handle.tsx b/src/renderer/src/components/activity/activity-thread-list-resize-handle.tsx
new file mode 100644
index 00000000000..a97a7ce223b
--- /dev/null
+++ b/src/renderer/src/components/activity/activity-thread-list-resize-handle.tsx
@@ -0,0 +1,37 @@
+import type React from 'react'
+import { cn } from '@/lib/utils'
+import { translate } from '@/i18n/i18n'
+
+export function ActivityThreadListResizeHandle({
+ isResizing,
+ onResizeStart
+}: {
+ isResizing?: boolean
+ onResizeStart?: React.MouseEventHandler
+}): React.JSX.Element {
+ return (
+
+ )
+}
diff --git a/src/renderer/src/components/activity/activity-thread-list-toolbar.tsx b/src/renderer/src/components/activity/activity-thread-list-toolbar.tsx
new file mode 100644
index 00000000000..f1ad0fb9057
--- /dev/null
+++ b/src/renderer/src/components/activity/activity-thread-list-toolbar.tsx
@@ -0,0 +1,227 @@
+import React from 'react'
+import { BellDot, CheckCheck, Search, Trash2, X } from 'lucide-react'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue
+} from '@/components/ui/select'
+import { Toggle } from '@/components/ui/toggle'
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
+import { cn } from '@/lib/utils'
+import { translate } from '@/i18n/i18n'
+import { ActivityThreadOptionsMenu } from './activity-thread-controls'
+import type { ActivityGroupBy, ThreadReadFilter } from './activity-thread-types'
+
+export function ActivityThreadListToolbar({
+ activityFilterInputRef,
+ query,
+ onQueryChange,
+ groupBy,
+ onGroupByChange,
+ readFilter,
+ onReadFilterChange,
+ compactMode,
+ showChildAgents,
+ hasUnreadThreads,
+ onCompactModeChange,
+ onShowChildAgentsChange,
+ onMarkAllThreadsRead,
+ hasCompletedThreads,
+ onClearCompleted,
+ resizable,
+ showFilterControls,
+ showOptionsMenu
+}: {
+ activityFilterInputRef: React.RefObject
+ query: string
+ onQueryChange: (query: string) => void
+ groupBy: ActivityGroupBy
+ onGroupByChange: (groupBy: ActivityGroupBy) => void
+ readFilter: ThreadReadFilter
+ onReadFilterChange: (readFilter: ThreadReadFilter) => void
+ compactMode: boolean
+ showChildAgents?: boolean
+ hasUnreadThreads: boolean
+ onCompactModeChange: (compactMode: boolean) => void
+ onShowChildAgentsChange?: (showChildAgents: boolean) => void
+ onMarkAllThreadsRead?: () => void
+ hasCompletedThreads?: boolean
+ onClearCompleted?: () => void
+ resizable: boolean
+ showFilterControls: boolean
+ showOptionsMenu: boolean
+}): React.JSX.Element | null {
+ const showToolbar = showFilterControls || showOptionsMenu
+ if (!showToolbar) {
+ return null
+ }
+
+ return (
+ <>
+
+
+ {showFilterControls ? (
+
+
+ onQueryChange(event.target.value)}
+ placeholder={translate(
+ 'auto.components.activity.ActivityPrototypePage.795cbf26e2',
+ 'Filter...'
+ )}
+ className={cn('h-7 w-full pl-6 text-[11px]', query ? 'pr-6' : '')}
+ />
+ {query ? (
+ {
+ onQueryChange('')
+ activityFilterInputRef.current?.focus()
+ }}
+ >
+
+
+ ) : null}
+
+ ) : null}
+ {resizable ? (
+
onGroupByChange(value as ActivityGroupBy)}
+ >
+
+
+
+
+
+ {translate('auto.components.activity.ActivityPrototypePage.none', 'None')}
+
+
+ {translate('auto.components.activity.ActivityPrototypePage.4a3986b200', 'Status')}
+
+
+ {translate(
+ 'auto.components.activity.ActivityPrototypePage.8c3b621ddf',
+ 'Project'
+ )}
+
+
+ {translate(
+ 'auto.components.activity.ActivityPrototypePage.b29191b3e0',
+ 'Worktree'
+ )}
+
+
+ {translate('auto.components.activity.ActivityPrototypePage.f6396e1f85', 'Agent')}
+
+
+
+ ) : null}
+ {showFilterControls ? (
+
+
+ onReadFilterChange(pressed ? 'unread' : 'all')}
+ size="sm"
+ className={cn(
+ 'size-7 shrink-0 p-0 rounded-md transition-all',
+ readFilter === 'unread'
+ ? '!border border-primary/50 !bg-primary/20 !text-primary shadow-xs hover:!bg-primary/30'
+ : 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
+ )}
+ aria-label={translate(
+ 'auto.components.activity.ActivityPrototypePage.d1a88df9a8',
+ 'Show unread threads only'
+ )}
+ >
+
+
+
+
+ {translate(
+ 'auto.components.activity.ActivityPrototypePage.d1a88df9a8',
+ 'Show unread threads only'
+ )}
+
+
+ ) : null}
+ {showOptionsMenu ? (
+
+ ) : null}
+
+
+ {onMarkAllThreadsRead || onClearCompleted ? (
+
+ {onMarkAllThreadsRead ? (
+
+
+
+ {translate(
+ 'auto.components.activity.ActivityPrototypePage.023ff75afe',
+ 'Mark all read'
+ )}
+
+
+ ) : null}
+ {onClearCompleted ? (
+
+
+
+ {translate(
+ 'auto.components.activity.ActivityPrototypePage.clearCompleted',
+ 'Clear completed'
+ )}
+
+
+ ) : null}
+
+ ) : null}
+ >
+ )
+}
diff --git a/src/renderer/src/components/activity/activity-thread-options-menu.tsx b/src/renderer/src/components/activity/activity-thread-options-menu.tsx
new file mode 100644
index 00000000000..a7d50a108ac
--- /dev/null
+++ b/src/renderer/src/components/activity/activity-thread-options-menu.tsx
@@ -0,0 +1,291 @@
+import React from 'react'
+import {
+ BellDot,
+ Check,
+ CheckCheck,
+ GitFork,
+ Layers,
+ MoreVertical,
+ Rows3,
+ Search,
+ Trash2
+} from 'lucide-react'
+import { Button } from '@/components/ui/button'
+import {
+ DropdownMenu,
+ DropdownMenuCheckboxItem,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuTrigger
+} from '@/components/ui/dropdown-menu'
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
+import { translate } from '@/i18n/i18n'
+import { ActivityScopeFilterMenuSections } from './activity-scope-filter-controls'
+import type { ActivityGroupBy } from './activity-thread-types'
+
+const ALIGNED_CHECKBOX_ITEM_CLASS = 'pl-2 [&>span.absolute]:hidden'
+
+function getActivityGroupByLabel(groupBy: ActivityGroupBy): string {
+ switch (groupBy) {
+ case 'none':
+ return translate('auto.components.activity.ActivityPrototypePage.none', 'None')
+ case 'status':
+ return translate('auto.components.activity.ActivityPrototypePage.4a3986b200', 'Status')
+ case 'project':
+ return translate('auto.components.activity.ActivityPrototypePage.8c3b621ddf', 'Project')
+ case 'worktree':
+ return translate('auto.components.activity.ActivityPrototypePage.b29191b3e0', 'Worktree')
+ case 'agent':
+ return translate('auto.components.activity.ActivityPrototypePage.f6396e1f85', 'Agent')
+ }
+}
+
+export function ActivityThreadOptionsMenu({
+ groupBy,
+ onGroupByChange,
+ compactMode,
+ showChildAgents = false,
+ hasUnreadThreads,
+ hasCompletedThreads = false,
+ onCompactModeChange,
+ onShowChildAgentsChange,
+ onMarkAllThreadsRead,
+ onClearCompleted,
+ onSearch,
+ unreadOnly = false,
+ onToggleUnread
+}: {
+ groupBy?: ActivityGroupBy
+ onGroupByChange?: (groupBy: ActivityGroupBy) => void
+ compactMode: boolean
+ showChildAgents?: boolean
+ hasUnreadThreads: boolean
+ hasCompletedThreads?: boolean
+ onCompactModeChange: (compactMode: boolean) => void
+ onShowChildAgentsChange?: (showChildAgents: boolean) => void
+ onMarkAllThreadsRead?: () => void
+ onClearCompleted?: () => void
+ onSearch?: () => void
+ unreadOnly?: boolean
+ onToggleUnread?: () => void
+}): React.JSX.Element {
+ const skipCloseAutoFocusRef = React.useRef(false)
+
+ return (
+
+
+
+ {/* Why: keep Tooltip and Dropdown from composing refs onto the same button (Radix setRef crash loop). */}
+
+
+
+
+
+
+
+
+
+ {translate('auto.components.activity.ActivityPrototypePage.a472a14700', 'More options')}
+
+
+ {
+ if (skipCloseAutoFocusRef.current) {
+ event.preventDefault()
+ skipCloseAutoFocusRef.current = false
+ }
+ }}
+ >
+ {onSearch || onToggleUnread ? (
+ <>
+ {onSearch ? (
+ {
+ skipCloseAutoFocusRef.current = true
+ onSearch()
+ }}
+ >
+
+
+ {translate('auto.components.activity.ActivityPrototypePage.search', 'Search')}
+
+
+ ) : null}
+ {onToggleUnread ? (
+
+
+ onToggleUnread()}
+ onSelect={(event) => event.preventDefault()}
+ >
+
+
+ {translate(
+ 'auto.components.activity.ActivityPrototypePage.showUnreadOnly',
+ 'Show unread only'
+ )}
+
+ {hasUnreadThreads ? (
+
+ ) : null}
+ {unreadOnly ? : null}
+
+
+
+ {translate(
+ 'auto.components.activity.ActivityPrototypePage.unreadOnlyDescription',
+ 'Filters the activity list to show only threads with unread updates.'
+ )}
+
+
+ ) : null}
+
+ >
+ ) : null}
+
+ {groupBy && onGroupByChange ? (
+
+
+
+
+
+ {translate(
+ 'auto.components.activity.ActivityPrototypePage.770d458144',
+ 'Group by'
+ )}
+
+
+ {getActivityGroupByLabel(groupBy)}
+
+
+
+
+ onGroupByChange(value as ActivityGroupBy)}
+ >
+ {[
+ ['none', 'None', 'auto.components.activity.ActivityPrototypePage.none'],
+ ['status', 'Status', 'auto.components.activity.ActivityPrototypePage.4a3986b200'],
+ [
+ 'project',
+ 'Project',
+ 'auto.components.activity.ActivityPrototypePage.8c3b621ddf'
+ ],
+ [
+ 'worktree',
+ 'Worktree',
+ 'auto.components.activity.ActivityPrototypePage.b29191b3e0'
+ ],
+ ['agent', 'Agent', 'auto.components.activity.ActivityPrototypePage.f6396e1f85']
+ ].map(([value, label, key]) => (
+ event.preventDefault()}
+ >
+ {translate(key, label)}
+
+ ))}
+
+
+
+ ) : null}
+
+
+ onCompactModeChange(checked === true)}
+ onSelect={(event) => event.preventDefault()}
+ >
+
+
+ {translate(
+ 'auto.components.activity.ActivityPrototypePage.f70e4bec47',
+ 'Compact mode'
+ )}
+
+ {compactMode ? : null}
+
+
+
+ {translate(
+ 'auto.components.activity.ActivityPrototypePage.compactModeDescription',
+ 'Shows shorter thread rows with one-line titles and two-line status messages.'
+ )}
+
+
+ {onShowChildAgentsChange ? (
+ onShowChildAgentsChange(checked === true)}
+ onSelect={(event) => event.preventDefault()}
+ >
+
+
+ {translate(
+ 'auto.components.activity.ActivityPrototypePage.showChildAgents',
+ 'Show child agents'
+ )}
+
+ {showChildAgents ? : null}
+
+ ) : null}
+ {onMarkAllThreadsRead || onClearCompleted ? (
+ <>
+
+ {onMarkAllThreadsRead ? (
+
+
+
+ {translate(
+ 'auto.components.activity.ActivityPrototypePage.023ff75afe',
+ 'Mark all read'
+ )}
+
+
+ ) : null}
+ {onClearCompleted ? (
+
+
+
+ {translate(
+ 'auto.components.activity.ActivityPrototypePage.clearCompleted',
+ 'Clear completed'
+ )}
+
+
+ ) : null}
+ >
+ ) : null}
+
+
+ )
+}
diff --git a/src/renderer/src/components/activity/activity-thread-presentation.test.ts b/src/renderer/src/components/activity/activity-thread-presentation.test.ts
new file mode 100644
index 00000000000..6aa3efc1a32
--- /dev/null
+++ b/src/renderer/src/components/activity/activity-thread-presentation.test.ts
@@ -0,0 +1,97 @@
+import { describe, expect, it } from 'vitest'
+import type { AgentPaneThread } from './activity-thread-types'
+import { activityThreadRowCopy } from './activity-thread-presentation'
+import { formatShortTimeAgo } from '@/lib/short-time-ago'
+import {
+ makeRepo,
+ makeTabWithIds,
+ makeWorktree,
+ PANE_KEY
+} from './ActivityPrototypePage-test-fixtures'
+
+function makeThread(overrides: Partial = {}): AgentPaneThread {
+ const worktree = makeWorktree()
+ return {
+ paneKey: PANE_KEY,
+ paneTitle: 'low hanging issues',
+ agentType: 'codex',
+ worktree,
+ repo: makeRepo(),
+ tab: makeTabWithIds('tab-1', worktree.id),
+ events: [],
+ latestEvent: null,
+ latestTimestamp: 1_000,
+ currentAgentState: null,
+ currentAgentEntry: null,
+ unread: false,
+ responsePreview: '',
+ ...overrides
+ }
+}
+
+describe('formatShortTimeAgo', () => {
+ it('uses short units', () => {
+ const now = 1_000_000
+ expect(formatShortTimeAgo(now - 10_000, now)).toBe('now')
+ expect(formatShortTimeAgo(now - 5 * 60_000, now)).toBe('5m')
+ expect(formatShortTimeAgo(now - 20 * 60 * 60_000, now)).toBe('20h')
+ expect(formatShortTimeAgo(now - 2 * 24 * 60 * 60_000, now)).toBe('2d')
+ })
+})
+
+describe('activityThreadRowCopy', () => {
+ it('leads with the task and the last activity, not project or workspace', () => {
+ const copy = activityThreadRowCopy(
+ makeThread({
+ responsePreview: 'Filed 8 issues from the audit.'
+ })
+ )
+ expect(copy.taskTitle).toBe('low hanging issues')
+ expect(copy.statusLine).toBe('Filed 8 issues from the audit.')
+ expect(copy.statusKind).toBe('message')
+ expect(copy.needsAttention).toBe(false)
+ expect(copy.workspaceLabel).toBe('feature')
+ })
+
+ it('names the live tool while working', () => {
+ const copy = activityThreadRowCopy(
+ makeThread({
+ paneTitle: 'Fix checkout race',
+ currentAgentState: 'working',
+ responsePreview: 'Edit src/checkout/session.ts'
+ })
+ )
+ expect(copy.statusKind).toBe('tool')
+ expect(copy.statusLine).toBe('Edit src/checkout/session.ts')
+ })
+
+ it('falls back to a state label when a live agent has no preview', () => {
+ const copy = activityThreadRowCopy(
+ makeThread({
+ paneTitle: 'Review PR 1842',
+ currentAgentState: 'waiting',
+ responsePreview: ''
+ })
+ )
+ expect(copy.statusKind).toBe('state')
+ expect(copy.statusLine).toBe('Waiting for input')
+ expect(copy.needsAttention).toBe(true)
+ })
+
+ it('does not invent a status line for a finished agent with no reply', () => {
+ const copy = activityThreadRowCopy(makeThread({ currentAgentState: null, responsePreview: '' }))
+ expect(copy.statusKind).toBe('none')
+ expect(copy.statusLine).toBe('')
+ })
+
+ it('does not repeat the task title as the last-message line', () => {
+ const copy = activityThreadRowCopy(
+ makeThread({
+ paneTitle: 'low hanging issues',
+ responsePreview: 'low hanging issues'
+ })
+ )
+ expect(copy.statusKind).toBe('none')
+ expect(copy.statusLine).toBe('')
+ })
+})
diff --git a/src/renderer/src/components/activity/activity-thread-presentation.ts b/src/renderer/src/components/activity/activity-thread-presentation.ts
index 167d2f30954..93d69c7688a 100644
--- a/src/renderer/src/components/activity/activity-thread-presentation.ts
+++ b/src/renderer/src/components/activity/activity-thread-presentation.ts
@@ -1,8 +1,10 @@
import { agentStateLabel, type AgentDotState } from '@/components/AgentStateDot'
import { formatAgentTypeLabel } from '@/lib/agent-status'
import { getAgentRowPrimaryText } from '@/lib/agent-row-primary-text'
+import { showsAgentToolPreview } from '@/lib/agent-row-tool-preview'
import {
getActivityThreadTaskTitle,
+ getActivityThreadWorkspaceTitle,
resolveActivityThreadStatusPreview
} from '@/lib/activity-thread-display'
import { formatUiRelativeTime } from '@/i18n/relative-time-format'
@@ -24,8 +26,8 @@ export function formatAbsoluteDate(timestamp: number): string {
return absoluteDateFormatter.format(new Date(timestamp))
}
-export function formatRelativeTime(timestamp: number): string {
- return formatUiRelativeTime(timestamp - Date.now())
+export function formatRelativeTime(timestamp: number, now = Date.now()): string {
+ return formatUiRelativeTime(timestamp - now)
}
function truncatePreservingSurrogates(value: string, maxLength: number): string {
@@ -111,3 +113,57 @@ export function threadAgentStateLabel(thread: AgentPaneThread): string {
}
return agentStateLabel(state)
}
+
+export type ActivityThreadStatusKind = 'tool' | 'message' | 'state' | 'none'
+
+export type ActivityThreadRowCopy = {
+ taskTitle: string
+ statusLine: string
+ statusKind: ActivityThreadStatusKind
+ needsAttention: boolean
+ workspaceLabel: string
+}
+
+function normalizeScanLabel(value: string): string {
+ return value.trim().toLowerCase().replace(/[-_]+/g, ' ').replace(/\s+/g, ' ')
+}
+
+function previewDuplicatesIdentity(preview: string, title: string, workspace: string): boolean {
+ const normalized = normalizeScanLabel(preview)
+ if (!normalized) {
+ return true
+ }
+ return normalized === normalizeScanLabel(title) || normalized === normalizeScanLabel(workspace)
+}
+
+export function activityThreadRowCopy(thread: AgentPaneThread): ActivityThreadRowCopy {
+ const workspaceLabel = getActivityThreadWorkspaceTitle(thread.worktree)
+ const taskTitle = thread.paneTitle.trim() || workspaceLabel
+ const renderedPreview = activityThreadResponseRenderPreview({
+ responsePreview: thread.responsePreview
+ })
+ const liveState = thread.currentAgentState ?? thread.latestEvent?.state ?? null
+ const toolPreviewState = liveState === 'monitoring' ? null : liveState
+ const state = threadAgentState(thread)
+ const needsAttention = state === 'waiting' || state === 'blocked' || state === 'permission'
+ if (renderedPreview && !previewDuplicatesIdentity(renderedPreview, taskTitle, workspaceLabel)) {
+ return {
+ taskTitle,
+ statusLine: renderedPreview,
+ // Monitoring is a distinct live state, not a tool-running row state.
+ statusKind: showsAgentToolPreview(toolPreviewState) ? 'tool' : 'message',
+ needsAttention,
+ workspaceLabel
+ }
+ }
+ if (state !== 'done' && state !== 'idle') {
+ return {
+ taskTitle,
+ statusLine: threadAgentStateLabel(thread),
+ statusKind: 'state',
+ needsAttention,
+ workspaceLabel
+ }
+ }
+ return { taskTitle, statusLine: '', statusKind: 'none', needsAttention, workspaceLabel }
+}
diff --git a/src/renderer/src/components/activity/activity-thread-row.tsx b/src/renderer/src/components/activity/activity-thread-row.tsx
index 77750f6c390..93d4d5ec18f 100644
--- a/src/renderer/src/components/activity/activity-thread-row.tsx
+++ b/src/renderer/src/components/activity/activity-thread-row.tsx
@@ -2,207 +2,213 @@ import React from 'react'
import { Bell, ExternalLink } from 'lucide-react'
import { AgentIcon } from '@/lib/agent-catalog'
import { agentTypeToIconAgent, formatAgentTypeLabel } from '@/lib/agent-status'
-import { getActivityThreadWorkspaceTitle } from '@/lib/activity-thread-display'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import { FilledBellIcon } from '../sidebar/WorktreeCardHelpers'
import CommentMarkdown from '../sidebar/CommentMarkdown'
-import {
- ActivityProjectLabel,
- EventTime,
- ThreadAgentStateIndicator
-} from './activity-thread-controls'
-import { activityThreadResponseRenderPreview } from './activity-thread-presentation'
+import { EventTime, ThreadAgentStateIndicator } from './activity-thread-controls'
+import { ActivityThreadHoverCard } from './activity-thread-hover-card'
+import { activityThreadRowCopy } from './activity-thread-presentation'
import type { AgentPaneThread } from './activity-thread-types'
-function isEventFromNestedInteractiveElement(
- target: EventTarget | null,
- currentTarget: HTMLElement
-): boolean {
- if (!(target instanceof HTMLElement)) {
- return false
- }
- const interactiveTarget = target.closest(
- 'a, button, input, select, textarea, [role="button"], [role="link"], [tabindex]:not([tabindex="-1"])'
- )
- return (
- interactiveTarget instanceof HTMLElement &&
- interactiveTarget !== currentTarget &&
- currentTarget.contains(interactiveTarget)
- )
-}
-
-export function ActivityThreadRow({
+// Why React.memo: rows are pure functions of these props; thread identity is stable across
+// query/selection/group re-renders, so memo keeps a keystroke or selection change from
+// re-rendering every mounted row. Callbacks take the thread so parents can pass stable handlers.
+export const ActivityThreadRow = React.memo(function ActivityThreadRow({
thread,
selected,
onSelect,
onJump,
+ onMarkRead,
onMarkUnread,
canJump,
- compactMode
+ compactMode,
+ disableMarkUnread = false,
+ showJumpAction = true
}: {
thread: AgentPaneThread
selected: boolean
- onSelect: () => void
- onJump: () => void
- onMarkUnread: () => void
+ onSelect: (thread: AgentPaneThread) => void
+ onJump: (thread: AgentPaneThread) => void
+ onMarkRead: (thread: AgentPaneThread) => void
+ onMarkUnread: (thread: AgentPaneThread) => void
canJump: boolean
compactMode: boolean
+ disableMarkUnread?: boolean
+ showJumpAction?: boolean
}): React.JSX.Element {
- const renderedResponsePreview = activityThreadResponseRenderPreview({
- responsePreview: thread.responsePreview
- })
- const workspaceTitle = getActivityThreadWorkspaceTitle(thread.worktree)
- const taskTitle = thread.paneTitle
+ const { taskTitle, statusLine, statusKind, needsAttention, workspaceLabel } =
+ activityThreadRowCopy(thread)
+ const showMarkdownStatus = statusKind === 'message'
const agentLabel = formatAgentTypeLabel(thread.agentType)
- const showStatusPreview =
- !compactMode &&
- renderedResponsePreview.length > 0 &&
- renderedResponsePreview !== taskTitle &&
- renderedResponsePreview !== workspaceTitle
+
return (
- {
- // Why: markdown responses can contain links; keyboard activation on a nested link follows the link instead of selecting the row.
- if (isEventFromNestedInteractiveElement(event.target, event.currentTarget)) {
- return
- }
- if (event.key === 'Enter' || event.key === ' ') {
- event.preventDefault()
- onSelect()
- }
- }}
- className={cn(
- // Why (WorktreeCard cues): selected = tint+shadow, beats hover; unread = weight + left bar only; stacking all three confused selected vs unread on hover.
- // Why (asymmetric padding): title leading-snug adds ~3px above cap-height; smaller top pad evens the row.
- 'group relative flex w-full cursor-pointer flex-col gap-1 border-b border-border px-3 pt-2.5 pb-3 text-left transition-colors',
- selected
- ? 'bg-black/[0.08] shadow-[0_1px_2px_rgba(0,0,0,0.04)] dark:bg-white/[0.10] dark:shadow-[0_1px_2px_rgba(0,0,0,0.03)]'
- : 'hover:bg-accent/40'
- )}
+
- {thread.unread ? (
-
- ) : null}
-
-
-
-
-
+ onSelect(thread)}
+ role="listitem"
+ aria-label={taskTitle}
+ aria-current={selected ? 'true' : undefined}
+ className={cn(
+ 'group relative flex w-full cursor-pointer flex-col gap-1.5 rounded-lg border border-transparent px-1.5 py-2.5 text-left transition-[background-color,border-color,opacity,box-shadow] duration-200 outline-none select-none worktree-sidebar-card-hover focus-visible:ring-1 focus-visible:ring-ring',
+ selected && 'border-transparent'
+ )}
+ >
+
+
+
-
-
-