Skip to content

sidebar: per-agent status rows (one row per agent pane) - #7559

Open
lawrencecchen wants to merge 53 commits into
mainfrom
feat-per-agent-status
Open

lawrencecchen wants to merge 53 commits into
mainfrom
feat-per-agent-status

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Jul 8, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • One sidebar status row per agent pane. statusEntries is workspace-scoped and keyed by agent type (last write wins), so N agents of the same type in one workspace collapsed into a single ambiguous pill. This PR stores a panel-scoped copy of every structured agent status report (statusEntriesByPanelId on the sidebar agent runtime observation model, written in the same socket upsert path) and renders one row per live agent pane.
  • Row semantics: the panel-scoped report wins; the workspace-level entry is only trusted when a single pane owns that status key; with multiple panes and no panel-scoped report, the per-panel agent lifecycle (running / needs input / idle) drives the row so a stale shared text is never attributed to the wrong pane.
  • Rows show a pane label (custom title, directory label, or pane title) when more than one agent runs in the workspace, and clicking a row focuses that pane. Structured entries covered by rows are removed from the flat metadata list to avoid double display; non-structured set_status keys and the CLI list-status output are unchanged.
  • Rows follow pane moves via the existing DetachedAgentRuntimeState transfer (extract now prefers the panel-scoped entry; adopt seeds it in the destination) and clear on agent PID clear, clear_status, and pane close.

Testing

  • cmuxTests/PerAgentSidebarStatusRowTests.swift (wired into pbxproj, lint-pbxproj-test-wiring ok): two same-type agents keep separate rows; sole owner falls back to the workspace entry; shared key without panel entries uses per-panel lifecycle and never the ambiguous shared text; PID clear removes only that pane's row; detached-runtime transfer carries the panel-scoped entry; pane close drops panel-scoped entries.
  • Tagged cloud build agrows.

Issues


View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.


Note

Medium Risk
Touches sidebar agent attribution, persistence, and control-socket mutation paths where incorrect panel/workspace targeting could show stale or wrong rows; changes are feature-flagged but span CLI and UI behavior.

Overview
Adds one sidebar status row per agent pane instead of collapsing multiple agents of the same type into a single workspace pill. Status is stored and resolved per panel (with safe fallbacks when a shared workspace key is ambiguous), rows can focus their pane, and structured keys shown as rows are hidden from the flat metadata list.

CLI / control socket: cmux set-status gains --panel and --pid; new commands set-agent-lifecycle and clear-agent-pid forward to the socket. Sidebar metadata forwarding resolves workspace/tab and panel IDs more consistently. set_status with a panel can mark dynamicAgentRowKey when the key passes the vault-agent allowlist (not enabled for report_meta).

Also adds localization for agent row lifecycle copy, a sidebar-agent-rows feature flag, debug Agent Rows Style strings, and routine Swift file-length budget updates.

Reviewed by Cursor Bugbot for commit 32fae4c. Bugbot is set up for automated code reviews on this repo. Configure here.


Summary by cubic

Show one sidebar status row per agent pane and store status per panel. Previously we collapsed same‑type agents into a workspace pill; now each pane keeps its own row that persists, focuses its pane on click, and safely falls back when a shared key is ambiguous.

  • Storage and rendering: prefer panel‑scoped status; trust a workspace entry only with a sole owner; otherwise render from per‑panel lifecycle. Hide covered keys from metadata. Labels include surface names and pane titles; same‑text title provenance changes (auto→user) now re-render immediately. URL‑backed rows mirror metadata link behavior, and clicks focus the target pane across workspaces.
  • Persistence and autosave: persist panel‑scoped url/priority/format and lifecycles; snapshots carry agentStatusRows; autosave fingerprints hash panel‑scoped content and lifecycles so value‑only changes save promptly. On restore, running → unknown; identical heartbeats don’t churn snapshots.
  • PID ownership and displacement: record PID ownership before writes; bare shared keys rekey to synthesized per‑pane keys so siblings keep rows. Non‑owner clear-agent-pid clears only that pane and refreshes ports; workspace clear_status reaps displaced runtimes. Pane close and agent replacement drop stale rows and workspace slots.
  • CLI/control socket and UI: set-status adds --panel/--pid; new set-agent-lifecycle and clear-agent-pid; registry‑validated dynamic keys can create rows; --workspace resolves both --tab and panel scope; validation runs off‑main. Brand icons with state dots; spinner for running, idle hides; accordion summary when multiple; independent below‑card click targets; graphite prototype and a debug “Agent Rows Style” lab. Gated behind sidebar-agent-rows-enabled-release (DEBUG on, release off).

Written for commit 403717e. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features
    • Added dedicated sidebar agent-status rows with idle, needs input, and running states, including per-panel detail, optional URL navigation, and accordion summaries when multiple rows exist.
  • Bug Fixes
    • Improved panel-scoped status attribution and refresh behavior when agents are replaced, panels are adopted/closed, or status entries are cleared—reducing stale, duplicated, or cross-pane updates.
  • Localization
    • Added English and Japanese translations for new sidebar agent-status strings and summary counts.
  • Tests
    • Added coverage for per-panel sidebar status row rendering and counting behavior.

Several agents in one workspace previously collapsed into a single
last-write-wins status pill per agent type (statusEntries is
workspace-scoped, keyed by agent type). Store a panel-scoped copy of
every structured agent status report and render one sidebar row per
live agent pane: panel-scoped report wins, the workspace-level entry is
only trusted when a single pane owns the key, otherwise the per-panel
lifecycle drives the row. Rows follow pane moves via the existing
detached-runtime transfer and clear on pane close; clicking a row
focuses that pane.
@vercel

ghost commented Jul 8, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview Aug 25, 2026 4:09am
cmux-staging Building Building Preview Aug 25, 2026 4:09am

@coderabbitai

ghost commented Jul 8, 2026 •

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds per-panel structured agent status storage and sidebar projection, plus new sidebar views, snapshot wiring, lifecycle updates, localization entries, tests, and Xcode project references.

Changes

Per-Panel Sidebar Agent Status

Layer / File(s) Summary
Status storage and row projection
Sources/WorkspaceSidebarAgentRuntimeObservationModel.swift, Sources/Workspace+SidebarAgentStatus.swift
Adds statusEntriesByPanelId, SidebarAgentStatusRow, ownership inference, visibility filtering, pane labels, and sidebarAgentStatusRows() projection and ordering.
Panel lifecycle integration
Sources/Workspace+PanelLifecycle.swift
Updates runtime aggregation and cleanup to read, record, clear, adopt, and discard panel-scoped status entries, and exposes structured-status helper accessors.
Terminal controller status updates
Sources/TerminalController+ControlSidebarContext.swift
Builds the status entry once during upsert, records panel-scoped status even when workspace replacement is skipped, and clears panel-scoped entries alongside workspace clears.
Agent status row views
Sources/SidebarAgentStatusRowViews.swift, Resources/Localizable.xcstrings
Adds agent-status sidebar views with summary counts, collapse handling, URL focus behavior, lifecycle text, icon/color selection, markdown rendering, and localized sidebar status strings.
Metadata row views
Sources/SidebarMetadataRowViews.swift
Adds sidebar metadata row views with URL focus behavior, expandable row display, icon and color selection, and markdown rendering.
Snapshot and sidebar rendering
Sources/ContentView.swift, Sources/Sidebar/SidebarWorkspaceSnapshotRefreshPolicy.swift
Computes agent status rows in the snapshot, filters overlapping metadata entries, renders the new sidebar section, propagates the field through context-menu snapshot stabilization, and removes old inline metadata row rendering.
Tests and project wiring
cmuxTests/PerAgentSidebarStatusRowTests.swift, cmuxTests/SidebarWorkspaceSnapshotRefreshPolicyTests.swift, cmux.xcodeproj/project.pbxproj
Adds panel-scoped sidebar status tests, updates the snapshot test fixture for the new field, and wires the new source files into the Xcode project targets.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

  • manaflow-ai/cmux#3744: Overlaps with panel lifecycle teardown and panel-scoped status/PID ownership cleanup in Sources/Workspace+PanelLifecycle.swift.
  • manaflow-ai/cmux#4237: Also extends discardClosedPanelLifecycleState(...) cleanup for panel-scoped workspace state.
  • manaflow-ai/cmux#7357: Touches the same sidebar control-socket status scheduling paths in TerminalController+ControlSidebarContext.swift.

Important

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

❌ Failed checks (2 errors, 1 warning)

Check name Status Explanation Resolution
Cmux Algorithmic Complexity ❌ Error Sources/Workspace+PanelLifecycle.swift scans all ownership maps inside per-key loops in agentRuntimeState/discardClosedPanelLifecycleState, creating nested O(k·n) work on close/detach paths. Precompute status-key ownership once per call (or maintain a reverse index) and reuse it in the loops; avoid calling panelsOwningAgentStatusKey repeatedly.
Cmux Full Internationalization ❌ Error New sidebar strings were added only for en/ja, but Localizable.xcstrings already supports 20 locales, leaving 18 locales untranslated. Add matching translations for every existing locale in Resources/Localizable.xcstrings (ar, bs, da, de, es, fr, it, km, ko, nb, pl, pt-BR, ru, th, tr, uk, zh-Hans, zh-Hant).
Linked Issues check ⚠️ Warning The PR implements per-pane status rows, but #1336 also asks for rename propagation and adapter support that aren't shown here. Either narrow the linked issue scope to per-agent sidebar rows or add the missing workspace-summary rename propagation and adapter-support work.
✅ Passed checks (22 passed)
Check name Status Explanation
Out of Scope Changes check ✅ Passed The changes stay focused on sidebar agent-status storage, rendering, lifecycle cleanup, tests, and localization for the new feature.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Cmux Swift Actor Isolation ✅ Passed New sidebar status storage is @MainActor on Workspace and the runtime model; mutations hop through main-actor scheduling, so no isolation regression introduced.
Cmux Swift Blocking Runtime ✅ Passed Touched Swift changes add state/UI projection only; no new waits, sleeps, semaphores, main-syncs, or locks appear in the modified files.
Cmux Browser Automation Off-Main ✅ Passed PR only changes sidebar/status files; it does not touch the browser automation routing files covered by the rule.
Cmux Expensive Synchronous Load ✅ Passed Diff only adds in-memory status projection/UI; no RestorableAgentSessionIndex.load(), transcript/JSONL parsing, or other heavy history load was moved onto main/interactive paths.
Cmux Cache Substitution Correctness ✅ Passed agentStatusRows is computed fresh in makeWorkspaceSnapshot; refresh policy only preserves it for transient context-menu UI, with current??next cold fallback and documented noisy-telemetry rationale.
Cmux No Hacky Sleeps ✅ Passed Only Swift sources/tests changed; no non-Swift runtime or build scripts added fixed sleeps, timers, or polling.
Cmux Swift Concurrency ✅ Passed No new legacy async patterns; the only added Task is a callback-bound MainActor hop in AsyncStream termination, not app async work.
Cmux Swift @Concurrent ✅ Passed No changed Swift file adds @concurrent or nonisolated async work; the only new async hop is explicit MainActor cleanup in an observation callback.
Cmux Swift File And Package Boundaries ✅ Passed PASS: New production files are 248/306/164 lines; no oversized file grew, and the change stays split between app-specific state/projection and SwiftUI glue with tests.
Cmux Swiftpm Lockfiles ✅ Passed PR only wires new source/test files into project.pbxproj; no Package.resolved, .gitignore, workflow, or SwiftPM package-reference changes are in the diff.
Cmux Swift Logging ✅ Passed No new print/debugPrint/NSLog/Logger usage appears in the touched production Swift files; only existing comment text and tests mention logging.
Cmux User-Facing Error Privacy ✅ Passed The PR only adds sidebar status/summary text and per-pane row UI; no user-facing errors, alerts, command output, or recovery copy expose sensitive details.
Cmux Swiftui State Layout ✅ Passed PASS: the new sidebar rows use immutable snapshots/closures, the runtime model is @Observable, and no new GeometryReader, store refs, or render-time state writes were introduced.
Cmux Architecture Rethink ✅ Passed PASS: The diff adds explicit per-panel status ownership and snapshot projection, with no sleeps, locks, polling, or split lifecycle repair path; state stays owned by Workspace/runtime model.
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed Diff only adds sidebar row/views and tests; no new NSWindow/NSPanel/WindowGroup/WindowController or cmux.* auxiliary-window identifiers, so the rule doesn’t apply.
Cmux Source Artifacts ✅ Passed Changed paths are source/test/localization/config only; no artifact, cache, build, temp, or scratch directories appeared.
Cmux No Test Or Debug Seam In Production Source ✅ Passed No added DEBUG/test-only seam appears in the production diff; the widened helpers are used by production sidebar/lifecycle logic, not tests.
Cmux No Ambient Global State ✅ Passed No new file-scope API, singleton, or global mutable state was introduced; the PR keeps state in Workspace/Observable types and only adjusts lifecycle cleanup and row sorting.
Title check ✅ Passed The title clearly summarizes the primary change: adding one sidebar status row for each agent pane.
Description check ✅ Passed The description provides a detailed summary and testing evidence, but it omits the template checklist and demo video details.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat-per-agent-status

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.

@lawrencecchen

ghost commented Jul 8, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

Comment thread Sources/Workspace+PanelLifecycle.swift Outdated

ghost left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 17a5e5255a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

