Skip to content

Recycle menu card hosting views and reconcile menu content in place - #1394

Merged
steipete merged 2 commits into
steipete:mainfrom
bcssewl:perf/recycle-menu-card-hosting-views
Jun 10, 2026
Merged

Recycle menu card hosting views and reconcile menu content in place#1394
steipete merged 2 commits into
steipete:mainfrom
bcssewl:perf/recycle-menu-card-hosting-views

Conversation

@bcssewl

@bcssewl bcssewl commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

Refs #1374, #1321, #1311, #1360, #1325, #1308.

Summary

Two structural changes that together remove the rebuild cost from CodexBar's hottest menu paths — background data ticks and provider switches — without touching any scheduling semantics:

  • Hosting-view recycling: populateMenu's teardown harvests the outgoing items' card hosting views, and makeMenuCardItem adopts a harvested view when the card identifier matches — or, failing that, the first type-compatible leftover (a usage card is the same SwiftUI content type for every provider, so cold provider switches repaint existing views instead of constructing fresh hierarchies). The replaced rootView is diffed in place by SwiftUI on the live graph.
  • In-place skeleton reconciliation: replacement content is built into a detached scratch menu (interaction closures capture the live menu via a captureMenu parameter threaded through the builders), and a position-wise reconciler mutates the live NSMenuItems in place when the row skeleton (separators, identifiers, view classes) is unchanged — zero removals, zero insertions, so AppKit never relayouts the open tracked menu per insert. Mismatched skeletons fall back to the wholesale swap.

Tab switches whose incoming cache entry is still valid keep the instant wholesale-reattach path; the displaced selection's cache entry is consumed before harvesting so no cached item can ever share a view with a live one. See the round-2 comment for the field traces that motivated the reconciler and the detailed numbers.

Why

Every data tick currently pays full hosting-view reconstruction wherever a rebuild lands:

Measurements

Unit-level timing of 60 data-only repopulates of a single codex card (cost usage enabled, 30-day daily breakdown seeded, snapshot data varied per tick so height fingerprints miss the cache, matching real refresh behavior — refreshFrequency ticks always change updatedAt/usage):

run (interleaved) median p90
this branch 9.09 ms / 9.50 ms / 10.16 ms 10.4–11.3 ms
current main (2eaa313) 12.82 ms / 12.35 ms 14.3–15.4 ms

≈ 25–30% less main-thread time per repopulate for one moderate card; the saving is per card, so merged menus with several cards/overview rows save proportionally more. When data does not change between populate passes (fingerprint cache hits on both sides), timings are flat — the win is specifically the virgin-hosting-view construction + first layout that recycling eliminates, consistent with the field samples above.

Timing harness (not committed; drop into Tests/CodexBarTests to reproduce)
// 60 iterations of: mutate codex snapshot (usedPercent/resetsAt) →
// invalidateMenus(allowStaleContentDuringDataRefresh: true) →
// clock.measure { controller.populateMenu(menu, provider: .codex) }
// reporting median/p90/avg; single-provider menu, rendering enabled,
// cost usage enabled with a 30-entry daily breakdown.

I kept the harness out of the tree since timing assertions are CI-flaky; happy to post the full file in a comment or gist if useful.

Safety notes

  • Cache aliasing: after a merged-tab cache hit, cached items and live items are the same objects, so harvesting live views could double-parent a view that a cached item still references. The canRecycleMenuCardViews gate (cache-empty per menu) makes that state unreachable: any pass that just cached outgoing tab content, or that could re-attach cached content, sees a non-empty cache and skips harvesting entirely.
  • Highlight continuity: MenuCardItemHostingView.setHighlighted drives the MenuCardHighlightState captured at construction, so adoption rebuilds the wrapped MenuCardSectionContainerView around the recycled view's own state object — hover highlighting keeps working across reuse (covered by a test).
  • Click handlers: onClick is replaced on reuse and the gesture recognizer is installed lazily if a recycled card gains a click action it didn't have.
  • Type changes: adoption requires the exact wrapped content type; an incompatible same-id pool entry is consumed and dropped, restoring build-fresh behavior (covered by a test).
  • Pool lifetime: the pool lives only between harvest and the end of the same synchronous populate pass (defer { clearMenuCardViewRecyclePool() } on both teardown sites); unconsumed views are released there, so no retention growth.
  • Tests-only mode: with menuCardRenderingEnabled == false nothing is harvested or adopted.

