Skip to content

perf(desktop): key sidebar git-watch off agent activity, not every row - #6856

Open
AviPeltz wants to merge 8 commits into
mixolydian-cockatoofrom
sidebar-activity-based-git-watch
Open

perf(desktop): key sidebar git-watch off agent activity, not every row#6856
AviPeltz wants to merge 8 commits into
mixolydian-cockatoofrom
sidebar-activity-based-git-watch

Conversation

@AviPeltz

@AviPeltz AviPeltz commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

Links

Summary

  • DashboardSidebarWorkspaceStatusProvider now only holds live git:watch interest for the active workspace or ones with a currently running/blocked/attention-needing agent (any non-idle PaneStatus), instead of every sidebar row.

Why / Context

#6848 made GitWatcher refcounted and lazy, but live-tested against real production data (90 non-archived workspaces) showed the fix's practical benefit was capped: merely opening the dashboard caused 79 of those 90 to become watched anyway, because this provider called watchGit for every row to keep diff-stat caches warm. For a user who keeps the dashboard open — the common case, and the scenario in the original terminal-input-lag report — that's most of the population #6848 was trying to stop watching, reintroduced through a different subscriber. This PR is the flagged follow-up that actually unlocks the benefit for that case.

How It Works

  • An idle workspace's git state can't change without user interaction, so its diff count doesn't need to stay live — it only needs to be correct the next time it's opened, which useDiffStats' normal query fetch already handles. Non-active rows don't render a diff count today anyway (only the active row does, per the existing entries memo), so losing liveness for an idle row costs a first-open loading flash, not a wrong number ever shown on screen.
  • The "is this workspace active" signal is already computed in this component (bindingRowsByIndex + deriveTerminalAgentStatus — the same data driving the status dot), so no new plumbing was needed.
  • Split into two effects so the (still full-coverage) lifecycle-listener registration doesn't churn every time an unrelated workspace's terminal status flips:
    • One for listeners (agent:lifecycle/terminal:lifecycle/git:changed), unchanged, keyed on the full target list — this still covers every row, since it's what detects a row transitioning into activity in the first place. Cheap to tear down and redo every render.
    • One for watchGit/unwatchGit, new, keyed on a fingerprint-stabilized "currently worth watching" set — these calls carry real host-side cost (DB lookup, git subprocess, live fs.watch attach/teardown), so only they're gated.
    • The existing final-unmount cleanup is unchanged.

Manual QA Checklist

Testing

  • bunx tsc --noEmit (desktop) — clean for this file
  • bunx biome check — clean
  • No automated test exists for this component (none did before this change either)

Known Limitations

Follow-ups

  • If useful, verify live (CDP) that opening the dashboard now only watches the active/in-play subset instead of ~88% of non-archived workspaces.

Summary by cubic

Gate the dashboard sidebar’s git-watch to only the active workspace and workspaces with non‑idle agents, instead of every sidebar row, to cut host load and stop terminal lag. Old behavior watched all rows; new behavior watches only active/in‑play rows and keeps full lifecycle listeners for all rows to detect transitions; idle, non‑active rows no longer live‑update diff counts and instead refetch on open (a brief first‑open loading is expected).

  • Bug Fixes

    • workspace-client: unwatchGit, unwatchFs, and file unwatch now call maybeCleanupConnection so a release‑only call can’t create and strand a new, unretained connection.
    • Adds a real WebSocket test that reproduces the ordering hazard and asserts no extra upgrade and no lingering client.
  • Refactors

    • Reuses the canonical attention predicate for gating instead of duplicating status !== "idle".
    • Sorts the watched set by workspaceId to stabilize the fingerprint and avoid needless watch/unwatch churn on pure reorders.

Written for commit a9edaf0. Summary will update on new commits.

Review in cubic

DashboardSidebarWorkspaceStatusProvider called watchGit for every sidebar
row to keep diff-stat caches warm. Verified live against real data in
#6848: opening the dashboard alone (no workspace opened) caused 79 of 90
non-archived workspaces to become watched, since the sidebar is normally
mounted for the whole session — the load and terminal-lag scenario #6729
set out to fix, reintroduced through a different subscriber.

An idle workspace's git state can't change without user interaction, so its
diff count doesn't need to stay live — it only needs to be correct the next
time it's opened, which useDiffStats' normal query fetch already handles.
Non-active rows don't render a diff count today anyway (only the active
row does, per the existing `entries` memo), so losing liveness for an idle
row costs a first-open loading flash, not a wrong number on screen.

Now only workspaces with a currently running/blocked/attention-needing
agent (working/permission/review/failed — any non-idle PaneStatus) or the
active workspace hold live git-watch interest. The signal is already
computed here (bindingRowsByIndex + deriveTerminalAgentStatus, the same
data driving the status dot) — no new plumbing. The full lifecycle-listener
pass (agent:lifecycle/terminal:lifecycle/git:changed) still covers every
row regardless, since that's what detects a row transitioning into
activity in the first place; only the watchGit/unwatchGit calls — the ones
with real host-side cost — are now gated.

Split into two effects so the (still full-coverage, cheap) listener
registration doesn't churn every time an unrelated workspace's terminal
status flips: one for listeners (unchanged, keyed on the full target list),
one for git-watch diffing (new, keyed on a fingerprint-stabilized
"currently worth watching" set), plus the existing final-unmount cleanup.