// slot is last-write-wins per key, so "no change" there can still
// be a change for this panel's own row.
if let panelId = panelID {
tab.recordPanelStatusEntry(entry, panelId: panelId)

ghost Jul 8, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Skip unchanged panel-scoped status writes

When the same agent pane re-emits an identical status (for example, hooks repeatedly send set_status codex Running --panel=<same>), this unconditional write stores a fresh SidebarStatusEntry(timestamp: Date()) before the duplicate check below runs. Since statusEntriesByPanelId equality includes that timestamp, the setter notifies and the sidebar rows can re-render/re-sort even though nothing visible changed, defeating the existing duplicate suppression; compare against the current panel entry before recording it.

Useful? React with 👍 / 👎.

ghost Jul 8, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9c95eb5: recordPanelStatusEntry now reuses TerminalController.shouldReplaceStatusEntry, so identical repeats (timestamp-only changes) are dropped before any observation write. Regression test: testIdenticalPanelStatusReportDoesNotReplaceStoredEntry.

— Claude Code

Comment thread Sources/Workspace+PanelLifecycle.swift Outdated
Comment thread Sources/ContentView.swift Outdated
@greptile-apps

ghost commented Jul 8, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds per-pane agent status rows to the sidebar. The main changes are:

  • Panel-scoped status storage for agent rows.
  • CLI and control-socket support for panel status, PID ownership, and lifecycle updates.
  • Sidebar rendering for one row per live agent pane.
  • Persistence, cleanup, and transfer handling for row state.

Confidence Score: 4/5

This is close, but the pane-move lifecycle gap should be fixed before merging.

  • Moving an agent pane can still drop needsInput or idle row state.
  • The transfer path restores row text and PID state, but not the lifecycle map used by the sidebar.
  • Session restore handles lifecycle data, while detached pane transfer still lacks the matching state handoff.

Sources/Workspace+DetachedSurfaceTransfer.swift; Sources/Workspace+PanelLifecycle.swift

Important Files Changed

Filename Overview
Sources/Workspace+DetachedSurfaceTransfer.swift Adds dynamic row-key transfer state, but still omits lifecycle values needed for moved agent rows.
Sources/Workspace+PanelLifecycle.swift Updates agent runtime extraction and adoption for panel-scoped status, but the move path still does not restore lifecycle state.
Sources/Workspace+SidebarAgentStatus.swift Adds the sidebar row projection and session persistence for panel-scoped agent status and lifecycle data.

Reviews (39): Last reviewed commit: "Fix covariant Self in allFlags stored-pr..." | Re-trigger Greptile

Comment thread Sources/Workspace+PanelLifecycle.swift Outdated
Comment on lines +361 to +364
for (panelId, entries) in statusEntriesByPanelId where panels[panelId] != nil {
for statusKey in entries.keys where Self.structuredAgentHookStatusKeys.contains(statusKey) {
statusKeysByPanel[panelId, default: []].insert(statusKey)
}

ghost Jul 8, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Unowned Panel Status Rows

When set_status includes a panel but omits --pid, the upsert path still records a panel-scoped entry. This loop then treats that entry as enough to create an agent row, so a pane can show a live-agent status row with no recorded PID or lifecycle owner to clear it when the process exits.

Rule Used: Flag correctness-critical detection/identity deriv... (source)

ghost Jul 8, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9c95eb5: sidebarAgentStatusRows() now seeds rows exclusively from recorded agent PID ownership (agentPIDPanelIdsByKey), so a panel-scoped entry without a PID owner can no longer create a row. Regression test: testPanelEntryWithoutAgentPIDDoesNotCreateRow.

— Claude Code

ghost Jul 8, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correction to my earlier reply: PID-only seeding was reverted in ba8ab51 because real Claude hooks share one bare PID key across panes (ownership migrates to the last reporter), which collapsed the rows this PR exists to split. Rows now come from the union of PID ownership and live panels holding a panel-scoped structured entry (statusEntriesByPanelId is only populated for structured agent hook status keys, and entries are dropped on clear_status, clearAgentPID(clearStatus: true), and pane close, so unowned rows stay bounded to a live pane's own report). Regression test: testBareSharedPIDKeyHookSequenceKeepsBothPanesRows.

— Claude Code

nonisolated func controlSidebarScheduleStatusClear(target: ControlSidebarTabTarget, key: String) {
controlSidebarScheduleMutation(target: target) { _, tab in
_ = tab.statusEntries.removeValue(forKey: key)
_ = tab.clearPanelStatusEntries(statusKey: key)

ghost Jul 8, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Shared Key Clears Every Pane

clear_status <key> has only a workspace/tab target, but panel-scoped rows are now per pane. If one same-type agent clears claude_code, this call removes that key from every panel in the workspace, so other running panes lose their panel-specific row text even though their agents are still active.

Rule Used: Flag correctness-critical detection/identity deriv... (source)

ghost Jul 8, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

clear_status is workspace/tab-scoped by protocol: ControlSidebarContext.controlSidebarScheduleStatusClear (CmuxControlSocket) carries no panel id. Pre-PR the same command removed the one shared pill for every pane, so clearing the per-pane copies mirrors the existing workspace-scoped semantics, and rows themselves survive via PID ownership + lifecycle. A pane-scoped clear needs a protocol + CLI verb extension, which is out of scope for this PR; flagged as a follow-up.

— Claude Code

ghost Jul 8, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intended contract: clear_status is the workspace-scoped user command and by contract removes the key from the whole workspace, including every pane's panel-scoped copy (see the doc comment on controlSidebarScheduleStatusClear). Agent hooks never send it; panel-scoped cleanup routes through clear_agent_pid --panel= --clear-status, which cannot touch sibling panes' rows.

— Claude Code

guard let runtimeState else { return }
for (statusKey, statusEntry) in runtimeState.statusEntries {
statusEntries[statusKey] = statusEntry
recordPanelStatusEntry(statusEntry, panelId: runtimeState.panelId)

ghost Jul 8, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Moved Status Keeps Old Panel

Detached runtime adoption stores the panel-scoped status under runtimeState.panelId. When a move creates or uses a different panel UUID in the destination workspace, later lookups use the destination panel ID and miss this entry, so the moved agent can fall back to ambiguous workspace text or lose its per-pane status.

Rule Used: Flag correctness-critical detection/identity deriv... (source)

ghost Jul 8, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The entry is stored under the same runtimeState.panelId that agentPIDs/agentPIDKeys ownership adoption already uses (pre-existing contract, unchanged by this PR): panel UUIDs are preserved across transfer. If a destination re-keyed panels, the runtime PIDs would already be orphaned; the panel-scoped entry follows exactly the same identity rule, so no new failure mode is introduced.

— Claude Code

ghost Jul 8, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a fair correction — since agentPIDs/agentPIDKeys adoption already relies on runtimeState.panelId being stable across transfer, the panel-scoped status entry is following the same established contract. Any UUID mismatch would have already orphaned PID tracking before this PR. Withdrawing the concern.

ghost Jul 8, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pane moves carry the panel UUID with the transferred surface, so the adoption keyed by runtimeState.panelId re-binds correctly; if a future path ever re-keys panels mid-move, the entry is dropped at source pane close, so it stays bounded rather than leaking.

— Claude Code

Review fixes:
- recordPanelStatusEntry reuses TerminalController.shouldReplaceStatusEntry
  so identical agent heartbeats (timestamp-only changes) no longer invalidate
  the sidebar snapshot on every report.
- Agent status rows are keyed off recorded agent PID ownership only, so a
  bare panel-scoped text report cannot create an unowned row or keep a stale
  one alive after clear_agent_pid without --clear-status.
- agentRuntimeState only attributes the workspace-level last-write-wins entry
  to a panel when no other pane can own the status key, so a transferred pane
  no longer adopts a sibling agent's status text.
- Shared-key rows no longer inherit the last writer's icon/color decorations.
- URL-backed agent rows mirror SidebarMetadataEntryRow: underlined button
  that focuses the pane and opens the URL.

workflow-guard-tests (Swift file length budget) fix by splitting:
- Sources/Workspace+SidebarAgentStatus.swift: SidebarAgentStatusRow, panel
  status storage, visibility filtering, and the per-agent row projection.
- Sources/SidebarStatusRowViews.swift: SidebarMetadataRows and
  SidebarAgentStatusRows view families moved out of ContentView.swift.

Tests: identical-heartbeat dedupe, ambiguous transfer attribution, and
unowned/stale row gating added to PerAgentSidebarStatusRowTests.
Comment on lines 80 to 81
_ = tab.clearPanelStatusEntries(statusKey: key)
tab.clearAgentPID(key: key)

ghost Jul 8, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Shared Clear Scope
clear_status is still scoped only to the workspace or tab, but this path now deletes every panel-scoped entry for the key and then clears PID ownership without a panel filter. When two live panes both own claude_code, a clear from one pane removes the other pane's statusEntriesByPanelId value and can drop the ownership mapping that sidebarAgentStatusRows() uses to build that row. The other running pane then loses its agent status row/text even though its agent is still active. The clear needs to use the emitting panel when that context exists, or avoid deleting other panels' panel-scoped row state.

ghost Jul 8, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rejecting with emitter evidence: no agent lifecycle path sends bare clear_status. Every built-in agent cleanup goes through clear_agent_pid --panel= --clear-status (CLI/cmux.swift:24719, 24782, 30518), which routes to the panel-scoped clearAgentPID: it refuses when another panel owns the key and clears only that panel's entry. Bare clear_status is the workspace-scoped user command; removing the key from every pane is its documented contract (leaving sibling panel copies would make the user command appear to fail). Documented inline at the clear path in b76ad3d.

— Claude Code

ghost Jul 8, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a convincing distinction — the two clear paths have clearly different contracts and the routing keeps them isolated. Withdrawing the concern; the documented workspace-scoped semantics for bare clear_status make the all-panel clear correct by design.

Comment thread Sources/Workspace+SidebarAgentStatus.swift
Comment thread Sources/SidebarStatusRowViews.swift Outdated
lawrencecchen added 2 commits July 7, 2026 18:47
… sessions

A panel can hold several PID keys for one base status key (two sessions of
the same agent type); clearAgentPID(clearStatus: true) now drops the panel-
scoped entry only when no remaining key on that panel maps to the status key,
so a stale session's cleanup cannot erase a live session's row. Mirrors the
existing hasAgentRuntime gate that protects the workspace-level entry.

Test: testStaleSessionCleanupKeepsLiveSameKeySessionEntry.
Comment thread Sources/Workspace+SidebarAgentStatus.swift Outdated
Comment thread Sources/Workspace+SidebarAgentStatus.swift
…write

recordAgentPID evicts stale structured runtimes on the panel (agent
replacement in the same pane), and that cleanup clears their panel-scoped
status entries. The upsert previously wrote the new panel-scoped entry first,
so the eviction deleted the just-recorded row text. The upsert now updates
the workspace slot (guarded), records PID ownership, then writes the
panel-scoped copy last.

Test: testAgentReplacementOnSamePanelKeepsFreshPanelEntry.

ghost left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Sources/Sidebar/SidebarWorkspaceSnapshotRefreshPolicy.swift`:
- Line 37: Add test coverage for `agentStatusRows` retention in
`SidebarWorkspaceSnapshotRefreshPolicyTests`; the current helper always uses an
empty array, so it never verifies `applyingContextMenuImmediateFields` preserves
non-empty `agentStatusRows` from `self`. Update the test setup alongside
`contextMenuPinChangeUpdatesDisplayedFieldsAndDefersNoisyFields` to seed
`agentStatusRows` with data and assert the result keeps `self.agentStatusRows`
unchanged, similar to the existing `metadataEntries` and `listeningPorts`
checks.

In `@Sources/SidebarStatusRowViews.swift`:
- Around line 98-112: Validate the agent-provided URL before calling
NSWorkspace.shared.open in the row action handling, since row.url can point to
unsafe schemes. Update the Button action in SidebarStatusRowViews (and the other
open-url call site noted in the review) to allow only approved schemes such as
http and https, optionally mailto, and fall back to focusing the panel or
showing no open action when the URL is absent or disallowed. Keep the validation
close to the existing row.url / entry.url handling so the unsafe open path is
prevented wherever the URL is used.
- Around line 216-236: The icon parsing in iconView is duplicated across
SidebarAgentStatusEntryRow and SidebarMetadataEntryRow, so extract the shared
emoji:/text:/sf: handling into a common helper to keep behavior consistent. Add
a reusable renderer or parsing helper near these row views and have both
iconView properties delegate to it, preserving the same fallback to
CmuxSystemSymbolImage and the same empty-value guards.
- Around line 216-236: Avoid AnyView type erasure in iconView; it is hurting
SwiftUI identity and row diffing. Refactor the SidebarStatusRowViews.iconView
property to use `@ViewBuilder` and return some View instead of AnyView, preserving
the existing emoji/text/sf symbol branches with conditional view composition.
Update the caller in the row view to consume the builder output directly (no
optional AnyView unwrapping), so empty cases render as EmptyView while keeping
efficient SwiftUI updates.
- Around line 140-149: The displayText property in SidebarStatusRowViews
currently builds the name-and-lifecycle string with plain interpolation, so
replace the "\(name): \(lifecycleText)" path with a localized template that lets
translators control ordering and punctuation. Update displayText to use the new
localization key sidebar.agentStatus.displayNameWithLifecycle when lifecycleText
is present, and add that entry to Localizable.xcstrings with per-locale
translations so the formatting can adapt correctly.

In `@Sources/Workspace`+PanelLifecycle.swift:
- Line 410: Clear the workspace-level status when a panel-only status owner
closes. The cleanup in `discardAgentRuntimeState`/panel teardown currently
removes `statusEntriesByPanelId` but leaves stale `statusEntries` when the
structured status had no tracked PID, so update the panel-close path around
`statusEntriesByPanelId.removeValue(forKey:)` to also clear the corresponding
workspace entry in `tab.statusEntries` (or route it through a shared cleanup
helper used by `controlSidebarScheduleStatusUpsert` and
`discardAgentRuntimeState`) whenever the panel-scoped owner is the sole source
of that status.

In `@Sources/Workspace`+SidebarAgentStatus.swift:
- Around line 187-198: The SidebarAgentStatusRow construction in
Workspace+SidebarAgentStatus is still inheriting workspaceEntry priority and
timestamp when soleOwner is false, which lets ambiguous multi-pane rows use
workspace-level freshness for ordering. Update the priority and timestamp
fallbacks in the row builder so they only use workspaceEntry values when
soleOwner is true, otherwise rely on the row’s own entry or neutral defaults.
Keep the existing entry/soleOwner logic around value, icon, and color consistent
with this change.
- Around line 92-139: The structured status filtering in
Workspace+SidebarAgentStatus is still allowing keys that are already represented
by agent rows to remain in the flat metadata list. Update
sidebarStatusEntriesVisibleForDisplay and, if needed,
visibleStructuredAgentStatusKeysByPanel so that structured keys mapped to a
panel in agentPIDPanelIdsByKey are excluded from the legacy workspace-level
pills, while unscoped/legacy keys without a panel mapping continue to pass
through. Keep the per-panel winner selection in place, but return only the keys
that should not be rendered as agent rows.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 4b05b515-60e9-4ef9-928f-334ac31b3b58

📥 Commits

Reviewing files that changed from the base of the PR and between d879c53 and 7e83b71.

📒 Files selected for processing (11)
  • Resources/Localizable.xcstrings
  • Sources/ContentView.swift
  • Sources/Sidebar/SidebarWorkspaceSnapshotRefreshPolicy.swift
  • Sources/SidebarStatusRowViews.swift
  • Sources/TerminalController+ControlSidebarContext.swift
  • Sources/Workspace+PanelLifecycle.swift
  • Sources/Workspace+SidebarAgentStatus.swift
  • Sources/WorkspaceSidebarAgentRuntimeObservationModel.swift
  • cmux.xcodeproj/project.pbxproj
  • cmuxTests/PerAgentSidebarStatusRowTests.swift
  • cmuxTests/SidebarWorkspaceSnapshotRefreshPolicyTests.swift

Comment thread Sources/Sidebar/SidebarWorkspaceSnapshotRefreshPolicy.swift
Comment thread Sources/SidebarStatusRowViews.swift Outdated
Comment on lines +98 to +112
if let url = row.url {
Button {
onFocusPanel(row.panelId)
NSWorkspace.shared.open(url)
} label: {
rowContent(underlined: true)
}
.buttonStyle(.plain)
.safeHelp(url.absoluteString)
} else {
rowContent(underlined: false)
.contentShape(Rectangle())
.onTapGesture { onFocusPanel(row.panelId) }
.safeHelp(helpText)
}

ghost Jul 8, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Validate URL scheme before NSWorkspace.shared.open.

Agent-provided URLs (from row.url / entry.url) are opened directly via NSWorkspace.shared.open without scheme validation. A malicious or buggy agent could set file://, ftp://, or other schemes that open arbitrary files or applications. Consider allowlisting http/https (and optionally mailto) before opening.

🛡️ Proposed fix for both call sites
 // In SidebarAgentStatusEntryRow.body (line ~98)
 if let url = row.url {
     Button {
         onFocusPanel(row.panelId)
-        NSWorkspace.shared.open(url)
+        if let scheme = url.scheme?.lowercased(),
           ["http", "https", "mailto"].contains(scheme) {
+            NSWorkspace.shared.open(url)
+        }
     } label: {
 // In SidebarMetadataEntryRow.body (line ~248)
 if let url = entry.url {
     Button {
         onFocus()
-        NSWorkspace.shared.open(url)
+        if let scheme = url.scheme?.lowercased(),
+           ["http", "https", "mailto"].contains(scheme) {
+            NSWorkspace.shared.open(url)
+        }
     } label: {

Also applies to: 247-262

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/SidebarStatusRowViews.swift` around lines 98 - 112, Validate the
agent-provided URL before calling NSWorkspace.shared.open in the row action
handling, since row.url can point to unsafe schemes. Update the Button action in
SidebarStatusRowViews (and the other open-url call site noted in the review) to
allow only approved schemes such as http and https, optionally mailto, and fall
back to focusing the panel or showing no open action when the URL is absent or
disallowed. Keep the validation close to the existing row.url / entry.url
handling so the unsafe open path is prevented wherever the URL is used.

Comment thread Sources/SidebarStatusRowViews.swift Outdated
Comment thread Sources/SidebarStatusRowViews.swift Outdated
Comment on lines +216 to +236
private var iconView: AnyView? {
guard let iconRaw = effectiveIcon else { return nil }
if iconRaw.hasPrefix("emoji:") {
let value = String(iconRaw.dropFirst("emoji:".count))
guard !value.isEmpty else { return nil }
return AnyView(Text(value).cmuxFont(size: 9 * fontScale))
}
if iconRaw.hasPrefix("text:") {
let value = String(iconRaw.dropFirst("text:".count))
guard !value.isEmpty else { return nil }
return AnyView(Text(value).cmuxFont(size: 8 * fontScale, weight: .semibold))
}
let symbolName: String
if iconRaw.hasPrefix("sf:") {
symbolName = String(iconRaw.dropFirst("sf:".count))
} else {
symbolName = iconRaw
}
guard !symbolName.isEmpty else { return nil }
return AnyView(CmuxSystemSymbolImage(magnified: symbolName, pointSize: 8 * fontScale, weight: .medium))
}

ghost Jul 8, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract shared icon-parsing logic to eliminate duplication.

The iconView property is nearly identical in SidebarAgentStatusEntryRow (lines 216–236) and SidebarMetadataEntryRow (lines 293–316) — same emoji:/text:/sf: prefix parsing, same CmuxSystemSymbolImage fallback. Extract a shared helper to keep them from diverging.

♻️ Proposed shared helper
/// Shared icon rendering for sidebar rows.
`@ViewBuilder`
enum SidebarRowIcon {
    static func make(iconRaw: String, fontScale: CGFloat) -> some View {
        if iconRaw.hasPrefix("emoji:") {
            let value = String(iconRaw.dropFirst("emoji:".count))
            if !value.isEmpty {
                Text(value).cmuxFont(size: 9 * fontScale)
            }
        } else if iconRaw.hasPrefix("text:") {
            let value = String(iconRaw.dropFirst("text:".count))
            if !value.isEmpty {
                Text(value).cmuxFont(size: 8 * fontScale, weight: .semibold)
            }
        } else {
            let symbolName = iconRaw.hasPrefix("sf:")
                ? String(iconRaw.dropFirst("sf:".count))
                : iconRaw
            if !symbolName.isEmpty {
                CmuxSystemSymbolImage(magnified: symbolName, pointSize: 8 * fontScale, weight: .medium)
            }
        }
    }
}

Then each iconView becomes:

`@ViewBuilder`
private var iconView: some View {
    if let iconRaw = effectiveIcon?.trimmingCharacters(in: .whitespacesAndNewlines),
       !iconRaw.isEmpty {
        SidebarRowIcon.make(iconRaw: iconRaw, fontScale: fontScale)
    }
}

Also applies to: 293-316

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/SidebarStatusRowViews.swift` around lines 216 - 236, The icon parsing
in iconView is duplicated across SidebarAgentStatusEntryRow and
SidebarMetadataEntryRow, so extract the shared emoji:/text:/sf: handling into a
common helper to keep behavior consistent. Add a reusable renderer or parsing
helper near these row views and have both iconView properties delegate to it,
preserving the same fallback to CmuxSystemSymbolImage and the same empty-value
guards.

🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Avoid AnyView type erasure in sidebar row views.

AnyView breaks SwiftUI's view identity and prevents diffing optimizations. In a ForEach inside a LazyVStack sidebar, this causes unnecessary re-renders on every row invalidation. Use @ViewBuilder to return some View with _ConditionalContent instead.

⚡ Proposed refactor using `@ViewBuilder`
-    private var iconView: AnyView? {
-        guard let iconRaw = effectiveIcon else { return nil }
-        if iconRaw.hasPrefix("emoji:") {
-            let value = String(iconRaw.dropFirst("emoji:".count))
-            guard !value.isEmpty else { return nil }
-            return AnyView(Text(value).cmuxFont(size: 9 * fontScale))
-        }
-        if iconRaw.hasPrefix("text:") {
-            let value = String(iconRaw.dropFirst("text:".count))
-            guard !value.isEmpty else { return nil }
-            return AnyView(Text(value).cmuxFont(size: 8 * fontScale, weight: .semibold))
-        }
-        let symbolName: String
-        if iconRaw.hasPrefix("sf:") {
-            symbolName = String(iconRaw.dropFirst("sf:".count))
-        } else {
-            symbolName = iconRaw
-        }
-        guard !symbolName.isEmpty else { return nil }
-        return AnyView(CmuxSystemSymbolImage(magnified: symbolName, pointSize: 8 * fontScale, weight: .medium))
-    }
+    `@ViewBuilder`
+    private var iconView: some View {
+        if let iconRaw = effectiveIcon {
+            SidebarRowIcon.make(iconRaw: iconRaw, fontScale: fontScale)
+        }
+    }

The caller at line 119 changes from if let icon = iconView { to:

-            if let icon = iconView {
-                icon
-                    .foregroundColor(foregroundColor.opacity(0.95))
-            }
+            iconView
+                .foregroundColor(foregroundColor.opacity(0.95))

@ViewBuilder renders EmptyView when the if let is nil, so no optional unwrapping is needed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/SidebarStatusRowViews.swift` around lines 216 - 236, Avoid AnyView
type erasure in iconView; it is hurting SwiftUI identity and row diffing.
Refactor the SidebarStatusRowViews.iconView property to use `@ViewBuilder` and
return some View instead of AnyView, preserving the existing emoji/text/sf
symbol branches with conditional view composition. Update the caller in the row
view to consume the builder output directly (no optional AnyView unwrapping), so
empty cases render as EmptyView while keeping efficient SwiftUI updates.

Comment thread Sources/Workspace+PanelLifecycle.swift
Comment thread Sources/Workspace+SidebarAgentStatus.swift
Comment thread Sources/Workspace+SidebarAgentStatus.swift Outdated
…tries

Claude Code hooks share ONE bare PID key per agent type across all panes of
a workspace (set_agent_pid claude_code <pid>), so bare-key ownership migrates
to the last reporting pane. Seeding rows from PID ownership alone made every
other pane lose its row, reintroducing the exact collapse this PR fixes
(reproduced live with two panes running the real hook sequence).

sidebarAgentStatusRows() now derives candidate (panel, statusKey) pairs from
the union of recorded PID ownership and live panels holding a panel-scoped
structured entry. Stale entries stay bounded: clear_status,
clearAgentPID(clearStatus: true), and pane close all drop panel-scoped
entries.

Regression test: testBareSharedPIDKeyHookSequenceKeepsBothPanesRows
simulates the bare-key two-pane hook sequence end-to-end at the Workspace
level.
Comment on lines +160 to +164
for (panelId, entries) in statusEntriesByPanelId where panels[panelId] != nil {
for statusKey in entries.keys where Self.structuredAgentHookStatusKeys.contains(statusKey) {
statusKeysByPanel[panelId, default: []].insert(statusKey)
}
}

ghost Jul 8, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Unowned rows persist

This still lets set_status with a panel but without --pid create an agent row. The upsert path can store a panel-scoped entry without recording PID ownership, and this loop treats that entry as enough to seed a row while the pane remains open. If that agent exits without sending clear_status, the stale PID sweep cannot clean the row because there is no PID key to sweep, so the sidebar can keep showing a live-looking agent status for a dead process.

Rule Used: Flag correctness-critical detection/identity deriv... (source)

ghost Jul 8, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the tradeoff consciously taken in ba8ab51: strict PID-only seeding broke the primary feature scenario (real Claude hooks share ONE bare claude_code PID key across panes, ownership migrates to the last reporter), so rows seed from the union of PID ownership and live panel entries. A set_status --panel without --pid can keep a row until the pane closes or a clear arrives; that residue is bounded to the open pane, and as of b76ad3d pane close also sweeps the workspace-level slot when the closed pane was the key's last owner.

— Claude Code

ghost Jul 8, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Understood — the bounded residue (open pane only, swept on close) is an acceptable tradeoff given that strict PID-only seeding breaks the primary Claude hooks use case where a single bare PID key migrates across panes. The docblock on sidebarAgentStatusRows() already captures this reasoning, so the intent is clear. I'll withdraw the concern.

ghost Jul 8, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deliberate design, documented on sidebarAgentStatusRows(): panel-scoped entries keep a pane's row alive independently of pid ownership because bare shared keys migrate ownership to the last reporter. Staleness is bounded: clear_status, clearAgentPID(clearStatus: true), agent replacement on the pane (as of 2cde0e4), and pane close all drop panel-scoped entries. An agent that reports set_status --panel without --pid and exits without clear_status leaves its final status visible on purpose (same as workspace-level set_status always has); pid-based liveness requires the pid the caller chose not to provide.

— Claude Code

The app-host unit test lane builds the app as "cmux DEV" (module cmux_DEV),
so a bare `@testable import cmux` fails module scanning there ("Unable to
find module dependency: 'cmux'", all four shards red). Use the same
canImport(cmux_DEV) conditional import every other wired cmuxTests file
uses.
Comment on lines +160 to +163
for (panelId, entries) in statusEntriesByPanelId where panels[panelId] != nil {
for statusKey in entries.keys where Self.structuredAgentHookStatusKeys.contains(statusKey) {
statusKeysByPanel[panelId, default: []].insert(statusKey)
}

ghost Jul 8, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Unowned rows persist

This loop lets a panel-scoped structured status create an agent row even when no PID owner was recorded. A caller can send set_status with a panel and no --pid; the upsert path records statusEntriesByPanelId, and this branch then renders it as a live agent row. Since no PID owner exists, PID-exit cleanup cannot remove that row, so the pane can keep showing stale agent status until the pane closes or an explicit clear arrives.

Rule Used: Flag correctness-critical detection/identity deriv... (source)

ghost Jul 8, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Settled in ba8ab51 (see the sibling thread): PID-only seeding was tried in 9c95eb5 and reverted because shared bare PID keys migrate ownership to the last reporter, which erased still-running sibling panes' rows. The panel-entry seed is required for correctness of the primary scenario; the no-PID residue is bounded to the pane's lifetime (pane close, clear_status, and clear_agent_pid --clear-status all drop it, and b76ad3d also sweeps the workspace slot on close).

— Claude Code

ghost Jul 8, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See the reply on the sibling thread: intended, documented, and bounded (clear paths + replacement cleanup + pane close). Liveness sweeping needs a pid; a caller omitting --pid opts into explicit-clear semantics.

— Claude Code

Comment thread Sources/Workspace+SidebarAgentStatus.swift
…ing)

- Split SidebarStatusRowViews.swift into SidebarMetadataRowViews.swift and
  SidebarAgentStatusRowViews.swift so each new file owns one major view type
  (plus its private entry row).
- Convert PerAgentSidebarStatusRowTests to Swift Testing (@Test/#expect/
  #require, @mainactor struct suite), matching the repo policy for new
  non-UI tests. Suite remains discovered by the app-host shard planner
  (cmux_unit_test_shard.py --validate passes, suite listed with weight 20).
Comment on lines +160 to +163
for (panelId, entries) in statusEntriesByPanelId where panels[panelId] != nil {
for statusKey in entries.keys where Self.structuredAgentHookStatusKeys.contains(statusKey) {
statusKeysByPanel[panelId, default: []].insert(statusKey)
}

ghost Jul 8, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Unowned rows persist

A structured set_status can still create a live-looking agent row without an owning runtime. The upsert path records a panel-scoped entry whenever a panel id is present, even when no --pid was supplied, and this loop then uses that entry as a row seed. If the reporting process exits without sending clear_status, the stale PID sweep cannot clean the row because no PID key was recorded, so the sidebar can keep showing the old agent status until the pane closes.

Rule Used: Flag correctness-critical detection/identity deriv... (source)

ghost Jul 8, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same settled decision as the two sibling threads (ba8ab51): the union seed is deliberate because bare shared PID keys migrate ownership; without the panel-entry seed, live sibling agents lose their rows, which is worse than a bounded stale row in a still-open pane whose agent exited without clear_status.

— Claude Code

ghost Jul 8, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as the sibling threads: intended explicit-clear semantics for no-pid reports, staleness bounded by clear paths, same-pane agent replacement (2cde0e4), and pane close.

— Claude Code

nonisolated func controlSidebarScheduleStatusClear(target: ControlSidebarTabTarget, key: String) {
controlSidebarScheduleMutation(target: target) { _, tab in
_ = tab.statusEntries.removeValue(forKey: key)
_ = tab.clearPanelStatusEntries(statusKey: key)

ghost Jul 8, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Shared clear deletes siblings

clear_status still removes the panel-scoped copy for every pane that shares this structured key. With two live claude_code panes, one pane's clear_status claude_code removes all statusEntriesByPanelId[*][claude_code]; for the bare-key hook sequence, only the last reporter has PID ownership, so another pane can lose its row entirely. With qualified PID keys, the rows remain but their per-pane text, icon, and color are erased. The clear path needs a panel-scoped target when available, or it should avoid deleting other panes' panel status state.

Rule Used: Flag correctness-critical detection/identity deriv... (source)

ghost Jul 8, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same resolution as the earlier thread on this line: agent exits never send bare clear_status; hooks use clear_agent_pid --panel --clear-status (CLI/cmux.swift:24719, 24782, 30518), which is panel-scoped and cannot touch sibling panes. Bare clear_status is the workspace-wide user command by contract. Invariant now documented inline in b76ad3d.

— Claude Code

ghost Jul 8, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as the sibling thread: workspace-scoped clear_status intentionally clears every pane's copy by contract; hooks use the panel-scoped clear_agent_pid --panel path, which never touches sibling panes. The 2cde0e4 round strengthened this: displaced synthesized runtimes and lifecycles are now reaped too, so the whole-workspace clear is complete instead of partial.

— Claude Code

Comment thread Sources/Workspace+PanelLifecycle.swift

ghost left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Sources/SidebarAgentStatusRowViews.swift`:
- Around line 93-96: The URL-backed tooltip path in SidebarAgentStatusRowView is
omitting the pane label that helpText includes for non-URL rows, so update the
tooltip logic to preserve that same disambiguating context. In the view’s URL
branch, build the tooltip from the URL string plus the row’s paneLabel when
available, using the same helpText-style formatting already used by the helpText
computed property.
- Around line 158-178: The icon parsing in SidebarAgentStatusEntryRow.iconView
duplicates the same emoji:/text:/sf: handling already used in
SidebarMetadataEntryRow.iconView, so extract that shared logic into a common
helper or small parser type and have both row views call it. Keep the existing
prefix behavior, empty-string guards, and symbol/font sizing in the shared
implementation so the two views stay consistent and do not drift.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 25e5870e-2936-48c4-bb3a-dbbd75385f2c

📥 Commits

Reviewing files that changed from the base of the PR and between c3d2f47 and 7092c30.

📒 Files selected for processing (4)
  • Sources/SidebarAgentStatusRowViews.swift
  • Sources/SidebarMetadataRowViews.swift
  • cmux.xcodeproj/project.pbxproj
  • cmuxTests/PerAgentSidebarStatusRowTests.swift

Comment thread Sources/SidebarAgentStatusRowViews.swift Outdated
Comment thread Sources/SidebarAgentStatusRowViews.swift Outdated
lawrencecchen added 2 commits July 7, 2026 19:33
…values

Structured statuses moved out of the generic metadata renderer lost their
format: a set_status value with --format=markdown rendered as raw markdown
syntax in the per-agent row. SidebarAgentStatusRow now carries the reported
entry's format and the row view renders markdown values through the same
AttributedString(markdown:) inline path SidebarMetadataEntryRow uses;
lifecycle/name fallback text stays plain.

Test: testRowCarriesReportedValueFormat.
With more than one agent pane, the agent-rows block gets a disclosure
header showing an aggregate summary (agent count, needs-input count,
worst-state accent dot when collapsed). Clicking the header folds or
unfolds the per-agent rows. Single-agent workspaces render the row
directly with no accordion chrome.
@lawrencecchen

ghost commented Jul 8, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

ghost left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fcf43ddba3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

agentPIDProcessIdentities: agentPIDIdentitiesForPanel,
agentPIDKeys: pidKeys
agentPIDKeys: pidKeys,
dynamicAgentRowKeys: dynamicAgentRowKeys.intersection(statusEntriesForPanel.keys)

ghost Jul 10, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep dynamic row keys when transferring lifecycle-only agents

When a registered third-party agent is tracked with set_agent_pid <key> --panel plus set_agent_lifecycle <key> ... --panel but has not emitted a panel-scoped set_status, it can render in the source workspace from PID + lifecycle state. This line only carries dynamic row eligibility for keys that also have a status entry, so after a detach/move adoptDetachedAgentRuntimeState records the PID without re-registering the dynamic key and the destination treats it as an unregistered metadata key, dropping the row.

Useful? React with 👍 / 👎.

/// instead of degrading to the ambiguous workspace-level slot.
func panelScopedAgentStatusSnapshots(panelId: UUID) -> [SessionStatusEntrySnapshot]? {
let entries = (statusEntriesByPanelId[panelId] ?? [:]).values
.filter { Self.structuredAgentHookStatusKeys.contains($0.key) }

ghost Jul 10, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Persist registered dynamic agent row snapshots

For a registered dynamic agent row created by set_status <custom-key> ... --panel, the live entry is stored in statusEntriesByPanelId and included in the autosave fingerprint, but this filter writes only built-in structured keys to the session snapshot. After relaunch those custom rows are missing entirely (and their lifecycle is similarly filtered below), so pane-scoped third-party agent rows do not survive session restore even though built-in rows do.

Useful? React with 👍 / 👎.

lawrencecchen added 2 commits July 9, 2026 17:53
Key resolutions:
- Workspace lifecycle functions moved to main's new Workspace+AgentLifecycle.swift;
  ported dynamic-agent-row-key pruning into clearAgentLifecycle,
  clearAgentLifecycleStates, and clearAllAgentLifecycleStates there.
- SidebarWorkspaceSnapshotBuilder moved to its own file on main; added
  agentStatusRows field to the extracted Snapshot.
- FeatureFlags: sidebar-agent-rows-enabled-release appended at index 4 after
  main's cloud-vm and agent-chat flags.
- Kept main's extraction shape for sidebar test files; dropped this branch's
  duplicate SidebarSelectedWorkspaceScrollPolicyTests.swift (identical suite
  now lives inline in SidebarWorkspaceSnapshotRefreshPolicyTests.swift).
- Localizable.xcstrings merged at JSON level (37 branch keys applied onto
  main's catalog); budget tsv regenerated; pbxproj unioned + normalized.
# Conflicts:
#	.github/swift-file-length-budget.tsv
#	Resources/Localizable.xcstrings
#	Sources/ContentView.swift
#	Sources/SidebarWorkspaceSnapshotBuilder.swift
#	cmux.xcodeproj/project.pbxproj
/// an unrelated process" (same contract as `isRecordedAgentPIDLive`).
let agentPIDProcessIdentities: [String: AgentPIDProcessIdentity]
let agentPIDKeys: Set<String>
let dynamicAgentRowKeys: Set<String>

ghost Jul 10, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Lifecycle still drops

This transfer state still does not carry the panel lifecycle values. When a pane with an agent row in needsInput or idle is moved, extraction carries the status entry and PID data, then the source cleanup clears agentLifecycleStatesByPanelId[panelId]. The destination adoption restores the row text, but there is no lifecycle value to restore, so the moved row loses its state dot and summary count until another lifecycle hook arrives.

workflow-guard-tests rejects files that enter the tracked budget at >=500
lines. Pure moves, no behavior change:
- SidebarAgentStatusRowViews.swift -> graphite row views split into
  SidebarAgentGraphiteRowViews.swift
- PerAgentSidebarStatusRowTests.swift -> clearing/replacement/summary tests
  split into PerAgentSidebarStatusRowClearingTests.swift
- SidebarWorkspaceSnapshotRefreshPolicyTests.swift -> scroll-policy and
  row-interaction suites split into their own test files (matching the
  pre-revert extraction shape)
/// an unrelated process" (same contract as `isRecordedAgentPIDLive`).
let agentPIDProcessIdentities: [String: AgentPIDProcessIdentity]
let agentPIDKeys: Set<String>
let dynamicAgentRowKeys: Set<String>

ghost Jul 10, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Lifecycle still drops This transfer state still does not carry the panel lifecycle map. When a pane with an agent row in needsInput or idle is moved, the source cleanup removes agentLifecycleStatesByPanelId[panelId], while adoption restores the status text and row key without restoring the lifecycle value. The moved row can keep its text but lose the state dot and summary count until another lifecycle hook arrives. Please carry the panel lifecycle entries in the detached runtime state and adopt them with the panel-scoped status entries.

Comment thread CLI/cmux.swift
let lifecycle = rem2[1]
let allowed = ["running", "idle", "needsInput", "unknown"]
guard allowed.contains(lifecycle) else {
throw CLIError(message: "set-agent-lifecycle: invalid lifecycle '\(lifecycle)' (expected running, idle, needsInput, or unknown)")

ghost Jul 10, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CLI lifecycle token mismatch

Medium Severity

The new cmux set-agent-lifecycle path rejects lifecycle tokens that the control socket already accepts. forwardSetAgentLifecycleCommand checks an exact allowlist (needsInput, etc.) before forwarding, while set_agent_lifecycle normalizes input (case, underscores, and forms like needs-input). Scripts using common spellings get a CLI error even though the same command would succeed on the socket.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit c258544. Configure here.

/// an unrelated process" (same contract as `isRecordedAgentPIDLive`).
let agentPIDProcessIdentities: [String: AgentPIDProcessIdentity]
let agentPIDKeys: Set<String>
let dynamicAgentRowKeys: Set<String>

ghost Jul 10, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Lifecycle State Missing

This transfer state still has no field for the moved pane's agent lifecycle values. A pane move can carry the panel-scoped status entry into the destination workspace, but the source cleanup removes agentLifecycleStatesByPanelId[panelId] and adoptDetachedAgentRuntimeState only restores entries, dynamic keys, and PID ownership. When a pane showing needsInput or idle is moved, the row keeps its text but loses its state dot and summary count until another lifecycle hook arrives. Carry the panel lifecycle entries in this runtime state and restore them with the status entries.

Rule Used: Flag correctness-critical detection/identity deriv... (source)

Comment thread Sources/Workspace+PanelLifecycle.swift
# Conflicts:
#	.github/swift-file-length-budget.tsv
/// an unrelated process" (same contract as `isRecordedAgentPIDLive`).
let agentPIDProcessIdentities: [String: AgentPIDProcessIdentity]
let agentPIDKeys: Set<String>
let dynamicAgentRowKeys: Set<String>

ghost Jul 10, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Lifecycle Still Drops
The detached runtime payload now carries dynamic row keys, but it still has no field for the pane's lifecycle values. When a pane in needsInput or idle moves to another workspace or through the dock, the transfer can restore the row text, PID state, and dynamic row eligibility, while the source-side close clears agentLifecycleStatesByPanelId[panelId]. The destination then builds the row with a nil lifecycle, so the state dot and summary count disappear until another lifecycle hook arrives. Carry the panel lifecycle map in this transfer state and restore it with the panel-scoped status entries.

Rule Used: Flag correctness-critical detection/identity deriv... (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

lawrencecchen added 2 commits July 9, 2026 21:34
Main nested the struct inside Workspace (Workspace+DetachedSurfaceTransfer.swift);
the bare reference broke the test-target build on every app-host shard.
# Conflicts:
#	Resources/Localizable.xcstrings
#	Sources/FeatureFlags.swift
#	Sources/Workspace+PanelLifecycle.swift
/// an unrelated process" (same contract as `isRecordedAgentPIDLive`).
let agentPIDProcessIdentities: [String: AgentPIDProcessIdentity]
let agentPIDKeys: Set<String>
let dynamicAgentRowKeys: Set<String>

ghost Jul 10, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Lifecycle still drops

The detached runtime payload carries status entries, PIDs, and dynamic row keys, but it still does not carry the pane's lifecycle values. During a pane move, agentRuntimeState(forPanelId:) captures this payload before discardClosedPanelLifecycleState clears agentLifecycleStatesByPanelId[panelId]; the destination then restores the row text in adoptDetachedAgentRuntimeState without restoring whether the row was needsInput or idle. A moved agent pane can keep its status text while losing its state dot and summary count until another hook fires. Please carry the panel lifecycle map with this transfer state and restore it with the panel-scoped status entries.

Merge left the new flag entry referencing Self.sidebarAgentRowsDefault inside
the allFlags closure initializer; spell the class name like the sibling
entries.
@@ -14,6 +14,7 @@ extension Workspace {
/// an unrelated process" (same contract as `isRecordedAgentPIDLive`).
let agentPIDProcessIdentities: [String: AgentPIDProcessIdentity]
let agentPIDKeys: Set<String>

ghost Jul 10, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Lifecycle Still Drops

This transfer state still omits the pane's agent lifecycle values. During a pane move, the runtime transfer can restore the row text, PID state, and dynamic row eligibility, but the source cleanup clears agentLifecycleStatesByPanelId[panelId] and the destination never writes it back. A moved row that was needsInput or idle can keep its status text while losing the state dot and summary count until another lifecycle hook arrives. Carry the panel lifecycle map in this payload and restore it with the panel-scoped status entries.

Rule Used: Flag correctness-critical detection/identity deriv... (source)

ghost left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

There are 4 total unresolved issues (including 3 from previous reviews).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 32fae4c. Configure here.

if let panelId = panelID, !tab.panels.keys.contains(panelId) {
return
}
guard Self.shouldReplaceStatusEntry(

ghost Jul 10, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale panel drops workspace status

Medium Severity

When set_status includes a --panel id that is not in the target workspace, the scheduled upsert returns before updating anything, yet the socket path still replies OK. Workspace-level statusEntries are skipped along with the panel-scoped copy, so hooks with a stale or wrong surface id lose the update silently.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 32fae4c. Configure here.

* test: cover agent row surface names

* Show surface names in agent rows
austinpower1258 added 2 commits August 24, 2026 20:53
Add a regression test proving that claiming an unchanged auto-generated surface name emits the sidebar observation update and renders the combined row label.
Include custom-title provenance in the sidebar observation snapshot so a same-text auto-to-user claim refreshes the visible agent row immediately.

This branch was successfully deployed

1 active deployment
Preview – cmux — 403717e4 Deployed Aug 25, 2026 by vercel[bot]
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.

Shared-workspace agent summaries for multi-agent workflows

3 participants