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 ( @@ -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 (
    -
    - {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 ? ( + + ) : null} +
    + ) : null} + {resizable ? ( + + ) : 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 ? ( + + ) : null} + {onClearCompleted ? ( + + ) : 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 ? ( + + + + {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' + )} + > +
    + + - -
    -
    -
    - -
    - {workspaceTitle} -
    - {taskTitle !== workspaceTitle ? ( -
    - {taskTitle} -
    - ) : null} - {showStatusPreview ? ( +
    + {/* Keep the activation target separate from markdown links and row actions. */} + + + {statusLine ? ( + showMarkdownStatus ? ( - ) : null} -
    - {agentLabel} - {canJump ? ( - - - - - - - {translate( + ) : ( +
    + {statusLine} +
    + ) + ) : null} + +
    + + + + + {workspaceLabel} + + {canJump && showJumpAction ? ( + + + +
    -
    - - + onClick={(event) => { + event.stopPropagation() + onJump(thread) + }} + onMouseDown={(event) => event.stopPropagation()} + > + + + + + {translate( + 'auto.components.activity.ActivityPrototypePage.4616ea39fd', + 'Jump to workspace' + )} + + + + ) : null} + {thread.unread ? ( - - ) : ( + + + {translate( + 'auto.components.activity.ActivityPrototypePage.markThreadRead', + 'Mark thread as read' + )} + + + ) : ( + + + @@ -214,11 +220,11 @@ export function ActivityThreadRow({ )} - - + +
    -
    + ) -} +}) diff --git a/src/renderer/src/components/activity/activity-thread-types.ts b/src/renderer/src/components/activity/activity-thread-types.ts index 7609d416581..7225a71b935 100644 --- a/src/renderer/src/components/activity/activity-thread-types.ts +++ b/src/renderer/src/components/activity/activity-thread-types.ts @@ -10,7 +10,7 @@ import type { Worktree } from '../../../../shared/worktree/types' import type { ActivityPortalReadinessStatus } from './activity-portal-readiness-oscillation' export type ThreadReadFilter = 'all' | 'unread' -export type ActivityGroupBy = 'status' | 'project' | 'worktree' | 'agent' +export type ActivityGroupBy = 'none' | 'status' | 'project' | 'worktree' | 'agent' export type ActivityEventState = Extract export type ActivityHookLiveAgentState = Extract< AgentStatusState, diff --git a/src/renderer/src/components/activity/activity-thread-virtual-items.test.ts b/src/renderer/src/components/activity/activity-thread-virtual-items.test.ts new file mode 100644 index 00000000000..041841bcad4 --- /dev/null +++ b/src/renderer/src/components/activity/activity-thread-virtual-items.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest' +import { + buildActivityVirtualItems, + findActivityThreadItemIndex, + getActivityHeaderItemIndexes, + getActivityVirtualItemKey +} from './activity-thread-virtual-items' +import type { ActivityThreadGroup, AgentPaneThread } from './activity-thread-types' +import { makeTab, makeWorktree } from './ActivityPrototypePage-test-fixtures' + +function makeThread(paneKey: string): AgentPaneThread { + return { + paneKey, + tab: makeTab(), + worktree: makeWorktree(), + repo: null, + currentAgentState: null, + currentAgentEntry: null, + latestEvent: null, + latestTimestamp: 1000, + agentType: 'claude', + unread: false, + paneTitle: `Agent ${paneKey}`, + responsePreview: '', + events: [] + } +} + +function makeGroup(key: string, threadKeys: string[]): ActivityThreadGroup { + return { key, label: key, threads: threadKeys.map(makeThread) } +} + +describe('buildActivityVirtualItems', () => { + it('flattens headers and threads in group order', () => { + const items = buildActivityVirtualItems({ + groups: [makeGroup('working', ['a', 'b']), makeGroup('done', ['c'])], + groupBy: 'status', + collapsedGroupKeys: new Set() + }) + expect(items.map((item) => getActivityVirtualItemKey(item))).toEqual([ + 'h:working', + 't:a', + 't:b', + 'h:done', + 't:c' + ]) + expect(getActivityHeaderItemIndexes(items)).toEqual([0, 3]) + }) + + it('omits header rows entirely when ungrouped', () => { + const items = buildActivityVirtualItems({ + groups: [{ key: 'all', label: '', threads: [makeThread('a'), makeThread('b')] }], + groupBy: 'none', + collapsedGroupKeys: new Set() + }) + expect(items.map((item) => getActivityVirtualItemKey(item))).toEqual(['t:a', 't:b']) + }) + + it('keeps a collapsed group header but drops its thread rows', () => { + const items = buildActivityVirtualItems({ + groups: [makeGroup('working', ['a', 'b']), makeGroup('done', ['c'])], + groupBy: 'status', + collapsedGroupKeys: new Set(['working']) + }) + expect(items.map((item) => getActivityVirtualItemKey(item))).toEqual([ + 'h:working', + 'h:done', + 't:c' + ]) + }) + + it('locates the selected thread row by paneKey', () => { + const items = buildActivityVirtualItems({ + groups: [makeGroup('working', ['a', 'b', 'c'])], + groupBy: 'status', + collapsedGroupKeys: new Set() + }) + expect(findActivityThreadItemIndex(items, 'c')).toBe(3) + expect(findActivityThreadItemIndex(items, 'missing')).toBeNull() + expect(findActivityThreadItemIndex(items, null)).toBeNull() + }) +}) diff --git a/src/renderer/src/components/activity/activity-thread-virtual-items.ts b/src/renderer/src/components/activity/activity-thread-virtual-items.ts new file mode 100644 index 00000000000..63ccf56de38 --- /dev/null +++ b/src/renderer/src/components/activity/activity-thread-virtual-items.ts @@ -0,0 +1,74 @@ +import type { ActivityGroupBy, ActivityThreadGroup, AgentPaneThread } from './activity-thread-types' + +/** One row of the virtualized Activity list: a group header or a thread. */ +export type ActivityVirtualItemDescriptor = + | { type: 'header'; group: ActivityThreadGroup } + | { type: 'thread'; thread: AgentPaneThread; groupKey: string } + +export const ACTIVITY_HEADER_ROW_ESTIMATE = 32 +export const ACTIVITY_THREAD_ROW_COMPACT_ESTIMATE = 96 +export const ACTIVITY_THREAD_ROW_FULL_ESTIMATE = 116 + +/** + * Flatten grouped threads into a single virtualizable row list, honoring + * collapsed groups (their thread rows are omitted entirely). + */ +export function buildActivityVirtualItems(args: { + groups: readonly ActivityThreadGroup[] + groupBy: ActivityGroupBy + collapsedGroupKeys: ReadonlySet +}): ActivityVirtualItemDescriptor[] { + const items: ActivityVirtualItemDescriptor[] = [] + for (const group of args.groups) { + if (args.groupBy !== 'none') { + items.push({ type: 'header', group }) + if (args.collapsedGroupKeys.has(group.key)) { + continue + } + } + for (const thread of group.threads) { + items.push({ type: 'thread', thread, groupKey: group.key }) + } + } + return items +} + +/** Stable per-row key: group key for headers, paneKey for threads. */ +export function getActivityVirtualItemKey(item: ActivityVirtualItemDescriptor): string { + return item.type === 'header' ? `h:${item.group.key}` : `t:${item.thread.paneKey}` +} + +export function estimateActivityVirtualItemSize( + item: ActivityVirtualItemDescriptor | undefined, + compactMode: boolean +): number { + if (!item || item.type === 'header') { + return ACTIVITY_HEADER_ROW_ESTIMATE + } + return compactMode ? ACTIVITY_THREAD_ROW_COMPACT_ESTIMATE : ACTIVITY_THREAD_ROW_FULL_ESTIMATE +} + +/** Index of the selected thread's row, or null; kept mounted so activation and focus survive scrolling. */ +export function findActivityThreadItemIndex( + items: readonly ActivityVirtualItemDescriptor[], + paneKey: string | null +): number | null { + if (paneKey === null) { + return null + } + const index = items.findIndex((item) => item.type === 'thread' && item.thread.paneKey === paneKey) + return index === -1 ? null : index +} + +/** Indexes of header rows, for sticky-header resolution. */ +export function getActivityHeaderItemIndexes( + items: readonly ActivityVirtualItemDescriptor[] +): number[] { + const indexes: number[] = [] + items.forEach((item, index) => { + if (item.type === 'header') { + indexes.push(index) + } + }) + return indexes +} diff --git a/src/renderer/src/components/activity/activity-thread-virtual-row.tsx b/src/renderer/src/components/activity/activity-thread-virtual-row.tsx new file mode 100644 index 00000000000..4b6d7ac7d48 --- /dev/null +++ b/src/renderer/src/components/activity/activity-thread-virtual-row.tsx @@ -0,0 +1,71 @@ +import type React from 'react' +import { translate } from '@/i18n/i18n' +import { ActivityStatusGroupHeader } from './activity-thread-controls' +import { ActivityThreadRow } from './activity-thread-row' +import type { ActivityVirtualItemDescriptor } from './activity-thread-virtual-items' + +export function ActivityThreadVirtualRow({ + item, + collapsed, + onToggleGroup, + selectedPaneKey, + onSelectThread, + onJumpToWorkspace, + onMarkThreadRead, + onMarkThreadUnread, + canJumpToWorkspace, + compactMode, + allowMarkUnreadWhenSelected, + showJumpAction +}: { + item: ActivityVirtualItemDescriptor + collapsed: boolean + onToggleGroup: (groupKey: string) => void + selectedPaneKey: string | null + onSelectThread: Parameters[0]['onSelect'] + onJumpToWorkspace: Parameters[0]['onJump'] + onMarkThreadRead: Parameters[0]['onMarkRead'] + onMarkThreadUnread: Parameters[0]['onMarkUnread'] + canJumpToWorkspace: ( + thread: Extract['thread'] + ) => boolean + compactMode: boolean + allowMarkUnreadWhenSelected: boolean + showJumpAction: boolean +}): React.JSX.Element { + if (item.type === 'header') { + return ( +
    + onToggleGroup(item.group.key)} + /> +
    + ) + } + return ( +
    + +
    + ) +} diff --git a/src/renderer/src/components/activity/dev-activity-fixture.test.ts b/src/renderer/src/components/activity/dev-activity-fixture.test.ts new file mode 100644 index 00000000000..3895c01eeb4 --- /dev/null +++ b/src/renderer/src/components/activity/dev-activity-fixture.test.ts @@ -0,0 +1,39 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { parsePaneKey } from '../../../../shared/stable-pane-id' + +const mocks = vi.hoisted(() => ({ + getState: vi.fn(), + setState: vi.fn(), + setAgentStatuses: vi.fn() +})) + +vi.mock('@/store', () => ({ + useAppStore: { + getState: mocks.getState, + setState: mocks.setState + } +})) + +import { seedDevActivityFixture } from './dev-activity-fixture' + +describe('seedDevActivityFixture', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getState.mockReturnValue({ + repos: [], + agentStatusByPaneKey: {}, + setAgentStatuses: mocks.setAgentStatuses + }) + }) + + it('seeds locally-owned rows with valid stable pane keys', () => { + seedDevActivityFixture() + + const updates = mocks.setAgentStatuses.mock.calls[0]?.[0] ?? [] + expect(updates).toHaveLength(3) + for (const update of updates) { + expect(parsePaneKey(update.paneKey)).not.toBeNull() + expect(update.routing.connectionId).toBeNull() + } + }) +}) diff --git a/src/renderer/src/components/activity/dev-activity-fixture.ts b/src/renderer/src/components/activity/dev-activity-fixture.ts new file mode 100644 index 00000000000..9c2f2fc45f9 --- /dev/null +++ b/src/renderer/src/components/activity/dev-activity-fixture.ts @@ -0,0 +1,127 @@ +import { useAppStore } from '@/store' +import type { Repo } from '../../../../shared/repo-types' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import type { Worktree } from '../../../../shared/worktree/types' + +const FIXTURE_REPO_ID = 'dev-fixture-repo' +const FIXTURE_WORKTREE_ID = `${FIXTURE_REPO_ID}::/dev/orca-sample` +const FIXTURE_LEAF_IDS = [ + '11111111-1111-4111-8111-111111111111', + '22222222-2222-4222-8222-222222222222', + '33333333-3333-4333-8333-333333333333' +] as const + +function fixtureRepo(): Repo { + return { + id: FIXTURE_REPO_ID, + path: '/dev/orca-sample', + displayName: 'Orca Sample App', + badgeColor: '#8b5cf6', + addedAt: Date.now(), + kind: 'git', + executionHostId: 'local' + } +} + +function fixtureWorktree(): Worktree { + return { + id: FIXTURE_WORKTREE_ID, + repoId: FIXTURE_REPO_ID, + path: '/dev/orca-sample', + head: 'dev-fixture-head', + branch: 'feature/activity-dashboard', + isBare: false, + isMainWorktree: false, + displayName: 'Activity dashboard', + comment: 'Development fixture workspace', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: true, + isPinned: false, + sortOrder: 0, + lastActivityAt: Date.now() + } +} + +function fixtureTab(id: string, title: string, sortOrder: number): TerminalTab { + return { + id, + ptyId: null, + worktreeId: FIXTURE_WORKTREE_ID, + title, + customTitle: null, + color: null, + sortOrder, + createdAt: Date.now(), + launchAgent: id === 'dev-fixture-tab-1' ? 'codex' : 'claude' + } +} + +/** Populate a fresh development profile with representative activity rows. */ +export function seedDevActivityFixture(): void { + const state = useAppStore.getState() + if (state.repos.length > 0 || Object.keys(state.agentStatusByPaneKey).length > 0) { + return + } + + const now = Date.now() + const repo = fixtureRepo() + const worktree = fixtureWorktree() + const tabs = [ + fixtureTab('dev-fixture-tab-1', 'Refactor activity filters', 0), + fixtureTab('dev-fixture-tab-2', 'Review empty-state copy', 1), + fixtureTab('dev-fixture-tab-3', 'Add keyboard shortcut', 2) + ] + + useAppStore.setState({ + repos: [repo], + activeRepoId: repo.id, + worktreesByRepo: { [repo.id]: [worktree] }, + activeWorktreeId: worktree.id, + activeWorkspaceKey: `worktree:${worktree.id}`, + tabsByWorktree: { [worktree.id]: tabs } + }) + + state.setAgentStatuses([ + { + paneKey: makePaneKey(tabs[0].id, FIXTURE_LEAF_IDS[0]), + payload: { + state: 'working', + prompt: 'Refactor the activity filters and keep the list responsive.', + agentType: 'codex', + model: 'gpt-5-codex', + toolName: 'Edit', + toolInput: 'activity-scope-filter.ts' + }, + timing: { updatedAt: now - 12_000, stateStartedAt: now - 90_000 }, + routing: { tabId: tabs[0].id, worktreeId: worktree.id, connectionId: null } + }, + { + paneKey: makePaneKey(tabs[1].id, FIXTURE_LEAF_IDS[1]), + payload: { + state: 'waiting', + prompt: 'Review the new empty-state copy before merging.', + agentType: 'claude', + model: 'claude-sonnet-4', + lastAssistantMessage: 'The copy is ready for your review.' + }, + timing: { updatedAt: now - 45_000, stateStartedAt: now - 120_000 }, + routing: { tabId: tabs[1].id, worktreeId: worktree.id, connectionId: null } + }, + { + paneKey: makePaneKey(tabs[2].id, FIXTURE_LEAF_IDS[2]), + payload: { + state: 'done', + prompt: 'Add a shortcut to focus the activity search field.', + agentType: 'codex', + model: 'gpt-5-codex', + lastAssistantMessage: 'Added the shortcut and covered it with a test.' + }, + timing: { updatedAt: now - 5 * 60_000, stateStartedAt: now - 8 * 60_000 }, + routing: { tabId: tabs[2].id, worktreeId: worktree.id, connectionId: null } + } + ]) +} diff --git a/src/renderer/src/components/activity/event-time-clock-refresh.test.tsx b/src/renderer/src/components/activity/event-time-clock-refresh.test.tsx new file mode 100644 index 00000000000..d0350fef867 --- /dev/null +++ b/src/renderer/src/components/activity/event-time-clock-refresh.test.tsx @@ -0,0 +1,54 @@ +// @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' + +const clock = vi.hoisted(() => ({ now: 1_000_000 })) + +vi.mock('@/hooks/use-now', () => ({ + useNow: () => clock.now +})) + +import { EventTime } from './activity-thread-controls' + +globalThis.IS_REACT_ACT_ENVIRONMENT = true + +describe('EventTime shared-clock refresh', () => { + 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('derives the label from the shared clock, not a frozen render-time Date.now()', () => { + // Why: memo'd rows no longer re-render on unrelated store writes, so the label + // must follow the injected clock or "2m" would freeze at whatever render saw. + const timestamp = clock.now - 2 * 60_000 + const render = (): void => { + act(() => { + root.render( + + + + ) + }) + } + render() + expect(container.textContent).toContain('2m') + + clock.now += 8 * 60_000 + render() + expect(container.textContent).toContain('10m') + }) +}) diff --git a/src/renderer/src/components/activity/use-activity-thread-action-bindings.test.tsx b/src/renderer/src/components/activity/use-activity-thread-action-bindings.test.tsx new file mode 100644 index 00000000000..ea21dc82cbf --- /dev/null +++ b/src/renderer/src/components/activity/use-activity-thread-action-bindings.test.tsx @@ -0,0 +1,113 @@ +// @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 type { AgentPaneThread } from './activity-thread-types' + +const mocks = vi.hoisted(() => ({ + clearCompletedActivity: vi.fn() +})) + +vi.mock('./activity-clear-completed', () => ({ + clearCompletedActivity: mocks.clearCompletedActivity, + // Done threads carry no live state; the null marker stands in for the real group-id predicate. + isClearableActivityThread: (thread: AgentPaneThread) => thread.currentAgentState === null +})) + +vi.mock('@/store', () => ({ useAppStore: { getState: () => ({}) } })) + +import { useActivityThreadActionBindings } from './use-activity-thread-action-bindings' + +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +function makeThread(paneKey: string, overrides: Partial = {}): AgentPaneThread { + return { paneKey, unread: false, currentAgentState: 'working', ...overrides } as AgentPaneThread +} + +type HookResult = ReturnType + +let container: HTMLDivElement +let root: Root +let latest: HookResult | null + +function Probe(props: Parameters[0]): null { + latest = useActivityThreadActionBindings(props) + return null +} + +function renderProbe(props: Parameters[0]): HookResult { + act(() => { + root.render() + }) + if (!latest) { + throw new Error('hook did not render') + } + return latest +} + +beforeEach(() => { + mocks.clearCompletedActivity.mockClear() + latest = null + container = document.createElement('div') + document.body.append(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() +}) + +describe('useActivityThreadActionBindings', () => { + const acknowledgeAgents = vi.fn() + const baseProps = { + acknowledgeAgents, + unacknowledgeAgents: vi.fn(), + setSelectedPaneKey: vi.fn() + } + + beforeEach(() => { + acknowledgeAgents.mockClear() + }) + + it('enables and applies Mark all read from the badge set, not the narrowed visible set', () => { + const hiddenUnread = makeThread('tab-1:hidden', { unread: true }) + const bindings = renderProbe({ + ...baseProps, + // Search/scope narrowing hid the only unread thread from the list… + visibleThreads: [makeThread('tab-2:read')], + // …but it is still in the badge-coherent set, so it must stay clearable. + markAllReadThreads: [makeThread('tab-2:read'), hiddenUnread] + }) + + expect(bindings.hasUnreadThreads).toBe(true) + bindings.markAllThreadsRead() + expect(acknowledgeAgents).toHaveBeenCalledWith([hiddenUnread.paneKey]) + }) + + it('clears completed strictly from the visible set', () => { + const visibleDone = makeThread('tab-1:done', { currentAgentState: null }) + const hiddenDone = makeThread('tab-2:hidden-done', { currentAgentState: null }) + const bindings = renderProbe({ + ...baseProps, + visibleThreads: [visibleDone], + markAllReadThreads: [visibleDone, hiddenDone] + }) + + expect(bindings.hasCompletedThreads).toBe(true) + bindings.handleClearCompleted() + expect(mocks.clearCompletedActivity).toHaveBeenCalledWith([visibleDone]) + }) + + it('disables clear-completed when completions are only outside the visible set', () => { + const hiddenDone = makeThread('tab-2:hidden-done', { currentAgentState: null }) + const bindings = renderProbe({ + ...baseProps, + visibleThreads: [makeThread('tab-1:working')], + markAllReadThreads: [makeThread('tab-1:working'), hiddenDone] + }) + + expect(bindings.hasCompletedThreads).toBe(false) + }) +}) diff --git a/src/renderer/src/components/activity/use-activity-thread-action-bindings.ts b/src/renderer/src/components/activity/use-activity-thread-action-bindings.ts new file mode 100644 index 00000000000..0004db75737 --- /dev/null +++ b/src/renderer/src/components/activity/use-activity-thread-action-bindings.ts @@ -0,0 +1,71 @@ +import { useCallback, useEffect, useMemo, useRef } from 'react' +import { clearCompletedActivity, isClearableActivityThread } from './activity-clear-completed' +import { createActivityThreadActions } from './activity-thread-actions' +import type { AgentPaneThread } from './activity-thread-types' + +type ActivityThreadActionBindings = { + markThreadRead: (thread: AgentPaneThread) => void + markThreadUnread: (thread: AgentPaneThread) => void + selectThread: (thread: AgentPaneThread) => void + jumpToWorkspace: (thread: AgentPaneThread) => void + markAllThreadsRead: () => void + hasUnreadThreads: boolean + hasCompletedThreads: boolean + handleClearCompleted: () => void +} + +/** + * Bulk-action wiring shared by the sidebar Agents list and the full Activity + * page, so their semantics can never drift apart: + * - Mark all read acts on the badge-coherent set (`markAllReadThreads`), so the + * Agents-tab badge always reaches zero even when search/scope hides rows. + * - Clear completed is destructive and acts only on `visibleThreads` — never on + * rows the user cannot currently see. + */ +export function useActivityThreadActionBindings({ + visibleThreads, + markAllReadThreads, + acknowledgeAgents, + unacknowledgeAgents, + setSelectedPaneKey +}: { + visibleThreads: AgentPaneThread[] + markAllReadThreads: AgentPaneThread[] + acknowledgeAgents: (paneKeys: string[]) => void + unacknowledgeAgents: (paneKeys: string[]) => void + setSelectedPaneKey: (paneKey: string | null) => void +}): ActivityThreadActionBindings { + // Why refs: rows are React.memo'd on these handlers; recreating them whenever a + // thread array identity changes (every status ping) would re-render every mounted row. + const visibleThreadsRef = useRef(visibleThreads) + const markAllReadThreadsRef = useRef(markAllReadThreads) + useEffect(() => { + visibleThreadsRef.current = visibleThreads + markAllReadThreadsRef.current = markAllReadThreads + }, [visibleThreads, markAllReadThreads]) + + const actions = useMemo( + () => + createActivityThreadActions({ + getMarkAllReadThreads: () => markAllReadThreadsRef.current, + acknowledgeAgents, + unacknowledgeAgents, + setSelectedPaneKey + }), + [acknowledgeAgents, unacknowledgeAgents, setSelectedPaneKey] + ) + + const hasUnreadThreads = useMemo( + () => markAllReadThreads.some((t) => t.unread), + [markAllReadThreads] + ) + const hasCompletedThreads = useMemo( + () => visibleThreads.some(isClearableActivityThread), + [visibleThreads] + ) + const handleClearCompleted = useCallback(() => { + clearCompletedActivity(visibleThreadsRef.current) + }, []) + + return { ...actions, hasUnreadThreads, hasCompletedThreads, handleClearCompleted } +} diff --git a/src/renderer/src/components/activity/use-agent-pane-threads.ts b/src/renderer/src/components/activity/use-agent-pane-threads.ts new file mode 100644 index 00000000000..01eb0475a02 --- /dev/null +++ b/src/renderer/src/components/activity/use-agent-pane-threads.ts @@ -0,0 +1,264 @@ +import { useDeferredValue, useMemo, useRef } from 'react' +import { useShallow } from 'zustand/react/shallow' +import { useAppStore } from '@/store' +import { getRepoMapFromState, getWorktreeMapFromState } from '@/store/selectors' +import type { AppState } from '@/store/types' +import { + getSettingsFocusedExecutionHostId, + type ExecutionHostId +} from '../../../../shared/execution-host' +import { buildActivityEvents, createActivityEventBuildCache } from './activity-event-builder' +import { projectActivityTabs, type ActivityTabProjection } from './activity-tab-projection' +import { buildAgentPaneThreads, createAgentPaneThreadReuseCache } from './activity-thread-builder' +import { collectChildAgentPaneKeys } from './activity-thread-child-agent' + +const EMPTY_PANE_KEYS: ReadonlySet = new Set() +import { filterThreadsByActivityScope, resolveActivityScopeRepoIds } from './activity-scope-filter' +import { + activityThreadMatchesSearchQuery, + buildActivityThreadGroups, + isActivitySearchQueryTooLarge +} from './activity-thread-grouping' +import type { + ActivityGroupBy, + ActivityThreadGroup, + AgentPaneThread, + ThreadReadFilter +} from './activity-thread-types' + +export type AgentPaneThreadsStoreData = Pick< + AppState, + | 'agentStatusByPaneKey' + | 'runtimeAgentOrchestrationByPaneKey' + | 'migrationUnsupportedByPtyId' + | 'retainedAgentsByPaneKey' + | 'tabsByWorktree' + | 'repos' + | 'worktreesByRepo' + | 'folderWorkspaces' + | 'detectedWorktreesByRepo' + | 'getKnownWorktreeById' + | 'acknowledgedAgentsByPaneKey' + | 'activityClearedAtByPaneKey' + | 'acknowledgeAgents' + | 'unacknowledgeAgents' +> & { + /** Terminal and agent-session tabs only, identity-stable across focus writes. */ + activityTabs: ActivityTabProjection + worktreeMap: ReturnType + repoMap: ReturnType + generatedTitlesEnabled: boolean + /** Focused-host fallback for hostless worktrees, shared by the scope filter and the row actions. */ + defaultHostId: ExecutionHostId +} + +/** The Activity thread pipeline (store read -> events -> threads -> filter -> + * groups), shared by the Activity page and the sidebar agents list. */ +export function useAgentPaneThreads(args: { + query: string + readFilter: ThreadReadFilter + groupBy: ActivityGroupBy + selectedPaneKey: string | null + showChildAgents?: boolean +}): { + storeData: AgentPaneThreadsStoreData + allThreads: AgentPaneThread[] + selectedPaneKeyIsLive: boolean + effectiveSelectedPaneKey: string | null + /** Threads shown after every active filter; Clear completed operates on exactly + * this set so it never destroys rows the user cannot see. */ + visibleThreads: AgentPaneThread[] + /** Threads after the child-agent classification only — the set the Agents-tab + * badge counts and Mark all read clears; transient search/scope/read narrowing + * is ignored so the badge is always clearable. */ + markAllReadThreads: AgentPaneThread[] + visibleThreadGroups: ActivityThreadGroup[] +} { + const { query, readFilter, groupBy, selectedPaneKey, showChildAgents = false } = args + const agentsVisibleHostIds = useAppStore((s) => s.agentsVisibleHostIds) + const agentsFilterRepoIds = useAppStore((s) => s.agentsFilterRepoIds) + // Why project: the unified tab map is rewritten on every tab focus; the projection keeps + // its identity (and each tab's) unless a field this pipeline reads actually changed. + const tabProjectionRef = useRef<{ + raw: AppState['unifiedTabsByWorktree'] | null + projected: ActivityTabProjection | null + }>({ raw: null, projected: null }) + const storeData = useAppStore( + useShallow((s) => ({ + agentStatusByPaneKey: s.agentStatusByPaneKey, + runtimeAgentOrchestrationByPaneKey: s.runtimeAgentOrchestrationByPaneKey, + migrationUnsupportedByPtyId: s.migrationUnsupportedByPtyId, + retainedAgentsByPaneKey: s.retainedAgentsByPaneKey, + tabsByWorktree: s.tabsByWorktree, + activityTabs: (() => { + const cache = tabProjectionRef.current + if (cache.raw === s.unifiedTabsByWorktree && cache.projected) { + return cache.projected + } + const projected = projectActivityTabs(s.unifiedTabsByWorktree, cache.projected) + tabProjectionRef.current = { raw: s.unifiedTabsByWorktree, projected } + return projected + })(), + repos: s.repos, + worktreesByRepo: s.worktreesByRepo, + folderWorkspaces: s.folderWorkspaces, + detectedWorktreesByRepo: s.detectedWorktreesByRepo, + getKnownWorktreeById: s.getKnownWorktreeById, + worktreeMap: getWorktreeMapFromState(s), + repoMap: getRepoMapFromState(s), + acknowledgedAgentsByPaneKey: s.acknowledgedAgentsByPaneKey, + activityClearedAtByPaneKey: s.activityClearedAtByPaneKey, + acknowledgeAgents: s.acknowledgeAgents, + unacknowledgeAgents: s.unacknowledgeAgents, + generatedTitlesEnabled: s.settings?.tabAutoGenerateTitle === true, + defaultHostId: getSettingsFocusedExecutionHostId(s.settings) + })) + ) + // 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) + + // Why per-hook caches: unchanged panes keep their exact event/snapshot/thread object + // identities across rebuilds, so a status write to one agent leaves every other row's + // memo bail-out and cached search text intact. Rebuilds are deterministic, so a repeated + // (StrictMode/deferred) memo invocation returns identical objects from the cache. + const eventBuildCacheRef = useRef(createActivityEventBuildCache()) + const threadReuseCacheRef = useRef(createAgentPaneThreadReuseCache()) + + const { events: allEvents, liveAgentByPaneKey } = useMemo( + () => + buildActivityEvents( + { + agentStatusByPaneKey: storeData.agentStatusByPaneKey, + runtimeAgentOrchestrationByPaneKey: storeData.runtimeAgentOrchestrationByPaneKey, + migrationUnsupportedByPtyId: storeData.migrationUnsupportedByPtyId, + retainedAgentsByPaneKey: storeData.retainedAgentsByPaneKey, + tabsByWorktree: storeData.tabsByWorktree, + unifiedTabsByWorktree: storeData.activityTabs, + worktreeMap: storeData.worktreeMap, + repoMap: storeData.repoMap, + repos: storeData.repos, + resolveWorktree: storeData.getKnownWorktreeById, + acknowledgedAgentsByPaneKey: storeData.acknowledgedAgentsByPaneKey, + activityClearedAtByPaneKey: storeData.activityClearedAtByPaneKey, + // 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() + }, + eventBuildCacheRef.current + ), + // eslint-disable-next-line react-hooks/exhaustive-deps + [storeData, agentStatusEpoch] + ) + + const allThreads = useMemo( + () => + buildAgentPaneThreads( + { + events: allEvents, + liveAgentByPaneKey, + generatedTitlesEnabled: storeData.generatedTitlesEnabled + }, + threadReuseCacheRef.current + ), + [allEvents, liveAgentByPaneKey, storeData.generatedTitlesEnabled] + ) + + const selectedPaneKeyIsLive = + selectedPaneKey === null || allThreads.some((thread) => thread.paneKey === selectedPaneKey) + const effectiveSelectedPaneKey = selectedPaneKeyIsLive ? selectedPaneKey : null + + // Why scope runs before the per-view filters: host/project scope must stay separate + // from unread/search narrowing. + const { threads: scopeVisibleThreads } = useMemo( + () => + filterThreadsByActivityScope({ + threads: allThreads, + scope: { + visibleHostIds: agentsVisibleHostIds, + filterRepoIds: resolveActivityScopeRepoIds(agentsFilterRepoIds, storeData.repoMap), + defaultHostId: storeData.defaultHostId + }, + exemptPaneKey: effectiveSelectedPaneKey + }), + [ + allThreads, + agentsVisibleHostIds, + agentsFilterRepoIds, + storeData.repoMap, + storeData.defaultHostId, + effectiveSelectedPaneKey + ] + ) + + // Why over allThreads (not the scoped list): child classification asks whether the + // parent pane still exists at all, and a scope filter hiding the parent must not + // reclassify its workers as orphans. + // Skipped entirely when children are shown: nothing reads the set then. + const childAgentPaneKeys = useMemo( + () => (showChildAgents ? EMPTY_PANE_KEYS : collectChildAgentPaneKeys(allThreads)), + [allThreads, showChildAgents] + ) + + // Why deferred: filtering hundreds of threads is interruptible background work; the input + // echoes the keystroke at full priority while the list catches up on the deferred value. + const deferredQuery = useDeferredValue(query) + const visibleThreads = useMemo(() => { + const normalizedQuery = isActivitySearchQueryTooLarge(deferredQuery) + ? null + : deferredQuery.trim().toLowerCase() + return scopeVisibleThreads.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 + } + // Why: child agents (e.g. dispatched orchestration workers) are hidden by default to keep top-level agent views focused on root tasks. + if ( + !showChildAgents && + childAgentPaneKeys.has(thread.paneKey) && + thread.paneKey !== effectiveSelectedPaneKey + ) { + return false + } + if (normalizedQuery === null) { + return false + } + return activityThreadMatchesSearchQuery({ thread, searchQuery: normalizedQuery }) + }) + }, [ + scopeVisibleThreads, + readFilter, + deferredQuery, + effectiveSelectedPaneKey, + showChildAgents, + childAgentPaneKeys + ]) + + const markAllReadThreads = useMemo( + () => + allThreads.filter( + (thread) => + showChildAgents || + !childAgentPaneKeys.has(thread.paneKey) || + thread.paneKey === effectiveSelectedPaneKey + ), + [allThreads, showChildAgents, childAgentPaneKeys, effectiveSelectedPaneKey] + ) + + const visibleThreadGroups = useMemo( + () => buildActivityThreadGroups(visibleThreads, groupBy), + [visibleThreads, groupBy] + ) + + return { + storeData, + allThreads, + selectedPaneKeyIsLive, + effectiveSelectedPaneKey, + visibleThreads, + markAllReadThreads, + visibleThreadGroups + } +} diff --git a/src/renderer/src/components/activity/useActivityUnreadCount.test.ts b/src/renderer/src/components/activity/useActivityUnreadCount.test.ts index 5c28a132762..62c423ce1bd 100644 --- a/src/renderer/src/components/activity/useActivityUnreadCount.test.ts +++ b/src/renderer/src/components/activity/useActivityUnreadCount.test.ts @@ -22,29 +22,26 @@ function makeSource(entry: AgentStatusEntry, ackAt = 0) { acknowledgedAgentsByPaneKey: { [PANE]: ackAt }, agentStatusByPaneKey: { [PANE]: entry }, migrationUnsupportedByPtyId: {}, - retainedAgentsByPaneKey: {}, - worktreesByRepo: {} + retainedAgentsByPaneKey: {} } } describe('countActivityUnread session-boundary rows (STA-3386)', () => { - it('does not count a session-boundary done as unread in either mode', () => { + it('does not count a session-boundary done as unread', () => { const source = makeSource(makeEntry({ sessionBoundary: true })) - expect(countActivityUnread(source, 'sidebar-badge')).toBe(0) - expect(countActivityUnread(source, 'agent-events')).toBe(0) + expect(countActivityUnread(source)).toBe(0) }) it('keeps counting a real completion displaced into history by a session boundary', () => { // Why: agent finished (unacknowledged), then the user resumed the session — the - // boundary row replaces the live done but the finish must stay unread in both badges. + // boundary row replaces the live done but the finish must stay unread. const source = makeSource( makeEntry({ sessionBoundary: true, stateHistory: [{ state: 'done', prompt: 'fix bug', startedAt: 1_000 }] }) ) - expect(countActivityUnread(source, 'sidebar-badge')).toBe(1) - expect(countActivityUnread(source, 'agent-events')).toBe(1) + expect(countActivityUnread(source)).toBe(1) }) it('stops counting the displaced completion once acknowledged', () => { @@ -55,12 +52,60 @@ describe('countActivityUnread session-boundary rows (STA-3386)', () => { }), 1_500 ) - expect(countActivityUnread(source, 'sidebar-badge')).toBe(0) - expect(countActivityUnread(source, 'agent-events')).toBe(0) + expect(countActivityUnread(source)).toBe(0) }) - it('still counts an ordinary unacknowledged done in sidebar-badge mode', () => { + it('still counts an ordinary unacknowledged done', () => { const source = makeSource(makeEntry({})) - expect(countActivityUnread(source, 'sidebar-badge')).toBe(1) + expect(countActivityUnread(source)).toBe(1) + }) +}) + +describe('countActivityUnread with Clear completed cutoffs', () => { + it('does not count events hidden by the pane cutoff', () => { + const source = { + ...makeSource( + makeEntry({ + stateHistory: [{ state: 'done', prompt: 'older run', startedAt: 1_000 }] + }) + ), + activityClearedAtByPaneKey: { [PANE]: 2_000 } + } + // Both the history event (1_000) and the live done (2_000) are at or before the cutoff. + expect(countActivityUnread(source)).toBe(0) + }) + + it('keeps counting turns newer than the cutoff', () => { + const source = { + ...makeSource( + makeEntry({ + stateStartedAt: 3_000, + stateHistory: [{ state: 'done', prompt: 'older run', startedAt: 1_000 }] + }) + ), + activityClearedAtByPaneKey: { [PANE]: 2_000 } + } + expect(countActivityUnread(source)).toBe(1) + }) +}) + +describe('countActivityUnread source overlap', () => { + it('counts an overlapping live and retained pane only once', () => { + const entry = makeEntry({}) + const source = { + acknowledgedAgentsByPaneKey: { [PANE]: 0 }, + agentStatusByPaneKey: { [PANE]: entry }, + retainedAgentsByPaneKey: { + [PANE]: { + entry, + worktreeId: 'wt-1', + tab: {} as never, + agentType: 'claude', + startedAt: 1_000 + } + }, + migrationUnsupportedByPtyId: {} + } + expect(countActivityUnread(source)).toBe(1) }) }) diff --git a/src/renderer/src/components/activity/useActivityUnreadCount.ts b/src/renderer/src/components/activity/useActivityUnreadCount.ts index 91b51c4455e..3a3074d24e9 100644 --- a/src/renderer/src/components/activity/useActivityUnreadCount.ts +++ b/src/renderer/src/components/activity/useActivityUnreadCount.ts @@ -12,85 +12,60 @@ type ActivityUnreadCountSource = Pick< | 'agentStatusByPaneKey' | 'migrationUnsupportedByPtyId' | 'retainedAgentsByPaneKey' - | 'worktreesByRepo' -> - -type ActivityUnreadCountMode = 'agent-events' | 'sidebar-badge' - -const EMPTY_WORKTREES_BY_REPO: AppState['worktreesByRepo'] = {} -const EMPTY_MIGRATION_UNSUPPORTED: AppState['migrationUnsupportedByPtyId'] = {} -const EMPTY_RETAINED_AGENTS: AppState['retainedAgentsByPaneKey'] = {} -const EMPTY_ACKNOWLEDGED_AGENTS: AppState['acknowledgedAgentsByPaneKey'] = {} - -const DISABLED_ACTIVITY_UNREAD_INPUTS = { - sortEpoch: 0, - worktreesByRepo: EMPTY_WORKTREES_BY_REPO, - migrationUnsupportedByPtyId: EMPTY_MIGRATION_UNSUPPORTED, - retainedAgentsByPaneKey: EMPTY_RETAINED_AGENTS, - acknowledgedAgentsByPaneKey: EMPTY_ACKNOWLEDGED_AGENTS +> & { + /** Per-pane "Clear completed" cutoffs; hidden events must not count as unread. */ + activityClearedAtByPaneKey?: Record } function isUnreadAgentState(state: AgentStatusState): boolean { return state === 'done' || state === 'blocked' || state === 'waiting' } -export function countActivityUnread( - source: ActivityUnreadCountSource, - mode: ActivityUnreadCountMode -): number { +/** Counts unread done/blocked/waiting events for the Activity page titlebar badge. */ +export function countActivityUnread(source: ActivityUnreadCountSource): number { let count = 0 + const seenPaneKeys = new Set() - if (mode === 'sidebar-badge') { - for (const worktrees of Object.values(source.worktreesByRepo)) { - for (const worktree of worktrees) { - if (worktree.createdAt && worktree.isUnread) { - count += 1 - } - } - } - } - + // Why no worktree.isUnread here: Activity lists only agent threads, so a worktree + // unread would light a badge with no row to read and no way to clear it. const countEntry = (entry: AgentStatusEntry, ackAt: number): void => { - if (mode === 'agent-events') { - // Why: Activity feed surfaces historical done/blocked/waiting events - // from stateHistory, so the titlebar badge must mirror that event count. - for (const history of entry.stateHistory) { - if (isUnreadAgentState(history.state) && ackAt < history.startedAt) { - count += 1 - } + // Why: "Clear completed" hides events at or before the pane's cutoff from the feed, + // so a hidden event must not keep the badge lit; treat the cutoff like an ack floor. + const clearedAt = source.activityClearedAtByPaneKey?.[entry.paneKey] ?? 0 + const mutedAt = Math.max(ackAt, clearedAt) + // Why: Activity feed surfaces historical done/blocked/waiting events + // from stateHistory, so the titlebar badge must mirror that event count. + for (const history of entry.stateHistory) { + if (isUnreadAgentState(history.state) && mutedAt < history.startedAt) { + count += 1 } } // Why: a session-boundary done is an idle connect (STA-3386), not an event to read. - // History never contains a boundary, but it DOES keep the real completion a boundary - // displaced (the slice pushes it on done→done), so sidebar-badge mode — which skips the - // history loop above — must still count that displaced completion or the badge silently - // drops an unacknowledged finish the moment its session is resumed. if ( isUnreadAgentState(entry.state) && entry.sessionBoundary !== true && - ackAt < entry.stateStartedAt + mutedAt < entry.stateStartedAt ) { count += 1 - } else if (mode === 'sidebar-badge' && entry.state === 'done' && entry.sessionBoundary) { - const displaced = entry.stateHistory.at(-1) - if (displaced && isUnreadAgentState(displaced.state) && ackAt < displaced.startedAt) { - count += 1 - } } } for (const [paneKey, entry] of Object.entries(source.agentStatusByPaneKey)) { + seenPaneKeys.add(paneKey) countEntry(entry, source.acknowledgedAgentsByPaneKey[paneKey] ?? 0) } for (const [paneKey, retained] of Object.entries(source.retainedAgentsByPaneKey)) { - if (mode === 'sidebar-badge' && retained.entry.state !== 'done') { + // Live status is the primary source; retained is a handoff cache and may briefly overlap it. + if (seenPaneKeys.has(paneKey)) { continue } + seenPaneKeys.add(paneKey) countEntry(retained.entry, source.acknowledgedAgentsByPaneKey[paneKey] ?? 0) } for (const unsupported of Object.values(source.migrationUnsupportedByPtyId)) { const entry = migrationUnsupportedToAgentStatusEntry(unsupported) - if (entry) { + if (entry && !seenPaneKeys.has(entry.paneKey)) { + seenPaneKeys.add(entry.paneKey) countEntry(entry, source.acknowledgedAgentsByPaneKey[entry.paneKey] ?? 0) } } @@ -98,53 +73,40 @@ export function countActivityUnread( return count } -export function useActivityUnreadCount(enabled: boolean, mode: ActivityUnreadCountMode): number { +export function useActivityUnreadCount(): number { const { sortEpoch, - worktreesByRepo, migrationUnsupportedByPtyId, retainedAgentsByPaneKey, - acknowledgedAgentsByPaneKey + acknowledgedAgentsByPaneKey, + activityClearedAtByPaneKey } = useAppStore( - useShallow((state) => { - if (!enabled) { - return DISABLED_ACTIVITY_UNREAD_INPUTS - } - return { - // Why: live status prompt/tool updates churn agentStatusByPaneKey but - // cannot change unread count unless a sort-relevant state transition - // or removal occurred. sortEpoch is the cheap invalidation signal. - sortEpoch: state.sortEpoch, - worktreesByRepo: state.worktreesByRepo, - migrationUnsupportedByPtyId: state.migrationUnsupportedByPtyId, - retainedAgentsByPaneKey: state.retainedAgentsByPaneKey, - acknowledgedAgentsByPaneKey: state.acknowledgedAgentsByPaneKey - } - }) + useShallow((state) => ({ + // Why: live status prompt/tool updates churn agentStatusByPaneKey but + // cannot change unread count unless a sort-relevant state transition + // or removal occurred. sortEpoch is the cheap invalidation signal. + sortEpoch: state.sortEpoch, + migrationUnsupportedByPtyId: state.migrationUnsupportedByPtyId, + retainedAgentsByPaneKey: state.retainedAgentsByPaneKey, + acknowledgedAgentsByPaneKey: state.acknowledgedAgentsByPaneKey, + activityClearedAtByPaneKey: state.activityClearedAtByPaneKey + })) ) return useMemo(() => { - if (!enabled) { - return 0 - } void sortEpoch - return countActivityUnread( - { - agentStatusByPaneKey: useAppStore.getState().agentStatusByPaneKey, - migrationUnsupportedByPtyId, - retainedAgentsByPaneKey, - worktreesByRepo, - acknowledgedAgentsByPaneKey - }, - mode - ) + return countActivityUnread({ + agentStatusByPaneKey: useAppStore.getState().agentStatusByPaneKey, + migrationUnsupportedByPtyId, + retainedAgentsByPaneKey, + acknowledgedAgentsByPaneKey, + activityClearedAtByPaneKey + }) }, [ acknowledgedAgentsByPaneKey, - enabled, + activityClearedAtByPaneKey, migrationUnsupportedByPtyId, - mode, retainedAgentsByPaneKey, - sortEpoch, - worktreesByRepo + sortEpoch ]) } diff --git a/src/renderer/src/components/browser-favicon.tsx b/src/renderer/src/components/browser-favicon.tsx new file mode 100644 index 00000000000..ecde4065f03 --- /dev/null +++ b/src/renderer/src/components/browser-favicon.tsx @@ -0,0 +1,59 @@ +import { useState } from 'react' +import { Globe } from 'lucide-react' +import { cn } from '@/lib/utils' + +function displayableFaviconUrl(faviconUrl: string | null | undefined): string | null { + const trimmed = faviconUrl?.trim() + if (!trimmed) { + return null + } + if (trimmed.startsWith('data:image/')) { + return trimmed + } + try { + const url = new URL(trimmed) + return url.protocol === 'http:' || url.protocol === 'https:' ? trimmed : null + } catch { + return null + } +} + +export function BrowserFavicon({ + faviconUrl, + className, + fallbackClassName +}: { + faviconUrl: string | null | undefined + className?: string + fallbackClassName?: string +}): React.JSX.Element { + const displayUrl = displayableFaviconUrl(faviconUrl) + const [failedUrl, setFailedUrl] = useState(null) + + // Why: reset during render on any favicon identity change — including a clear to null while + // a page loads — so navigating back to the same url retries instead of keeping the fallback. + if (failedUrl !== null && failedUrl !== displayUrl) { + setFailedUrl(null) + } + + if (displayUrl && failedUrl !== displayUrl) { + return ( + setFailedUrl(displayUrl)} + /> + ) + } + + return