Verified: typecheck and biome clean; no automated test exists for this
component (none did before this change either). Logic re-reviewed by hand
against the exact structure verified live via CDP against real production
data in #6848, but not independently re-verified live in this follow-up —
happy to spin up a fresh dev session and confirm the watched-workspace
count actually drops if useful before merge.

Follow-up to #6848 / #6729.

Claude-Session: https://claude.ai/code/session_01NmLFihnhebmL9bbbojGYCR
@coderabbitai

coderabbitai Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f8061549-e051-4297-b13d-9e8d26ed5dda

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…ained connection

Found by a review of #6856's cleanup-effect ordering, but pre-existing —
affects fs:watch too, and predates both #6848 and #6856.

getEventBus(hostUrl) unconditionally (re)creates a ConnectionState if none
exists in the shared `connections` map. unwatchGit/unwatchFs/unwatchFsFile
never called maybeCleanupConnection, unlike on()'s and retain()'s cleanups
— so a caller that only ever intends to *release* interest (never establish
it) could, if the connection's last retainer/listener already tore it down
moments earlier, silently mint a brand-new WebSocket connection just to
send an unwatch command, then leave it dangling forever: nothing else would
ever call maybeCleanupConnection for it again.

This is a real, reachable ordering hazard in a multi-effect component like
DashboardSidebarWorkspaceStatusProvider: React runs effect cleanups in
declaration order (not reversed), so a "listener registration" effect's
cleanup releasing a connection's last retain/listener, followed by a
separate "release git-watch interest" effect's cleanup (or body, on a
mid-session workspace removal) calling unwatchGit for the same host, hits
this exactly.

Fix: unwatchGit/unwatchFs/unwatchFsFile now call maybeCleanupConnection
after releasing their own interest, mirroring on()'s and retain()'s
cleanups. Verified with a real WS server (no mocks): before the fix, a
release-only call after the connection's teardown opens a second real
upgrade that never closes; after the fix, maybeCleanupConnection fires
synchronously and fast enough that the stray connection never even dials
out — confirmed via a standalone diagnostic script before writing the
regression test, so the test's "no upgrade" assertion isn't guesswork.

Claude-Session: https://claude.ai/code/session_01NmLFihnhebmL9bbbojGYCR
…tch gating

Two smaller findings from reviewing #6856:

- computedGitWatchTargets re-derived "is this workspace worth a live git
  watch" via an ad hoc `status !== "idle"` check, duplicating the file's own
  canonical predicate (getHighestPriorityStatus(...) !== null, already used
  two lines below for the UI's own attention indicator). Reusing it means a
  future PaneStatus change can't silently drift the two decisions apart —
  and it drops an unnecessary array-spread allocation in the same motion
  (getHighestPriorityStatus takes the Map's values() iterator directly).

- gitWatchTargets's fingerprint was computed over an array in `targets`'
  order, even though the actual watched set is order-independent (Effect
  B's diff is keyed by workspaceId) — a pure reorder of `workspaces` with no
  membership change produced a different fingerprint and triggered a
  needless diffing pass. Now sorted by workspaceId before returning.

Claude-Session: https://claude.ai/code/session_01NmLFihnhebmL9bbbojGYCR
@github-actions

Copy link
Copy Markdown
Contributor

🚀 Preview Deployment

🔗 Preview Links

Service Status Link
Neon Database (Neon) View Branch
Vercel API (Vercel) Open Preview
Vercel Web (Vercel) Open Preview
Vercel Marketing (Vercel) Open Preview
Vercel Admin (Vercel) Open Preview
Vercel Docs (Vercel) Open Preview

Preview updates automatically with new commits

AviPeltz added a commit that referenced this pull request Aug 25, 2026
…ained connection

Found by a review of #6856's cleanup-effect ordering, but pre-existing —
affects fs:watch too, and predates both #6848 and #6856.

getEventBus(hostUrl) unconditionally (re)creates a ConnectionState if none
exists in the shared `connections` map. unwatchGit/unwatchFs/unwatchFsFile
never called maybeCleanupConnection, unlike on()'s and retain()'s cleanups
— so a caller that only ever intends to *release* interest (never establish
it) could, if the connection's last retainer/listener already tore it down
moments earlier, silently mint a brand-new WebSocket connection just to
send an unwatch command, then leave it dangling forever: nothing else would
ever call maybeCleanupConnection for it again.

This is a real, reachable ordering hazard in a multi-effect component like
DashboardSidebarWorkspaceStatusProvider: React runs effect cleanups in
declaration order (not reversed), so a "listener registration" effect's
cleanup releasing a connection's last retain/listener, followed by a
separate "release git-watch interest" effect's cleanup (or body, on a
mid-session workspace removal) calling unwatchGit for the same host, hits
this exactly.

Fix: unwatchGit/unwatchFs/unwatchFsFile now call maybeCleanupConnection
after releasing their own interest, mirroring on()'s and retain()'s
cleanups. Verified with a real WS server (no mocks): before the fix, a
release-only call after the connection's teardown opens a second real
upgrade that never closes; after the fix, maybeCleanupConnection fires
synchronously and fast enough that the stray connection never even dials
out — confirmed via a standalone diagnostic script before writing the
regression test, so the test's "no upgrade" assertion isn't guesswork.

Claude-Session: https://claude.ai/code/session_01NmLFihnhebmL9bbbojGYCR
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant