From 15bc3f73529f05ebe41639cb5badbcfd424e9a37 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 1 Oct 2026 15:08:54 -0400 Subject: [PATCH 1/5] cmux-next agent pane: tests for the crash reload loop and a closed pane held by its handshake Red first. A page whose web content process keeps crashing reloads forever, and the bridge keeps a closed pane (and its web view) alive for as long as the handshake waits on acpmux. The bridge's reply moves into reply(to:) unchanged so a test can drive it; AgentPaneCrashReloads is a stub that always reloads, today's behavior. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RYQHfug1ZVQDp4eWgwVUtD --- .../CmuxNextAgentPane/AgentPaneBridge.swift | 10 +++- .../AgentPaneCrashReloads.swift | 16 ++++++ .../AgentPaneBridgeTests.swift | 55 +++++++++++++++++++ .../AgentPaneCrashReloadsTests.swift | 21 +++++++ 4 files changed, 99 insertions(+), 3 deletions(-) create mode 100644 Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneCrashReloads.swift create mode 100644 Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneBridgeTests.swift create mode 100644 Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneCrashReloadsTests.swift diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneBridge.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneBridge.swift index 743ee41873f4..0a1f012f6c45 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneBridge.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneBridge.swift @@ -21,12 +21,16 @@ final class AgentPaneBridge: NSObject, WKScriptMessageHandlerWithReply { else { return (AgentPaneReply.failure(code: "untrusted_frame", message: "Untrusted frame"), nil) } - let request = AgentPaneRequest(body: message.body) + return (await reply(to: AgentPaneRequest(body: message.body)), nil) + } + + /// The reply for a request from the pane's trusted page. + func reply(to request: AgentPaneRequest) async -> [String: Any] { + guard let view else { return AgentPaneReply.failure(code: "closed", message: "Closed") } // The page installs its bridge and registry before asking for the // handshake, which can be after didFinish; replay the customization // so registry.js finds them. if request == .ready { view.replayCustomization() } - let reply = await view.model.respond(to: request) - return (reply, nil) + return await view.model.respond(to: request) } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneCrashReloads.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneCrashReloads.swift new file mode 100644 index 000000000000..e7dde24af247 --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneCrashReloads.swift @@ -0,0 +1,16 @@ +import Foundation + +/// Decides whether a pane reloads its page after the web content process +/// crashes. A page that crashes as it loads would otherwise reload forever. +nonisolated struct AgentPaneCrashReloads: Equatable, Sendable { + /// Reloads allowed within ``window``. + static let limit = 3 + static let window: TimeInterval = 60 + + private var crashes: [Date] = [] + + /// Records a crash at `now`; true when the page should reload. + mutating func shouldReload(at now: Date) -> Bool { + true + } +} diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneBridgeTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneBridgeTests.swift new file mode 100644 index 000000000000..333fb854ae93 --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneBridgeTests.swift @@ -0,0 +1,55 @@ +import AppKit +import Foundation +import Testing +@testable import CmuxNextAgentPane + +/// Holds every handshake until ``open()``. +private actor GatedHost: AgentPaneHostProviding { + private var asked = false + private var waiting: CheckedContinuation? + private var gate: CheckedContinuation? + + func handshake(sessionId: String?) async throws -> AgentPaneHandshake { + asked = true + waiting?.resume() + waiting = nil + await withCheckedContinuation { gate = $0 } + return .mock + } + + func waitUntilAsked() async { + if asked { return } + await withCheckedContinuation { waiting = $0 } + } + + func open() { + gate?.resume() + gate = nil + } +} + +@MainActor +@Suite struct AgentPaneBridgeTests { + private final class Weak { + weak var view: AgentPaneView? + } + + /// Closing a tab must free its pane (and web view) even while acpmux is + /// still starting for the handshake, which can take up to 20 seconds. + @Test func aClosedPaneIsFreedWhileItsHandshakeIsPending() async throws { + let host = GatedHost() + let page = FileManager.default.temporaryDirectory.appendingPathComponent("agent-pane-bridge-test.html") + let weak = Weak() + let bridge: AgentPaneBridge = try autoreleasepool { + let view = try #require(AgentPaneView(model: AgentPaneModel(host: host), source: .bundled(page))) + weak.view = view + return AgentPaneBridge(view: view) + } + let transport = Task { (await bridge.reply(to: .ready)["value"] as? [String: Any])?["transport"] as? String } + await host.waitUntilAsked() + autoreleasepool { weak.view?.close() } + #expect(weak.view == nil) + await host.open() + #expect(await transport.value == "mock") + } +} diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneCrashReloadsTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneCrashReloadsTests.swift new file mode 100644 index 000000000000..9068f5969da6 --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneCrashReloadsTests.swift @@ -0,0 +1,21 @@ +import Foundation +import Testing +@testable import CmuxNextAgentPane + +@Suite struct AgentPaneCrashReloadsTests { + private let start = Date(timeIntervalSinceReferenceDate: 1_000) + + @Test func aPageThatKeepsCrashingStopsReloading() { + var reloads = AgentPaneCrashReloads() + let decisions = (0..<5).map { reloads.shouldReload(at: start.addingTimeInterval(Double($0))) } + #expect(decisions == [true, true, true, false, false]) + } + + @Test func crashesOutsideTheWindowAreForgotten() { + var reloads = AgentPaneCrashReloads() + for second in 0.. Date: Thu, 1 Oct 2026 15:12:58 -0400 Subject: [PATCH 2/5] cmux-next agent pane: crash reload test calls the mutating check outside #expect Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RYQHfug1ZVQDp4eWgwVUtD --- .../AgentPaneCrashReloadsTests.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneCrashReloadsTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneCrashReloadsTests.swift index 9068f5969da6..165e0595a1f4 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneCrashReloadsTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneCrashReloadsTests.swift @@ -13,9 +13,9 @@ import Testing @Test func crashesOutsideTheWindowAreForgotten() { var reloads = AgentPaneCrashReloads() - for second in 0.. Date: Thu, 1 Oct 2026 15:29:09 -0400 Subject: [PATCH 3/5] cmux-next agent pane: the bridge test holds the pane until its handshake is pending Nothing held the pane strongly, so it was freed before the reply asked the host, and the test waited forever for a handshake that never came. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RYQHfug1ZVQDp4eWgwVUtD --- .../AgentPaneBridgeTests.swift | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneBridgeTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneBridgeTests.swift index 333fb854ae93..266fd5462bd4 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneBridgeTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneBridgeTests.swift @@ -40,14 +40,17 @@ private actor GatedHost: AgentPaneHostProviding { let host = GatedHost() let page = FileManager.default.temporaryDirectory.appendingPathComponent("agent-pane-bridge-test.html") let weak = Weak() - let bridge: AgentPaneBridge = try autoreleasepool { - let view = try #require(AgentPaneView(model: AgentPaneModel(host: host), source: .bundled(page))) - weak.view = view - return AgentPaneBridge(view: view) + var view: AgentPaneView? = try autoreleasepool { + try #require(AgentPaneView(model: AgentPaneModel(host: host), source: .bundled(page))) } + weak.view = view + let bridge = try #require(view.map { AgentPaneBridge(view: $0) }) let transport = Task { (await bridge.reply(to: .ready)["value"] as? [String: Any])?["transport"] as? String } await host.waitUntilAsked() - autoreleasepool { weak.view?.close() } + autoreleasepool { + view?.close() + view = nil + } #expect(weak.view == nil) await host.open() #expect(await transport.value == "mock") From c57ecf89f7370de2f9d9981fc80b60b998dad1b0 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 1 Oct 2026 15:38:16 -0400 Subject: [PATCH 4/5] cmux-next agent pane: cap crash reloads; a closed pane is not held by its handshake - After 3 web content crashes within 60 seconds, the pane stops reloading and shows "The agent pane crashed repeatedly." with a Reload button, which resets the count. - The bridge checks trust and replays the customization synchronously, then awaits only the model, so closing a tab frees its view and web view while acpmux is still starting for the handshake. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RYQHfug1ZVQDp4eWgwVUtD --- .../CmuxNextAgentPane/AgentPaneBridge.swift | 22 +- .../AgentPaneCrashReloads.swift | 4 +- .../AgentPaneNavigation.swift | 6 +- .../CmuxNextAgentPane/AgentPaneStrings.swift | 12 + .../CmuxNextAgentPane/AgentPaneView.swift | 39 +++ .../Resources/Localizable.xcstrings | 262 ++++++++++++++++++ 6 files changed, 336 insertions(+), 9 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneBridge.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneBridge.swift index 0a1f012f6c45..9dab9e44681f 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneBridge.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneBridge.swift @@ -16,9 +16,7 @@ final class AgentPaneBridge: NSObject, WKScriptMessageHandlerWithReply { } func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) async -> (Any?, String?) { - guard let view, message.webView === view.webView, message.frameInfo.isMainFrame, - view.source.isTrusted(message.frameInfo.request.url) - else { + guard isTrusted(message) else { return (AgentPaneReply.failure(code: "untrusted_frame", message: "Untrusted frame"), nil) } return (await reply(to: AgentPaneRequest(body: message.body)), nil) @@ -26,11 +24,25 @@ final class AgentPaneBridge: NSObject, WKScriptMessageHandlerWithReply { /// The reply for a request from the pane's trusted page. func reply(to request: AgentPaneRequest) async -> [String: Any] { - guard let view else { return AgentPaneReply.failure(code: "closed", message: "Closed") } + guard let model = prepare(for: request) else { return AgentPaneReply.failure(code: "closed", message: "Closed") } + // The handshake can wait up to 20 seconds for acpmux to start. Only + // the model is held across it, so closing the tab frees the view and + // its web view right away. + return await model.respond(to: request) + } + + private func isTrusted(_ message: WKScriptMessage) -> Bool { + guard let view else { return false } + return message.webView === view.webView && message.frameInfo.isMainFrame + && view.source.isTrusted(message.frameInfo.request.url) + } + + private func prepare(for request: AgentPaneRequest) -> AgentPaneModel? { + guard let view else { return nil } // The page installs its bridge and registry before asking for the // handshake, which can be after didFinish; replay the customization // so registry.js finds them. if request == .ready { view.replayCustomization() } - return await view.model.respond(to: request) + return view.model } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneCrashReloads.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneCrashReloads.swift index e7dde24af247..aa5a6ee1b36a 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneCrashReloads.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneCrashReloads.swift @@ -11,6 +11,8 @@ nonisolated struct AgentPaneCrashReloads: Equatable, Sendable { /// Records a crash at `now`; true when the page should reload. mutating func shouldReload(at now: Date) -> Bool { - true + crashes.removeAll { now.timeIntervalSince($0) >= Self.window } + crashes.append(now) + return crashes.count <= Self.limit } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneNavigation.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneNavigation.swift index c76493af7255..2a8ad7ee87c4 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneNavigation.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneNavigation.swift @@ -24,10 +24,10 @@ final class AgentPaneNavigation: NSObject, WKNavigationDelegate { view?.replayCustomization() } - /// A crashed web content process leaves a blank pane; reload the page, - /// which asks for a fresh handshake and reattaches the session. + /// A crashed web content process leaves a blank pane; the view reloads + /// the page, which asks for a fresh handshake and reattaches the session. func webViewWebContentProcessDidTerminate(_ webView: WKWebView) { - view?.source.load(into: webView) + view?.webContentProcessDidTerminate() } enum Decision: Equatable { diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneStrings.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneStrings.swift index 06721b584cb7..7329394c6613 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneStrings.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneStrings.swift @@ -1,3 +1,4 @@ +import AppKit import Foundation // User-facing text of the agent pane host. The page's own text is in the @@ -10,6 +11,17 @@ extension AgentPaneModel { } } +extension AgentPaneView { + /// Shown when the pane's page keeps crashing and no longer reloads itself. + static var crashedMessage: String { + String(localized: "agentPane.crashed.message", defaultValue: "The agent pane crashed repeatedly.", bundle: .module) + } + + static var reloadTitle: String { + String(localized: "agentPane.crashed.reload", defaultValue: "Reload", bundle: .module) + } +} + extension AgentPaneHostError { /// What the page shows for `error`; anything but a host error reads as a /// timeout. diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView.swift index c76c6e9d596b..c4d36ad83182 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView.swift @@ -25,6 +25,9 @@ public final class AgentPaneView: NSView { } } private let navigation = AgentPaneNavigation() + private var crashReloads = AgentPaneCrashReloads() + /// Shown instead of reloading once the page keeps crashing. + private var crashNotice: NSView? /// The bundled page, nil when it is missing (a broken build). public static var bundledPage: URL? { @@ -89,6 +92,42 @@ public final class AgentPaneView: NSView { applyTheme() } + /// Reloads the page after its web content process crashed, unless it + /// keeps crashing; then the pane says so and waits for the user. + func webContentProcessDidTerminate() { + if crashReloads.shouldReload(at: .now) { + source.load(into: webView) + } else { + showCrashNotice() + } + } + + private func showCrashNotice() { + guard crashNotice == nil else { return } + let message = NSTextField(wrappingLabelWithString: Self.crashedMessage) + message.alignment = .center + message.textColor = .secondaryLabelColor + let reload = NSButton(title: Self.reloadTitle, target: self, action: #selector(reloadAfterCrashes)) + let notice = NSStackView(views: [message, reload]) + notice.orientation = .vertical + notice.spacing = 12 + notice.translatesAutoresizingMaskIntoConstraints = false + addSubview(notice) + NSLayoutConstraint.activate([ + notice.centerXAnchor.constraint(equalTo: centerXAnchor), + notice.centerYAnchor.constraint(equalTo: centerYAnchor), + notice.widthAnchor.constraint(lessThanOrEqualTo: widthAnchor, constant: -48), + ]) + crashNotice = notice + } + + @objc private func reloadAfterCrashes() { + crashNotice?.removeFromSuperview() + crashNotice = nil + crashReloads = AgentPaneCrashReloads() + source.load(into: webView) + } + /// Pushes ``customization`` to the page, even an empty one (it clears /// what removed files left behind). func applyCustomization() { diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Resources/Localizable.xcstrings b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Resources/Localizable.xcstrings index 49e787c90e8c..be9359f23fa4 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Resources/Localizable.xcstrings +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Resources/Localizable.xcstrings @@ -1,6 +1,268 @@ { "sourceLanguage": "en", "strings": { + "agentPane.crashed.message": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعطلت لوحة الوكيل بشكل متكرر." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Panel agenta se više puta srušio." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Agentpanelet gik ned gentagne gange." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Der Agentenbereich ist wiederholt abgestürzt." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "The agent pane crashed repeatedly." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "El panel del agente se bloqueó repetidamente." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Le panneau de l’agent a planté à plusieurs reprises." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Il pannello dell’agente si è arrestato ripetutamente." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エージェントペインが繰り返しクラッシュしました。" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ផ្ទាំងភ្នាក់ងារបានគាំងម្តងហើយម្តងទៀត។" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "에이전트 패널이 반복해서 충돌했습니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Agentpanelet krasjet gjentatte ganger." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Panel agenta wielokrotnie uległ awarii." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "O painel do agente travou repetidamente." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Панель агента несколько раз аварийно завершилась." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "บานหน้าต่างเอเจนต์ขัดข้องซ้ำหลายครั้ง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Ajan bölmesi art arda çöktü." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Панель агента кілька разів аварійно завершилася." + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Ngăn tác tử đã gặp sự cố nhiều lần." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "代理窗格反复崩溃。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "代理窗格反覆當機。" + } + } + } + }, + "agentPane.crashed.reload": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إعادة التحميل" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ponovo učitaj" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Genindlæs" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Neu laden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Reload" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Recargar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Recharger" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Ricarica" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "再読み込み" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ផ្ទុកឡើងវិញ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다시 로드" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Last inn på nytt" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Załaduj ponownie" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Recarregar" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Перезагрузить" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "โหลดใหม่" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yeniden yükle" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Перезавантажити" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Tải lại" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重新载入" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重新載入" + } + } + } + }, "agentPane.error.daemonFailed": { "extractionState": "manual", "localizations": { From ef8954677efb8a4d322d4b60819db6054f98f87e Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 1 Oct 2026 15:41:31 -0400 Subject: [PATCH 5/5] cmux-next agent pane: review follow-ups for the crash notice - The reload window is 10 minutes. A load-to-crash cycle can take 20 seconds or more, and with a 60 second window that slow loop never hit the cap. - The notice takes the pane's theme (appearance and secondary text color), not the system appearance, and is re-themed with the pane. - Its width inset yields in panes narrower than the inset. - The bridge test has a time limit and says which path it drives. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RYQHfug1ZVQDp4eWgwVUtD --- .../AgentPaneCrashReloads.swift | 6 ++++-- .../CmuxNextAgentPane/AgentPaneView.swift | 19 +++++++++++++++++-- .../AgentPaneBridgeTests.swift | 4 +++- 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneCrashReloads.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneCrashReloads.swift index aa5a6ee1b36a..9045045ce948 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneCrashReloads.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneCrashReloads.swift @@ -3,9 +3,11 @@ import Foundation /// Decides whether a pane reloads its page after the web content process /// crashes. A page that crashes as it loads would otherwise reload forever. nonisolated struct AgentPaneCrashReloads: Equatable, Sendable { - /// Reloads allowed within ``window``. + /// Reloads allowed within ``window``. The window is long because one + /// load-to-crash cycle can take 20 seconds or more (the handshake waits + /// for acpmux), and a slow crash loop must stop too. static let limit = 3 - static let window: TimeInterval = 60 + static let window: TimeInterval = 600 private var crashes: [Date] = [] diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView.swift index c4d36ad83182..a7a95833bc48 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView.swift @@ -106,19 +106,33 @@ public final class AgentPaneView: NSView { guard crashNotice == nil else { return } let message = NSTextField(wrappingLabelWithString: Self.crashedMessage) message.alignment = .center - message.textColor = .secondaryLabelColor let reload = NSButton(title: Self.reloadTitle, target: self, action: #selector(reloadAfterCrashes)) let notice = NSStackView(views: [message, reload]) notice.orientation = .vertical notice.spacing = 12 notice.translatesAutoresizingMaskIntoConstraints = false addSubview(notice) + let inset = notice.widthAnchor.constraint(lessThanOrEqualTo: widthAnchor, constant: -48) + // A pane narrower than the inset clips the notice instead of + // breaking the layout. + inset.priority = .defaultHigh NSLayoutConstraint.activate([ notice.centerXAnchor.constraint(equalTo: centerXAnchor), notice.centerYAnchor.constraint(equalTo: centerYAnchor), - notice.widthAnchor.constraint(lessThanOrEqualTo: widthAnchor, constant: -48), + inset, ]) crashNotice = notice + themeCrashNotice(themeTokens) + } + + /// The notice sits on the pane's background, so it takes the pane's + /// theme rather than the system appearance. + private func themeCrashNotice(_ tokens: ThemeTokens) { + guard let notice = crashNotice else { return } + notice.appearance = NSAppearance(named: tokens.isDark ? .darkAqua : .aqua) + for case let label as NSTextField in notice.subviews { + label.textColor = tokens.textSecondary.nsColor + } } @objc private func reloadAfterCrashes() { @@ -149,6 +163,7 @@ public final class AgentPaneView: NSView { func applyTheme() { let tokens = themeTokens webView.underPageBackgroundColor = tokens.contentBackground.nsColor + themeCrashNotice(tokens) guard let script = AgentPaneTheme.script(tokens) else { return } webView.evaluateJavaScript(script, completionHandler: nil) } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneBridgeTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneBridgeTests.swift index 266fd5462bd4..f3eaff7e6786 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneBridgeTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneBridgeTests.swift @@ -36,7 +36,9 @@ private actor GatedHost: AgentPaneHostProviding { /// Closing a tab must free its pane (and web view) even while acpmux is /// still starting for the handshake, which can take up to 20 seconds. - @Test func aClosedPaneIsFreedWhileItsHandshakeIsPending() async throws { + /// Drives `reply(to:)`, the path behind WebKit's message handler after + /// its trust check (a test cannot make a WKScriptMessage). + @Test(.timeLimit(.minutes(1))) func aClosedPaneIsFreedWhileItsHandshakeIsPending() async throws { let host = GatedHost() let page = FileManager.default.temporaryDirectory.appendingPathComponent("agent-pane-bridge-test.html") let weak = Weak()