Skip to content

browser: expose webview lifecycle state in top - #4243

Merged
austinywang merged 5 commits into
manaflow-ai:mainfrom
lidge-jun:codex/browser-lifecycle-observability
May 18, 2026
Merged

austinywang merged 5 commits into
manaflow-ai:mainfrom
lidge-jun:codex/browser-lifecycle-observability

Conversation

@lidge-jun

@lidge-jun lidge-jun commented May 16, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • Track browser WKWebView lifecycle state as new_tab, live_visible, live_hidden, or closing.
  • Record browser portal/view visibility timestamps and reasons without resetting hidden duration on duplicate hide events.
  • Include lifecycle data in cmux top browser webview payloads for memory diagnostics.
  • Reset lifecycle metadata when a browser panel is reused for a fresh workspace context.
  • Add focused BrowserPanel lifecycle unit coverage.

Why

Hidden browser tabs/workspaces currently keep their WKWebView and WebContent process alive, but cmux top does not distinguish visible webviews from hidden retained ones. This PR adds the observability needed before adding discard/reclaim policy in follow-up PRs.

Review follow-up

  • Addressed duplicate hide events resetting hidden_duration_ms.
  • Deferred portal update lifecycle recording out of NSViewRepresentable render/update.
  • Reset lifecycle metadata during workspace context reset.
  • Removed test double-close.

Validation

  • git diff --check
  • swiftc -parse Sources/Panels/BrowserPanel.swift Sources/Panels/BrowserPanelView.swift Sources/TerminalController.swift cmuxTests/GhosttyConfigTests.swift

Not run: ./scripts/test-unit.sh -only-testing:cmuxTests/BrowserPanelWebViewLifecycleTests because this machine has only Command Line Tools selected and no Xcode.app install; xcodebuild exits with: tool xcodebuild requires Xcode.

Summary by CodeRabbit

  • New Features

    • Browser panel now persists WebView lifecycle states (new, visible, hidden, closing) with timestamped visibility transitions and computed hidden duration.
    • Lifecycle state and payload are included in workspace/browser surface telemetry.
  • Behavior

    • Panel view and portal interactions now report UI visibility changes; hiding the portal records a visibility transition.
  • Tests

    • Added tests verifying lifecycle transitions, payload contents, and closing behavior.

Review Change Stack

@vercel

vercel Bot commented May 16, 2026

Copy link
Copy Markdown

@lidge-jun is attempting to deploy a commit to the Manaflow Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai

coderabbitai Bot commented May 16, 2026 •

Copy link
Copy Markdown

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

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: fc9d1b0a-9fdc-4d47-8538-fba49f4139fb

📥 Commits

Reviewing files that changed from the base of the PR and between 9550f84 and 23ec6bc.

📒 Files selected for processing (1)
  • Sources/Panels/BrowserPanel.swift

📝 Walkthrough

Walkthrough

Adds persistent WebView lifecycle tracking to BrowserPanel (new enum, visibility metadata, payload export), observes UI and portal visibility in BrowserPanelView, reports lifecycle data in TerminalController workspace payloads, and adds unit tests for lifecycle transitions.

Changes

WebView Lifecycle State Tracking

Layer / File(s) Summary
Lifecycle State Model & Properties
Sources/Panels/BrowserPanel.swift
Introduces BrowserWebViewLifecycleState and adds published lifecycle state, last-visibility timestamps/reason, closing flag, and a didSet on shouldRenderWebView to refresh lifecycle state.
Visibility Recording & Payload Reporting
Sources/Panels/BrowserPanel.swift
Implements noteWebViewVisibility(_:reason:now:recordIfUnchanged:), refreshWebViewLifecycleState(), webViewLifecycleTopPayload(now:), timestamp formatting, and hidden-duration computation.
Panel Lifecycle Integration
Sources/Panels/BrowserPanel.swift
Resets and refreshes lifecycle metadata during profile switches, WKWebView replacements (process termination), workspace context resets, sets closing flag in close(), and records portal hides via hideBrowserPortalView(source:).
BrowserPanelView Visibility Recording
Sources/Panels/BrowserPanelView.swift
Calls panel.noteWebViewVisibility at onAppear ("view.onAppear"), on isVisibleInUI changes ("view.visible"/"view.hidden"), and in WebViewRepresentable.updateUsingWindowPortal on portal accept/release ("portal.update.visible"/"portal.update.hidden").
Terminal Controller Payload Integration
Sources/TerminalController.swift
Adds top-level browser_webview_lifecycle_state and embeds lifecycle (from webViewLifecycleTopPayload()) into webviews[0] for each browser surface in the V2 workspace tree payload.
Lifecycle State Tests
cmuxTests/GhosttyConfigTests.swift
Adds BrowserPanelWebViewLifecycleTests asserting initial .newTab, visible/hidden transitions via noteWebViewVisibility, payload contents (state, visibility flags, reason, hidden duration), record-if-unchanged behavior, and .closing on close().

Sequence Diagram

sequenceDiagram
  participant BrowserPanelView
  participant BrowserPanel
  participant TerminalController
  BrowserPanelView->>BrowserPanel: noteWebViewVisibility(visible, reason)
  BrowserPanel->>BrowserPanel: refreshWebViewLifecycleState()
  TerminalController->>BrowserPanel: webViewLifecycleTopPayload()
  BrowserPanel-->>TerminalController: lifecycle payload (state, visible_in_ui, should_render, timestamps, hidden_duration_ms)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • manaflow-ai/cmux#3835: Touches BrowserPanel.close() lifecycle/teardown behavior that overlaps with this PR's close-time lifecycle changes.
  • manaflow-ai/cmux#2000: Updates portal-hide usage that now interacts with the new portal. visibility recording.
  • manaflow-ai/cmux#1130: Modifies portal accept/release logic in BrowserPanelView/WebViewRepresentable related to the added visibility hooks.

"I hopped through windows, noting each view,
Tiny paws tapping when the visibility grew.
From new_tab to visible, hidden to close,
I log every heartbeat the WebView shows.
— a rabbit, blinking at the code 🐇"


Caution

Pre-merge checks failed

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

  • Ignore

❌ Failed checks (1 error, 1 warning)

Check name Status Explanation Resolution
Cmux Swiftui State Layout ❌ Error noteWebViewVisibility (called from updateNSView) mutates @Published webViewLifecycleState during NSViewRepresentable render phase, violating render-time mutation rule. Move noteWebViewVisibility calls from updateNSView to post-render lifecycle. Use callbacks or deferred state sync instead of direct render-phase writes to @Published state.
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (14 passed)
Check name Status Explanation
Title check ✅ Passed The title 'browser: expose webview lifecycle state in top' clearly describes the main change—exposing webview lifecycle state in the cmux top output for browser diagnostics.
Description check ✅ Passed The PR description includes a summary of changes and rationale, but lacks a Testing section and demo video as specified in the template.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Cmux Swift Actor Isolation ✅ Passed BrowserPanel is @MainActor with all lifecycle state properly isolated. The enum is nonisolated. No actor isolation mistakes in production code.
Cmux Swift Blocking Runtime ✅ Passed PR adds WebView lifecycle tracking via state transitions and property updates. No blocking primitives (semaphores, sleeps, asyncAfter, main.sync, locks) introduced in production code.
Cmux No Hacky Sleeps ✅ Passed Rule scope is TypeScript, JavaScript, shell scripts only. Swift code is covered by a separate rule. All PR changes are in Swift files, outside this rule.
Cmux Swift Concurrency ✅ Passed One @Published property added to existing ObservableObject. No new dispatch queues, completion handlers, or fire-and-forget Tasks. All new methods are synchronous.
Cmux Swift @Concurrent ✅ Passed No concurrency violations found. All new functions are synchronous on @MainActor. No @concurrent misuse, missing annotations, or heavy async work detected.
Cmux Swift File And Package Boundaries ✅ Passed BrowserPanel addition of +129 lines is below 250-line threshold for oversized files. Lifecycle tracking cohesive with WebView responsibility. No mixed responsibilities or package extraction needed.
Cmux Swift Logging ✅ Passed No logging violations found. PR adds 201 lines with zero instances of print, debugPrint, dump, or NSLog in Sources/. All code is clean data structures and state logic.
Cmux User-Facing Error Privacy ✅ Passed PR introduces browser lifecycle state tracking in internal socket telemetry and tests only. No vendor names, credentials, or sensitive data exposed in user-facing output.
Cmux Architecture Rethink ✅ Passed Clean lifecycle state tracking with single owner (BrowserPanel), clear invariants, no timing/dispatch patterns, proper guards on visibility updates, and derived state computation. No violations.
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed PR adds lifecycle tracking to WebViews but does not introduce new standalone application windows. Modal dialogs are utility windows, not subject to the cmux.* identifier requirement.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


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 and usage tips.

@greptile-apps

greptile-apps Bot commented May 16, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds WKWebView lifecycle tracking to browser panels, recording visibility state (new_tab, live_visible, live_hidden, closing) with timestamps and computed hidden_duration_ms, and surfaces the data in cmux top browser payloads. It addresses several previous review findings: the ISO8601DateFormatter is now a static cached instance, duplicate hide events no longer reset webViewLastHiddenAt, and the double-close in tests is removed.

  • Lifecycle state machine is tracked in BrowserPanel via noteWebViewVisibility, refreshWebViewLifecycleState, and a set of webViewLast* timestamps; the state is emitted in webViewLifecycleTopPayload and piped into TerminalController's top payload.
  • Visibility events are fed from three call sites: BrowserPanelView.onAppear, onChange(of: isVisibleInUI), and synchronously inside WebViewRepresentable.updateNSView for portal accept/release transitions.
  • Lifecycle metadata is reset on workspace context change (both the skip and the real-reset paths) and when the WebView instance is replaced.

Confidence Score: 3/5

The lifecycle tracking machinery is mostly correct but has multiple unresolved issues that can make the core hidden_duration_ms metric unreliable in production — the same metric the discard policy in follow-up PRs will depend on.

Two issues from prior review rounds remain in the code: resetWebViewLifecycleMetadata fires on the no-op branch of resetForWorkspaceContextChange clearing timestamp history for panels that do not need a reset, and noteWebViewVisibility is called synchronously inside updateNSView mutating a @published property during the SwiftUI view-update pass. Together they mean hidden_duration_ms can silently reset to zero or trigger runtime diagnostics on every workspace churn event. The new finding in this pass — null hidden_duration for background tabs that were never visible — adds a third gap in the observable this PR is meant to provide before the discard policy lands.

Sources/Panels/BrowserPanel.swift and Sources/Panels/BrowserPanelView.swift both need attention: the lifecycle timestamp reset placement in resetForWorkspaceContextChange and the synchronous visibility mutation inside updateNSView are the two paths most likely to corrupt the hidden-duration metric in real use.

Important Files Changed

Filename Overview
Sources/Panels/BrowserPanel.swift Adds lifecycle state machine, timestamp tracking, and top payload; resetWebViewLifecycleMetadata() is called in the wrong branch of resetForWorkspaceContextChange (the no-op guard-else path), wiping history for panels that don't need a reset; null hidden_duration_ms for tabs never visible from creation.
Sources/Panels/BrowserPanelView.swift Adds noteWebViewVisibility calls from onAppear, onChange(of: isVisibleInUI), and synchronously in updateNSView for portal transitions; synchronous @published mutation inside updateNSView remains unresolved.
Sources/TerminalController.swift Wires webViewLifecycleTopPayload and webViewLifecycleState.rawValue into the cmux top browser panel payload; change is additive and follows existing access patterns.
cmuxTests/GhosttyConfigTests.swift Adds two focused lifecycle tests; double-close from the previous review is gone; duplicate-hide assertion verifies the 11250ms computation; no test for the never-visible background tab scenario.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[BrowserPanel init] --> B{renderInitialNavigation?}
    B -- false --> C[shouldRenderWebView = false\nstate = .newTab]
    B -- true --> D[shouldRenderWebView = true\nstate = .liveHidden\nwebViewLastHiddenAt = nil]

    E[onAppear / onChange isVisibleInUI] -->|noteWebViewVisibility| F{changed or first record?}
    G[hideBrowserPortalView] -->|noteWebViewVisibility false| F
    H[updateNSView portal accept/release] -->|noteWebViewVisibility sync| F

    F -- yes --> I[update isWebViewVisibleInUI\nset webViewLastVisibleAt or webViewLastHiddenAt\ncall refreshWebViewLifecycleState]
    F -- no --> J[refreshWebViewLifecycleState only]

    I --> K{isClosingWebViewLifecycle?}
    K -- yes --> L[.closing]
    K -- no --> M{shouldRenderWebView?}
    M -- no --> N[.newTab]
    M -- yes --> O{isWebViewVisibleInUI?}
    O -- yes --> P[.liveVisible]
    O -- no --> Q[.liveHidden\nhidden_duration_ms = now - webViewLastHiddenAt]

    D -.->|webViewLastHiddenAt is nil\nhidden_duration_ms = NSNull| Q

    R[close] -->|isClosingWebViewLifecycle = true| L
    S[resetForWorkspaceContextChange needsReset=false] -->|resetWebViewLifecycleMetadata wipes timestamps| N
    T[resetForWorkspaceContextChange needsReset=true] -->|resetWebViewLifecycleMetadata| N
Loading

Reviews (5): Last reviewed commit: "browser: preserve lifecycle visibility o..." | Re-trigger Greptile

Comment thread Sources/Panels/BrowserPanel.swift Outdated
Comment on lines +2713 to +2720
isWebViewVisibleInUI = visible
if visible {
webViewLastVisibleAt = now
} else {
webViewLastHiddenAt = now
}
webViewLastVisibilityChangeAt = now
webViewLastVisibilityChangeReason = reason

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 webViewLastHiddenAt and webViewLastVisibilityChangeAt are unconditionally overwritten even when recordIfUnchanged: true and the view was already hidden. hideBrowserPortalView always passes recordIfUnchanged: true, so any subsequent AppKit portal-hide call — window animations, pane reflows, workspace churn — resets webViewLastHiddenAt to now. hidden_duration_ms is then computed as now − webViewLastHiddenAt, so it will read as near-zero for a tab that has been hidden for minutes. This defeats the stated purpose of the PR (identifying long-hidden retained WebContent processes before adding discard policy).

Suggested change
isWebViewVisibleInUI = visible
if visible {
webViewLastVisibleAt = now
} else {
webViewLastHiddenAt = now
}
webViewLastVisibilityChangeAt = now
webViewLastVisibilityChangeReason = reason
if changed {
isWebViewVisibleInUI = visible
if visible {
webViewLastVisibleAt = now
} else {
webViewLastHiddenAt = now
}
webViewLastVisibilityChangeAt = now
}
webViewLastVisibilityChangeReason = reason

Comment thread Sources/Panels/BrowserPanel.swift Outdated
Comment on lines +2756 to +2761
private static func webViewLifecycleTimestamp(_ date: Date?) -> Any {
guard let date else { return NSNull() }
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter.string(from: date)
}

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 webViewLifecycleTopPayload calls webViewLifecycleTimestamp up to three times per invocation, and each call allocates a fresh ISO8601DateFormatter. Formatter initialisation is non-trivial; since cmux top polls this for every browser panel, it creates avoidable per-poll allocations. A static cached instance is the standard fix.

Suggested change
private static func webViewLifecycleTimestamp(_ date: Date?) -> Any {
guard let date else { return NSNull() }
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter.string(from: date)
}
private static let lifecycleTimestampFormatter: ISO8601DateFormatter = {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return f
}()
private static func webViewLifecycleTimestamp(_ date: Date?) -> Any {
guard let date else { return NSNull() }
return Self.lifecycleTimestampFormatter.string(from: date)
}

Comment thread cmuxTests/GhosttyConfigTests.swift Outdated
Comment on lines +1201 to +1203
defer { panel.close() }

XCTAssertEqual(panel.webViewLifecycleState, .liveHidden)

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 defer { panel.close() } is set at the top of the test, and then panel.close() is also called explicitly just before the closing-state assertion. When the test function exits, the defer fires a second close() call. If any of the teardown helpers (GlobalSearchCoordinator.shared.purgePanel, closeDeveloperToolsForTeardown, etc.) are not idempotent, this will produce a double-teardown. Remove the defer since the test manages the lifetime manually.

Suggested change
defer { panel.close() }
XCTAssertEqual(panel.webViewLifecycleState, .liveHidden)
XCTAssertEqual(panel.webViewLifecycleState, .liveHidden)

@coderabbitai coderabbitai Bot 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: 3

🤖 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 `@cmuxTests/GhosttyConfigTests.swift`:
- Line 1201: The test currently calls defer { panel.close() } while also
explicitly calling panel.close() later to assert the .closing state, causing
close() to run twice; remove the defer cleanup from this test so the explicit
panel.close() is the sole cleanup/verification step (look for the defer {
panel.close() } and the explicit panel.close() and remove the defer to avoid
double invocation and rely on the assertion of the .closing state).

In `@Sources/Panels/BrowserPanel.swift`:
- Around line 2506-2512: The lifecycle metadata properties
(webViewLifecycleState, webViewLastVisibleAt, webViewLastHiddenAt,
webViewLastVisibilityChangeAt, webViewLastVisibilityChangeReason,
isWebViewVisibleInUI, isClosingWebViewLifecycle) are not cleared when a panel is
reused; update resetForWorkspaceContextChange(reason:) to reinitialize these
fields to their default values (e.g., .newTab for webViewLifecycleState, nil for
date/reason properties, false for booleans) so the panel does not carry stale
visibility/timestamp/reason data from the previous workspace.
- Around line 2701-2721: The method noteWebViewVisibility currently overwrites
hidden timestamps even when called with recordIfUnchanged while the web view is
already hidden; change it so webViewLastHiddenAt and
webViewLastVisibilityChangeAt are only set on an actual state transition to
hidden (i.e., when changed is true and visible is false). Keep updating
webViewLastVisibleAt when visible is true as before, and only update
webViewLastVisibilityChangeReason and webViewLastVisibilityChangeAt when changed
is true (or if webViewLastVisibilityChangeReason is nil on first record) so
repeated hideBrowserPortalView() calls do not reset hidden_duration_ms; adjust
logic in noteWebViewVisibility accordingly using the existing
isWebViewVisibleInUI, recordIfUnchanged, changed, webViewLastVisibleAt,
webViewLastHiddenAt, webViewLastVisibilityChangeAt, and
webViewLastVisibilityChangeReason symbols.
🪄 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: 91b2f22f-c82d-4f4c-8c4a-6ed1bb5eea00

📥 Commits

Reviewing files that changed from the base of the PR and between 19d70ef and 181a4db.

📒 Files selected for processing (4)
  • Sources/Panels/BrowserPanel.swift
  • Sources/Panels/BrowserPanelView.swift
  • Sources/TerminalController.swift
  • cmuxTests/GhosttyConfigTests.swift

Comment thread cmuxTests/GhosttyConfigTests.swift Outdated
Comment thread Sources/Panels/BrowserPanel.swift

@cubic-dev-ai cubic-dev-ai Bot 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.

1 issue found across 4 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="Sources/Panels/BrowserPanel.swift">

<violation number="1" location="Sources/Panels/BrowserPanel.swift:2713">
P1: Only update hidden/visible timestamps when visibility actually changes. Repeated hide events currently reset `webViewLastHiddenAt`, which under-reports `hidden_duration_ms` for retained hidden webviews.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.
Re-trigger cubic

Comment thread Sources/Panels/BrowserPanel.swift Outdated
Comment thread Sources/Panels/BrowserPanelView.swift Outdated
Comment on lines +6706 to +6708
DispatchQueue.main.async { [weak panel] in
panel?.noteWebViewVisibility(lifecycleVisibleInUI, reason: lifecycleReason)
}

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 Stale-capture race from deferred async dispatch in updateNSView

updateNSView is called on the main thread on every SwiftUI state change, including layout churn. The DispatchQueue.main.async defers each call to noteWebViewVisibility to the next run-loop cycle, which means multiple blocks accumulate in the queue with stale captured values. If onChange(of: isVisibleInUI) has already fired synchronously and set isWebViewVisibleInUI = false (with the correct webViewLastHiddenAt), a subsequent stale async block carrying lifecycleVisibleInUI = true from an older updateNSView pass will see changed = true and overwrite webViewLastHiddenAt with now, resetting the hidden-clock to zero. The hidden_duration_ms metric — the core observable this PR adds — can read near-zero for a tab that has been hidden for minutes.

The async hop is needed to avoid SwiftUI's "modifying state during view update" diagnostic (since noteWebViewVisibility can flip @Published var webViewLifecycleState). A safer shape is to snapshot visibility from isVisibleInUI inside a .task(id:) or onChange chain that already owns the transition, rather than racing an async block from the representable's imperative update path against the declarative SwiftUI path.

@coderabbitai coderabbitai Bot 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: 3

🤖 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/Panels/BrowserPanel.swift`:
- Around line 2506-2512: When webViewInstanceID is rotated the per-webview
lifecycle metadata must be reset; update switchToProfile(...) and
replaceWebViewPreservingState(...) to re-seed/clear webView lifecycle fields
(webViewLifecycleState, webViewLastVisibleAt, webViewLastHiddenAt,
webViewLastVisibilityChangeAt, webViewLastVisibilityChangeReason,
isWebViewVisibleInUI, isClosingWebViewLifecycle) immediately after
creating/assigning the new WKWebView and updating webViewInstanceID so the new
instance starts with fresh lifecycle values tied to the new ID.
- Around line 2714-2723: The bug is that webViewLastVisibilityChangeReason is
set unconditionally, causing it to drift from webViewLastVisibilityChangeAt when
recordIfUnchanged is true; only update webViewLastVisibilityChangeReason at the
same time you update webViewLastVisibilityChangeAt (i.e., inside the same
changed || isFirstVisibilityRecord branch) so the reason and timestamp always
refer to the same event—modify the block around
isWebViewVisibleInUI/webViewLastVisibleAt/webViewLastHiddenAt/webViewLastVisibilityChangeAt
to also set webViewLastVisibilityChangeReason there and remove the unconditional
assignment afterwards.

In `@Sources/Panels/BrowserPanelView.swift`:
- Around line 781-784: The call to panel.noteWebViewVisibility uses visibleInUI
to set the reason string but passes the effective visibility (visibleInUI &&
isCurrentPaneOwner) as the state, causing mismatched reason when the pane is not
owner; change the reason to reflect the actual emitted state by computing a
single effectiveVisibility = visibleInUI && isCurrentPaneOwner and pass that for
both the first argument and for selecting the reason (e.g., reason:
effectiveVisibility ? "view.visible" : "view.hidden") when calling
panel.noteWebViewVisibility so the reason matches the recorded visibility.
🪄 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: 07f8963d-cf90-4c3a-80df-64896aa5e046

📥 Commits

Reviewing files that changed from the base of the PR and between 181a4db and 1da1418.

📒 Files selected for processing (3)
  • Sources/Panels/BrowserPanel.swift
  • Sources/Panels/BrowserPanelView.swift
  • cmuxTests/GhosttyConfigTests.swift

Comment thread Sources/Panels/BrowserPanel.swift
Comment thread Sources/Panels/BrowserPanel.swift Outdated
Comment thread Sources/Panels/BrowserPanelView.swift

@cubic-dev-ai cubic-dev-ai Bot 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.

1 issue found across 3 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="Sources/Panels/BrowserPanelView.swift">

<violation number="1" location="Sources/Panels/BrowserPanelView.swift:6706">
P2: Avoid dispatching captured visibility values asynchronously from `updateNSView`; queued stale values can be applied out of order and overwrite lifecycle timestamps, producing incorrect `hidden_duration_ms` telemetry.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.
Re-trigger cubic

Comment thread Sources/Panels/BrowserPanelView.swift Outdated

@coderabbitai coderabbitai Bot 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: 1

🤖 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/Panels/BrowserPanel.swift`:
- Line 4631: The reset currently only runs after needsWorkspaceContextReset,
letting lifecycle-only tabs keep
webViewLastVisibleAt/webViewLastVisibilityChangeReason when shouldRenderWebView
== false, currentURL == nil, and webView.superview == nil; move or invoke
resetWebViewLifecycleMetadata() in the early-return branch (or before the guard
that checks those blank-tab conditions) so lifecycle metadata is cleared even
when the tab is non-rendering, ensuring stale webViewLastVisibleAt and
webViewLastVisibilityChangeReason are reset regardless of
needsWorkspaceContextReset.
🪄 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: 9a1bde70-6e3e-4373-96f9-6c8b995bc104

📥 Commits

Reviewing files that changed from the base of the PR and between 1da1418 and df77742.

📒 Files selected for processing (3)
  • Sources/Panels/BrowserPanel.swift
  • Sources/Panels/BrowserPanelView.swift
  • cmuxTests/GhosttyConfigTests.swift

Comment thread Sources/Panels/BrowserPanel.swift
Comment on lines +6738 to +6742
if portalHostAccepted || didReleasePortalHost {
let lifecycleVisibleInUI = portalHostAccepted && coordinator.desiredPortalVisibleInUI
let lifecycleReason = lifecycleVisibleInUI ? "portal.update.visible" : "portal.update.hidden"
panel.noteWebViewVisibility(lifecycleVisibleInUI, reason: lifecycleReason)
}

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 @Published mutation still inside updateNSView

The PR description states "Deferred portal update lifecycle recording out of NSViewRepresentable render/update," but panel.noteWebViewVisibility is still called synchronously inside updateNSView. noteWebViewVisibility calls refreshWebViewLifecycleState(), which writes to @Published var webViewLifecycleState. Mutating an ObservableObject's published property synchronously during a SwiftUI view-update pass triggers the "Publishing changes from within view updates is not allowed" runtime diagnostic. The prior async-dispatch approach deferred that mutation but introduced the stale-capture race described in the earlier review thread; neither variant is safe here. The invariant needs a different shape — tracking the last-accepted portal visibility on the coordinator so the lifecycle update can be driven by a side-channel that is not tied to the render pass.

@coderabbitai coderabbitai Bot 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: 1

🤖 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/Panels/BrowserPanel.swift`:
- Around line 3420-3424: The replacement webview can be left permanently
`.live_hidden` because resetWebViewLifecycleMetadata() clears
isWebViewVisibleInUI and refreshWebViewLifecycleState() doesn't force
noteWebViewVisibility() when claimPortalHost() fails; updateUsingWindowPortal()
currently guards noteWebViewVisibility() behind (portalHostAccepted ||
didReleasePortalHost). To fix, ensure noteWebViewVisibility() runs after a
replacement even if claimPortalHost() returns false by adding an unconditional
fallback path: after calling resetWebViewLifecycleMetadata() and assigning
webView/currentURL in the replacement code path (where webViewInstanceID
changes), schedule a deferred call (async dispatch/Task or short timer) to
noteWebViewVisibility() or invoke refreshWebViewLifecycleState() to trigger
visibility reconciliation; alternatively add a dedicated recovery hook (onChange
for webViewInstanceID or a background monitor) that calls
noteWebViewVisibility() when claimPortalHost() fails (e.g., when
shouldUseLocalInlineDeveloperToolsHosting() is true) so new instances always
receive a visibility event.
🪄 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: c75cb871-3f87-46f5-8b92-49d504a2aa5a

📥 Commits

Reviewing files that changed from the base of the PR and between df77742 and 9550f84.

📒 Files selected for processing (1)
  • Sources/Panels/BrowserPanel.swift

Comment thread Sources/Panels/BrowserPanel.swift Outdated
@greptile-apps

greptile-apps Bot commented May 16, 2026

Copy link
Copy Markdown
Contributor

Want your agent to iterate on Greptile's feedback? Try greploops.

@lidge-jun

Copy link
Copy Markdown
Contributor Author

@coderabbitai resolve

@coderabbitai

coderabbitai Bot commented May 16, 2026

Copy link
Copy Markdown
✅ Actions performed

Comments resolved and changes approved.

@vercel

vercel Bot commented May 18, 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, Comment May 18, 2026 10:45am

@austinywang
austinywang merged commit 1956a7e into manaflow-ai:main May 18, 2026
18 of 20 checks passed

@austinywang austinywang left a comment

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.

Summary

This is pointed in a useful direction: putting browser WebContent visibility into system.top is the right diagnostic surface, and the payload changes are mostly additive. I do not see a new WKWebView retain cycle, delegate-cycle, or observer teardown problem in the diff. I do see a correctness hole in the lifecycle state machine: attached-DevTools/local-inline hosting can keep reporting a hidden retained webview as live_visible, which is exactly the class of case this telemetry is supposed to distinguish.

Strengths

  • The top payload change preserves the existing webviews array and browser_web_content_pid fields, so existing JSON consumers should not break on missing old keys.
  • BrowserPanel.close() now marks the lifecycle as closing before delegate/KVO teardown, and workspace-context reset clears the new metadata.
  • The model-level duplicate-hide behavior does avoid resetting last_hidden_at; the direct unit test covers that helper-level invariant.
  • The new code does not add NotificationCenter observers, KVO observers, WK delegates, or strong self-capturing WKWebView closures.

Concerns

  • Sources/Panels/BrowserPanelView.swift:6482: updateUsingLocalInlineHosting never records lifecycle visibility, and that makes the state wrong when an attached-DevTools browser is hidden. The visible path is gated into local-inline mode at Sources/Panels/BrowserPanelView.swift:1328, then updateUsingLocalInlineHosting clears/suppresses portal ownership around Sources/Panels/BrowserPanelView.swift:6494. When the same panel later loses pane ownership or visibility, updateNSView falls back to the window-portal path, but the hidden lifecycle update at Sources/Panels/BrowserPanelView.swift:6738 only runs if a portal host was accepted or released. After local-inline hosting there may be no active portal lease to release, so noteWebViewVisibility(false, ...) is skipped and cmux top keeps showing live_visible for a hidden retained WKWebView. That hides the exact memory-diagnostic case this PR is trying to expose. The fix should make effective webview visibility a single transition independent of whether the current host is window-portal or local-inline.

  • Sources/Panels/BrowserPanel.swift:2701: lifecycle ownership is split across BrowserPanelView.onAppear (Sources/Panels/BrowserPanelView.swift:717), SwiftUI isVisibleInUI changes (Sources/Panels/BrowserPanelView.swift:780), portal acceptance/release (Sources/Panels/BrowserPanelView.swift:6738), and workspace/tab cleanup (Sources/Panels/BrowserPanel.swift:6335). Architecturally this is still bolted onto several ad-hoc lifecycle signals rather than derived from one owner of "effective browser visibility." The local-inline miss above is a symptom of that split. I would expect one @MainActor owner on BrowserPanel to receive explicit lifecycle events or snapshots from the view/portal layer and derive new_tab/live_visible/live_hidden/closing in one place, with portal/local-inline as implementation details rather than separate truth sources.

  • cmuxTests/GhosttyConfigTests.swift:1175: the new tests call BrowserPanel.noteWebViewVisibility directly, so they prove the helper behavior but not the real call paths that can disagree: selected-tab changes, workspace retirement, portal host replacement, local-inline DevTools hosting, and system.top serialization. Add at least one behavior-level test or small runtime seam that drives an effective visibility transition through the same path used by BrowserPanelView/portal ownership, plus a top-payload assertion. Otherwise the current tests would pass while the attached-DevTools hidden-state bug above remains.

Suggestions

  • If the goal is human memory diagnostics from cmux top, consider also rendering the lifecycle in the default tree label in CLI/cmux.swift:12486; right now it is only visible to JSON consumers.
  • Consider using truncation instead of rounding for hidden_duration_ms in Sources/Panels/BrowserPanel.swift:2773 if downstream tooling treats it as elapsed time rather than a displayed approximation.

Verdict

Request changes. The payload shape is fine, but the lifecycle source of truth is not durable yet, and at least one real browser hosting mode can report hidden retained WebViews as visible.

This branch was successfully deployed

1 active deployment
Preview – cmux — 23ec6bc8 Deployed May 18, 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.

2 participants