Validation

  • 4 new tests in MenuCardViewRecyclingTests.swift: data-only repopulate reuses hosting views by identity; preserved merged-switcher caches disable harvesting (and harvesting detaches views from outgoing items); recycled cards keep their hosting view + highlight state; same-id/different-type builds a fresh view and consumes the pool entry
  • swift test (full suite): green except the pre-existing locale-dependent expectation in MiniMaxMenuCardModelTests ("Renews: May 18, 2027" vs "18 May 2027"), which fails identically on clean main in a non-US-format locale
  • make check: 0 violations
  • Commands run: swift build, swift test, make check

I can't reproduce the heavy multi-provider field configs locally, so the unit numbers above are the floor, not the ceiling. A packaged retest from the #1274/#1314/#1325 measurement setups (@giuseppebisemi, @psufka, @dengshu2 — the before/after sample stack identity would be: MenuCardItemHostingView.__deallocating_deinit / initial sizeThatFits disappearing from repopulate paths) would be the decisive field proof, as in previous rounds.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8f2cc7da99

ℹ️ 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".

guard let id = item.representedObject as? String else { continue }
guard let view = item.view, view is any MenuCardMeasuring else { continue }
guard self.menuCardViewRecyclePool[id] == nil else { continue }
item.view = nil

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 Reset highlight before detaching recycled views

When an open menu is rebuilt while a card is highlighted, this detaches the view from the NSMenuItem that highlightedMenuItems still stores. If AppKit subsequently sends menu(_:willHighlight:) with nil or a different rebuilt item, the old item has no view, so the existing code cannot call setHighlighted(false) and the recycled hosting view can remain visibly highlighted on the wrong/no row. Clear the highlight or update highlightedMenuItems before moving the view into the recycle pool.

Useful? React with 👍 / 👎.

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.

Good catch — confirmed real: both unhighlight paths (menu(_:willHighlight:) and the menuDidClose cleanup) unwind through the tracked item's view, which harvesting nils out, so a card highlighted at rebuild time would re-attach with stale highlight rendering and no path to clear it. On current main this can't surface because the discarded view dies with the item; with recycling the state survives into the visible menu.

Fixed in 2f5cf69: harvestRecyclableMenuCardViews now calls setHighlighted(false) on the view and drops the menu's highlightedMenuItems entry when it strips the tracked item, before pooling. Behavior matches main's rebuild semantics (fresh content starts unhighlighted; the next mouse-move re-highlights via willHighlight). Added a regression test: harvesting a highlighted card clears its highlight and tracking entry. Full suite + make check green.

@clawsweeper

clawsweeper Bot commented Jun 10, 2026

Copy link
Copy Markdown

Codex review: needs real behavior proof before merge. Reviewed June 10, 2026, 12:53 PM ET / 16:53 UTC.

Summary
The PR pools outgoing menu-card hosting views by card identifier, reuses type-compatible views during synchronous repopulation, resets reusable highlight and click state, and adds focused AppKit lifecycle tests.

Reproducibility: not applicable. as a PR-level performance optimization. The reconstruction path is source-verifiable and related reports contain field samples, but this branch has not established an affected packaged after-fix reproduction.

Review metrics: 3 noteworthy metrics.

  • Changed surface: 7 files, +309/-15. The patch is bounded but changes both NSMenu teardown and reusable NSHostingView lifecycle, which warrants runtime proof beyond compilation.
  • Focused coverage: 5 tests added. Coverage checks identity reuse, cache ownership, highlight reset, and incompatible content types, but not the complete packaged multi-provider interaction path.
  • Reported microbenchmark: 25–30% lower median time. The claimed single-card unit-harness improvement is promising but remains uncommitted and is not real-app proof.

Merge readiness
Overall: 🧂 unranked krab
Proof: 🧂 unranked krab
Patch quality: 🐚 platinum hermit
Result: blocked until real behavior proof is added.

Overall follows the weaker of proof and patch quality, so missing proof can cap an otherwise strong patch.

Rank-up moves:

  • Post redacted packaged before/after timing or sample evidence from an affected multi-provider setup.
  • Demonstrate correct hover, click, submenu, tab-switch, content, and changing-height behavior across refreshes.
  • Remove the CHANGELOG.md edit.

Proof guidance:

  • [P1] Needs real behavior proof before merge: The PR includes tests and unit timing but explicitly lacks a packaged affected multi-provider after-fix run; post a redacted recording, terminal/sample output, live output, or linked artifact, then update the PR body for a fresh review or ask a maintainer to comment @clawsweeper re-review.

Mantis proof suggestion
Native macOS menu interaction plus diagnostic timing or sample output would materially validate this hosting-view lifecycle optimization. A maintainer can ask Mantis to capture proof by posting a new PR comment that starts with the OpenClaw Mantis account mention, followed by:

visual task: verify packaged merged-menu refreshes reuse cards without stale content or highlight/click/tab/submenu regressions, and capture redacted before/after timing or sample output.

Risk before merge

  • [P1] Reusing a live SwiftUI/AppKit hosting graph changes lifecycle behavior that identity-focused tests cannot fully settle; stale view state, responder behavior, hover state, submenu interaction, click handling, or changing-height layout could regress only in the packaged app.
  • [P1] The PR's claimed 25–30% unit-level timing improvement has not been demonstrated on the affected real multi-provider menu path, so the user-visible benefit and absence of runtime regressions remain unproven.
  • [P1] The branch edits release-owned CHANGELOG.md; normal PRs in this repository should keep release-note context in the PR body or commit message instead.

Maintainer options:

  1. Prove the packaged lifecycle (recommended)
    Run the branch in an affected merged multi-provider configuration and post redacted before/after timing or sample output together with interaction proof across data refreshes.
  2. Pause view reuse
    If packaged testing exposes persistent SwiftUI or AppKit state across reuse, pause this branch and continue with narrower model, measurement, or descriptor caching instead.

Next step before merge

  • [P1] An automated worker can remove the isolated policy-violating changelog line, but contributor-supplied real behavior proof remains a separate human merge gate.

Security
Cleared: The diff adds no dependency, download, permission, credential, secret, package-resolution, or supply-chain execution surface.

Review findings

  • [P3] Remove the release-owned changelog entry — CHANGELOG.md:20
Review details

Best possible solution:

Keep the one-pass, cache-aware reuse design if a freshly packaged affected configuration demonstrates lower menu rebuild cost with correct content, sizing, hover, click, submenu, and tab-switch behavior, and leave CHANGELOG.md to the release process.

Do we have a high-confidence way to reproduce the issue?

Not applicable as a PR-level performance optimization. The reconstruction path is source-verifiable and related reports contain field samples, but this branch has not established an affected packaged after-fix reproduction.

Is this the best way to solve the issue?

Unclear. The pool is narrow and follows the existing merged-cache ownership boundary, but a fresh-bundle run is necessary to show that preserving hosting graphs is safer and materially better than narrower caching approaches.

Full review comments:

  • [P3] Remove the release-owned changelog entry — CHANGELOG.md:20
    Repository policy reserves CHANGELOG.md for the release process, so this normal performance PR should not add its own release-note line. Keep the user-visible context in the PR body or commit message instead.
    Confidence: 0.99

Overall correctness: patch is correct
Overall confidence: 0.86

AGENTS.md: found and applied where relevant.

Codex review notes: reasoning high; reviewed against 08c171b6b487.

Label changes

Label changes:

  • add P2: This is a normal-priority performance improvement for reported menu latency and idle CPU churn with limited application scope.
  • add merge-risk: 🚨 availability: Persisting SwiftUI/AppKit hosting graphs across rebuilds could produce menu stalls or broken interaction if lifecycle state is not fully reset.
  • add rating: 🧂 unranked krab: Overall readiness is 🧂 unranked krab; proof is 🧂 unranked krab and patch quality is 🐚 platinum hermit.
  • add status: 📣 needs proof: The PR needs real behavior proof before ClawSweeper can clear the contributor ask. Needs real behavior proof before merge: The PR includes tests and unit timing but explicitly lacks a packaged affected multi-provider after-fix run; post a redacted recording, terminal/sample output, live output, or linked artifact, then update the PR body for a fresh review or ask a maintainer to comment @clawsweeper re-review.

Label justifications:

  • P2: This is a normal-priority performance improvement for reported menu latency and idle CPU churn with limited application scope.
  • merge-risk: 🚨 availability: Persisting SwiftUI/AppKit hosting graphs across rebuilds could produce menu stalls or broken interaction if lifecycle state is not fully reset.
  • rating: 🧂 unranked krab: Overall readiness is 🧂 unranked krab; proof is 🧂 unranked krab and patch quality is 🐚 platinum hermit.
  • status: 📣 needs proof: The PR needs real behavior proof before ClawSweeper can clear the contributor ask. Needs real behavior proof before merge: The PR includes tests and unit timing but explicitly lacks a packaged affected multi-provider after-fix run; post a redacted recording, terminal/sample output, live output, or linked artifact, then update the PR body for a fresh review or ask a maintainer to comment @clawsweeper re-review.
Evidence reviewed

Acceptance criteria:

  • [P1] git diff --check.

What I checked:

Likely related people:

  • Peter Steinberger: The current menu rebuild/hosting files have the strongest recent history under this name, including merging the preceding menu performance work and the latest related hosted-submenu reuse change. (role: recent area contributor and merger; confidence: high; commits: 88eb603fecf3, 7c0ed036e2e6, 920997c6a365; files: Sources/CodexBar/StatusItemController+Menu.swift, Sources/CodexBar/StatusItemController+MenuPresentation.swift)
  • hhh2210: Authored the merged menu height-cache and closed-rebuild work whose documented remaining scope includes actual hosting-view reuse and first-populate cost. (role: recent performance-area contributor; confidence: high; commits: 400f98aa0e9b; files: Sources/CodexBar/StatusItemController+Menu.swift, Sources/CodexBar/StatusItemController+MenuPresentation.swift)
  • Ratul Sarna: Git shortlog shows substantial prior contribution volume in the central menu construction and presentation files, making this a useful secondary routing candidate for lifecycle review. (role: historical area contributor; confidence: medium; files: Sources/CodexBar/StatusItemController+Menu.swift, Sources/CodexBar/StatusItemController+MenuPresentation.swift)
What the crustacean ranks mean
  • 🦀 challenger crab: rare, exceptional readiness with strong proof, clean implementation, and convincing validation.
  • 🦞 diamond lobster: very strong readiness with only minor maintainer review expected.
  • 🐚 platinum hermit: good normal PR, likely mergeable with ordinary maintainer review.
  • 🦐 gold shrimp: useful signal, but proof or patch confidence is still limited.
  • 🦪 silver shellfish: thin signal; proof, validation, or implementation needs work.
  • 🧂 unranked krab: not merge-ready because proof is missing/unusable or there are serious correctness or safety concerns.
  • 🌊 off-meta tidepool: rating does not apply to this item.

Shiny media proof means a screenshot, video, or linked artifact directly shows the changed behavior. Runtime, network, CSP, and security claims still need visible diagnostics.

How this review workflow works
  • ClawSweeper keeps one durable marker-backed review comment per issue or PR.
  • Re-runs edit this comment so the latest verdict, findings, and automation markers stay together instead of adding duplicate bot comments.
  • A fresh review can be triggered by eligible @clawsweeper re-review comments, exact-item GitHub events, scheduled/background review runs, or manual workflow dispatch.
  • PR/issue authors and users with repository write access can comment @clawsweeper re-review or @clawsweeper re-run on an open PR or issue to request a fresh review only.
  • Maintainers can also comment @clawsweeper review to request a fresh review only.
  • Fresh-review commands do not start repair, autofix, rebase, CI repair, or automerge.
  • Maintainer-only repair and merge flows require explicit commands such as @clawsweeper autofix, @clawsweeper automerge, @clawsweeper fix ci, or @clawsweeper address review.
  • Maintainers can comment @clawsweeper explain to ask for more context, or @clawsweeper stop to stop active automation.

@clawsweeper clawsweeper Bot added rating: 🧂 unranked krab Not merge-ready due to missing proof or serious correctness/safety concerns. status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask. P2 Normal priority bug or improvement with limited blast radius. merge-risk: 🚨 availability 🚨 Merging this PR could cause crashes, hangs, restart loops, stalls, or process outages. labels Jun 10, 2026
@bcssewl

bcssewl commented Jun 10, 2026

Copy link
Copy Markdown
Contributor Author

Two responses to the review:

Changelog (P3): I checked repo practice before opening this — the changelog entry follows the established contributor convention here. The four most recently merged PRs all carry their own CHANGELOG.md edit in the squashed commit: e97bfb0d (#1376), 7c0ed036 (#1384), 1246ec61 (#1386), f51db0e9 (#1378), each with the same entry style this PR uses. Happy to drop it if the maintainer prefers, but it looks consistent with how every other 0.32.6 entry got there.

Reproducible timing harness: full file below — drop it into Tests/CodexBarTests/ on any checkout and run:

swift test --filter "timing harness"

It mutates the seeded snapshot before every tick so card height fingerprints miss their cache, matching real refresh behavior (ticks always change updatedAt/usage). Interleaved branch/main runs on an Apple Silicon machine gave 9.1/9.5/10.2 ms (branch) vs 12.8/12.4 ms (main 2eaa313d) median for the single-card scenario.

MenuRepopulateTimingTests.swift
import AppKit
import CodexBarCore
import SwiftUI
import Testing
@testable import CodexBar

extension StatusMenuTests {
    @Test
    func `timing harness data only repopulate`() {
        StatusItemController.setMenuRefreshEnabledForTesting(false)
        let previousRendering = StatusItemController.menuCardRenderingEnabled
        StatusItemController.menuCardRenderingEnabled = true
        defer { StatusItemController.menuCardRenderingEnabled = previousRendering }

        let settings = self.makeSettings()
        settings.statusChecksEnabled = false
        settings.refreshFrequency = .manual
        settings.mergeIcons = false
        let registry = ProviderRegistry.shared
        for provider in UsageProvider.allCases {
            if let metadata = registry.metadata[provider] {
                settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .codex)
            }
        }
        settings.costUsageEnabled = true
        let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false)
        let dayFormatter = DateFormatter()
        dayFormatter.dateFormat = "yyyy-MM-dd"
        dayFormatter.timeZone = TimeZone(identifier: "UTC")
        var dailyEntries: [CostUsageDailyReport.Entry] = []
        for offset in 0..<30 {
            let day: Date = Date().addingTimeInterval(Double(-offset) * 86400)
            let input: Int = 120_000 + (offset * 1000)
            let output: Int = 60_000 + (offset * 500)
            let total: Int = input + output
            let cost: Double = 4.2 + (Double(offset) * 0.3)
            dailyEntries.append(CostUsageDailyReport.Entry(
                date: dayFormatter.string(from: day),
                inputTokens: input,
                outputTokens: output,
                totalTokens: total,
                costUSD: cost,
                modelsUsed: ["gpt-5.2-codex", "gpt-5.2", "gpt-5.1-codex-mini"],
                modelBreakdowns: nil))
        }
        store._setTokenSnapshotForTesting(
            CostUsageTokenSnapshot(
                sessionTokens: 1_234_567,
                sessionCostUSD: 12.34,
                last30DaysTokens: 45_678_910,
                last30DaysCostUSD: 123.45,
                daily: dailyEntries,
                updatedAt: Date()),
            provider: .codex)
        let controller = StatusItemController(
            store: store,
            settings: settings,
            account: UsageFetcher().loadAccountInfo(),
            updater: DisabledUpdaterController(),
            preferencesSelection: PreferencesSelection(),
            statusBar: self.makeStatusBarForTesting())
        defer { controller.releaseStatusItemsForTesting() }

        let menu = controller.makeMenu()
        controller.populateMenu(menu, provider: .codex)

        let clock = ContinuousClock()
        var samples: [Double] = []
        for iteration in 0..<60 {
            // Real refresh ticks change usage data, so card height fingerprints miss their
            // cache and every populate re-measures. Mirror that here.
            let used: Double = 20 + Double(iteration % 50)
            store._setSnapshotForTesting(
                UsageSnapshot(
                    primary: RateWindow(
                        usedPercent: used,
                        windowMinutes: 300,
                        resetsAt: Date().addingTimeInterval(1800 + Double(iteration)),
                        resetDescription: nil),
                    secondary: RateWindow(
                        usedPercent: 100 - used,
                        windowMinutes: 10080,
                        resetsAt: Date().addingTimeInterval(86400),
                        resetDescription: nil),
                    tertiary: nil,
                    updatedAt: Date(),
                    identity: ProviderIdentitySnapshot(
                        providerID: .codex,
                        accountEmail: "codex@example.com",
                        accountOrganization: nil,
                        loginMethod: "Plus Plan")),
                provider: .codex)
            controller.invalidateMenus(allowStaleContentDuringDataRefresh: true)
            let elapsed = clock.measure {
                controller.populateMenu(menu, provider: .codex)
            }
            samples.append(Double(elapsed.components.attoseconds) / 1e15 +
                Double(elapsed.components.seconds) * 1000)
        }
        samples.sort()
        let median = samples[samples.count / 2]
        let p90 = samples[Int(Double(samples.count) * 0.9)]
        let avg = samples.reduce(0, +) / Double(samples.count)
        print("TIMING_RESULT single_card median_ms=\(median) p90_ms=\(p90) avg_ms=\(avg) n=\(samples.count)")
        #expect(!samples.isEmpty)
    }

    @Test
    func `timing harness merged overview repopulate`() {
        StatusItemController.setMenuRefreshEnabledForTesting(false)
        let previousRendering = StatusItemController.menuCardRenderingEnabled
        StatusItemController.menuCardRenderingEnabled = true
        defer { StatusItemController.menuCardRenderingEnabled = previousRendering }

        let overviewProviders: [UsageProvider] = [
            .codex, .claude, .gemini, .cursor, .openrouter, .deepseek, .mistral, .grok,
        ]
        let settings = self.makeSettings()
        settings.statusChecksEnabled = false
        settings.refreshFrequency = .manual
        settings.mergeIcons = true
        settings.mergedMenuLastSelectedWasOverview = true
        let registry = ProviderRegistry.shared
        for provider in UsageProvider.allCases {
            if let metadata = registry.metadata[provider] {
                settings.setProviderEnabled(
                    provider: provider,
                    metadata: metadata,
                    enabled: overviewProviders.contains(provider))
            }
        }
        let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false)
        let now = Date()
        for provider in overviewProviders {
            store._setSnapshotForTesting(
                UsageSnapshot(
                    primary: RateWindow(
                        usedPercent: 35,
                        windowMinutes: 300,
                        resetsAt: now.addingTimeInterval(2400),
                        resetDescription: nil),
                    secondary: RateWindow(
                        usedPercent: 41,
                        windowMinutes: 10080,
                        resetsAt: now.addingTimeInterval(86400),
                        resetDescription: nil),
                    tertiary: nil,
                    updatedAt: now,
                    identity: ProviderIdentitySnapshot(
                        providerID: provider,
                        accountEmail: "user@example.com",
                        accountOrganization: nil,
                        loginMethod: "Plan")),
                provider: provider)
        }
        let controller = StatusItemController(
            store: store,
            settings: settings,
            account: UsageFetcher().loadAccountInfo(),
            updater: DisabledUpdaterController(),
            preferencesSelection: PreferencesSelection(),
            statusBar: self.makeStatusBarForTesting())
        defer { controller.releaseStatusItemsForTesting() }

        let menu = controller.makeMenu()
        controller.populateMenu(menu, provider: nil)
        let cardCount = menu.items.count { ($0.representedObject as? String)?.isEmpty == false }
        let clock = ContinuousClock()
        var samples: [Double] = []
        for _ in 0..<60 {
            controller.invalidateMenus(allowStaleContentDuringDataRefresh: true)
            let elapsed = clock.measure {
                controller.populateMenu(menu, provider: nil)
            }
            samples.append(Double(elapsed.components.attoseconds) / 1e15 +
                Double(elapsed.components.seconds) * 1000)
        }
        samples.sort()
        let median = samples[samples.count / 2]
        let p90 = samples[Int(Double(samples.count) * 0.9)]
        let avg = samples.reduce(0, +) / Double(samples.count)
        print(
            "TIMING_RESULT merged_overview cards=\(cardCount) median_ms=\(median) " +
                "p90_ms=\(p90) avg_ms=\(avg) n=\(samples.count)")
        #expect(!samples.isEmpty)
    }
}

On packaged proof: agreed that's the decisive gate. I'll follow up with a packaged before/after capture from a local config if I can make it representative; the #1274/#1314-style retest from the reporters with affected heavy multi-provider setups (tagged in the PR body) plus the suggested Mantis run would settle the field side either way.

@bcssewl

bcssewl commented Jun 10, 2026

Copy link
Copy Markdown
Contributor Author

Round 2, after field profiling on a real multi-provider setup surfaced where the remaining lag lives. Two findings worth recording, then the change.

Field finding 1 — switch lag is the dominant complaint path, and the tab cache can't help. 90-second sample traces of an interactive session on a packaged build (Apple Silicon, real provider config) show ~a third to ~three quarters of main-thread time inside NSMenuTrackingSession startRunningMenuEventLoop, dominated by recursive _layoutSubtreeWithOldSize storms under display-cycle flushes plus sizeThatFits/AttributeGraph work — i.e. rebuilds running while the menu is open and tracking. The merged-tab content cache rarely saves a switch in practice because every invalidateMenus clears it: with storage footprints enabled, menuWillOpen → refreshStorageFootprintsForOverview mutates the store and wipes the caches moments after every open, so effectively every provider switch is a cold rebuild during tracking.

Field finding 2 — view recycling alone doesn't fix switches. Per-interaction-normalized populate cost was flat with commit 2f5cf690's recycling during a switch-heavy session, because the remaining cost is item churn: removing and reinserting every NSMenuItem below the switcher makes AppKit relayout the open menu once per insert (-[NSMenu insertItem:atIndex:] → _optimalSizeForMenuItemAtIndex → layoutSubtreeIfNeeded, as #1325's sample also showed).

The change (a8dae47c):

  • Replacement content is now built into a detached scratch menu, with interaction closures capturing the live menu they will serve (new captureMenu parameter threaded through the section/switcher/overview builders).
  • A position-wise reconciler (StatusItemController+MenuReconcile.swift) then compares row skeletons (separator/id/view-class per position, snapshotted before harvest). Matching skeletons mutate the live items in place — views transplanted, plain rows recopied — so AppKit sees a structurally unchanged menu: zero removals, zero insertions on data ticks and same-shape switches. Mismatched skeletons fall back to the wholesale swap (today's behavior).
  • The recycle pool now also adopts type-compatible hosting views across selections (a usage card is the same SwiftUI content type for every provider), so a cold provider switch repaints the existing card views instead of constructing fresh hierarchies. Switches whose incoming cache entry is still valid keep the instant wholesale-reattach path, and the displaced selection's cache entry is consumed before harvesting so no cached item can ever share a view with a live one.

This is the shape #1374's closing asked for — a design that does not rebuild the full parent menu during tracking — applied to both the tick and switch paths.

Numbers (unit harness, interleaved runs, data varied per tick so height fingerprints miss): single-card data-tick repopulate 8.75ms vs 11.96ms median (~27% less); provider-switch repopulate 7.84ms vs 8.87ms (~12% less). The unit harness understates the switch win by construction — test menus are never inside a live menu window, so the per-insert relayout cost that reconciliation eliminates does not appear in it; that part is exactly the hot subtree in the field samples above.

Validation: 7 focused tests now, including the key structural assertion — a merged data-tick repopulate leaves the menu's NSMenuItem identities completely unchanged — plus cache-entry-consumption safety, cross-identifier type-compatible adoption, highlight-state continuity, and type-mismatch fallback. Full suite + make check green (the rotating failures in MiniMaxMenuCardModelTests (locale), HistoricalUsagePaceOwnershipTests and OpenAIDashboardWebViewCacheTests (load timing) reproduce identically on clean main; each passes in isolation).

Packaged before/after interaction proof from the same real-config setup is being collected and will follow.

@bcssewl bcssewl changed the title Recycle menu card hosting views across data-only rebuilds Recycle menu card hosting views and reconcile menu content in place Jun 10, 2026
@bcssewl

bcssewl commented Jun 10, 2026

Copy link
Copy Markdown
Contributor Author

Round 3, from continued field profiling of the round-2 build on the same real multi-provider setup.

What the new trace showed: the all-or-nothing reconcile rarely engaged on provider switches — providers differ in their bottom action sections, so the skeleton check failed and the wholesale fallback ran. The menu.addItem loop in that fallback was the hottest remaining CodexBar frame (805 of 1,565 populate samples). The same trace also caught the switcher shortcut monitor's per-runloop-pass NSApp.nextEvent peek costing 1,348 samples — filed separately as #1397 since it's an independent mechanism.

Changes (84bfe549, 61fccf54):

  • The reconciler now matches rows from both ends: the expensive card rows at the top and the shared action rows at the bottom (Settings/Quit/Refresh are identical across providers) are updated in place, and only the differing middle span is removed and reinserted. A cross-provider switch now churns a handful of cheap rows instead of the whole region.
  • Test contract update, disclosed explicitly: StatusMenuSwitcherRefreshTests asserted the parked-items mechanism via object identity ("switch restores the cached item objects", "required invalidation mints new items"). In-place reconciliation intentionally changes that mechanism: the same NSMenuItem objects carry freshly built content, which is what keeps AppKit from relayouting the open menu per insert. The rewritten tests assert the behaviors those tests actually protected — identity stability across switches, the live menu marked fresh after a required invalidation, and no cached entry predating it — with in-place identity covered deterministically in MenuCardViewRecyclingTests (now 8 tests, including "reconcile keeps matching edge rows when the middle differs"). The wholesale-reattach instant path is kept for switches whose incoming cache entry is still valid.
  • Also dropped a stray build-stamped project.pbxproj hunk that had slipped into an earlier commit (fa922c22).

Validation: make check 0 violations; full suite green across repeated runs except the known rotating locale/load flakes (MiniMaxMenuCardModelTests, HistoricalUsagePaceOwnershipTests, OpenAIDashboardWebViewCacheTests, StatusMenuSwitcherTrackingTests under load), each of which reproduces on clean main and passes in isolation.

@bcssewl

bcssewl commented Jun 10, 2026

Copy link
Copy Markdown
Contributor Author

Promised packaged-app follow-up — before/after from the affected real multi-provider setup (Apple Silicon, merged icons, daily-driver config).

Before (current release lineage): 90s sample during ordinary menu interaction: 23,713 of 67,237 main-thread samples inside the menu-tracking session, dominated by recursive _layoutSubtreeWithOldSize storms under display-cycle flushes, sizeThatFits (1,134 hits), AttributeGraph rebuilds (1,033), with reported beachballs matching CPU bursts of 20–30% on the interaction ticker.

Intermediate (recycling only, 2f5cf690): per-interaction-normalized populate cost flat during switch-heavy use — which is what motivated the reconciler: the trace pinned 805 of 1,565 populate samples on the wholesale fallback's menu.addItem loop and another 1,348 on the shortcut monitor's per-pass event peek (now #1397).

After (edge reconcile + #1397, combined build): interactive verification on the same setup reports responsive switching with no beachballs; both previously dominant paths are eliminated by construction — same-shape rows update in place (zero remove/insert during tracking, enforced by the item-identity regression test) and the event peek runs only when a key/click counter advanced.

Happy to attach redacted raw sample excerpts for any of the three stages if useful.

steipete and others added 2 commits June 10, 2026 20:09
@steipete
steipete force-pushed the perf/recycle-menu-card-hosting-views branch from fa922c2 to 7918c08 Compare June 10, 2026 19:18
@steipete
steipete merged commit f927e8a into steipete:main Jun 10, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

merge-risk: 🚨 availability 🚨 Merging this PR could cause crashes, hangs, restart loops, stalls, or process outages. P2 Normal priority bug or improvement with limited blast radius. rating: 🧂 unranked krab Not merge-ready due to missing proof or serious correctness/safety concerns. status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants