From fcee00c4b789f9059ee3e4e11a4f13b37636793d Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Sat, 22 Aug 2026 14:06:02 -0700 Subject: [PATCH 01/10] iOS: tighten the toolbar title's trailing reserve The measured-width reserve for trailing toolbar items over-counted the glass chrome (24pt per item vs ~16pt rendered) and the bar margin slack (84pt vs ~78pt of real margins, spacers, and pill padding), so the leading title pill truncated next to a ~35pt dead gap before the trailing cluster. Recalibrate both constants from a 402pt iPhone 17 dogfood measurement so the title extends to within roughly the back-to-title gap of the trailing cluster, still without pushing items into the overflow More menu. Co-Authored-By: Claude Fable 5 --- .../MobileLeadingToolbarTitleWidth.swift | 11 ++++++-- .../MobileLeadingToolbarTitleWidthTests.swift | 25 +++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileLeadingToolbarTitleWidth.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileLeadingToolbarTitleWidth.swift index f39eeef83337..b12b4641e14b 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileLeadingToolbarTitleWidth.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileLeadingToolbarTitleWidth.swift @@ -33,9 +33,16 @@ struct MobileLeadingToolbarTitleWidth { static let backButtonReserve: CGFloat = 44 static let trailingReserveBase: CGFloat = 64 static let chatToggleReserve: CGFloat = 60 - static let barMarginsAndSpacing: CGFloat = 84 + /// Bar side margins, the back-to-title spacer, the title pill's own glass + /// padding, and the minimum title-to-trailing gap. Calibrated against a + /// 402pt iPhone 17 bar (16 + 17 side margins, 13 back gap, ~18 pill + /// padding, 13 minimum trailing gap ≈ the back-to-title gap) so the title + /// stops just short of the trailing cluster instead of leaving a dead gap. + static let barMarginsAndSpacing: CGFloat = 78 /// Horizontal glass-capsule chrome around one trailing item's content. - static let trailingItemChrome: CGFloat = 24 + /// Measured: a 2-item trailing group renders ~33pt wider than the sum of + /// its content widths, so ~16pt per item, not the 24pt first estimated. + static let trailingItemChrome: CGFloat = 16 /// Safe content-width reserve for a structural item that has not reported /// geometry yet. static let unmeasuredTrailingItemReserve: CGFloat = 64 diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileLeadingToolbarTitleWidthTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileLeadingToolbarTitleWidthTests.swift index 287192a2a8c6..37a1ecb90b3c 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileLeadingToolbarTitleWidthTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileLeadingToolbarTitleWidthTests.swift @@ -107,6 +107,31 @@ import Testing #expect(unmeasured.cap == cap(393)) } + @Test func measuredBranchLeavesOnlyAMinimalTrailingGap() { + // Dogfood calibration on a 402pt iPhone 17 bar (changes chip ~30 + + // trailing cluster 96 measured): the earlier 24pt/item chrome plus + // 84pt margin reserve left a ~35pt dead gap before the trailing + // cluster while the title truncated. The recalibrated reserves must + // hand that slack to the title, leaving only a back-gap-sized space. + let value = MobileLeadingToolbarTitleWidth( + contentWidth: 402, + hasBackButton: true, + hasTrailingCluster: true, + hasChatToggle: true, + measuredTrailingItemsWidth: 126, + measuredTrailingItemCount: 2, + trailingItemCount: 2 + ) + let expected: CGFloat = 402 + - MobileLeadingToolbarTitleWidth.backButtonReserve + - (126 + 2 * MobileLeadingToolbarTitleWidth.trailingItemChrome) + - MobileLeadingToolbarTitleWidth.barMarginsAndSpacing + + #expect(value.cap == expected) + // 100pt under the old reserves; the freed ~22pt goes to the title. + #expect(value.cap >= 120) + } + @Test func structuralItemWithoutMeasurementStillReservesSpace() { // The changes chip just appeared: the cluster has measured, the chip // has not. The cap must not expand into the chip's space, or the From 9ed65202d440be5df3dffac801090e1d7721853d Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Sat, 22 Aug 2026 14:29:21 -0700 Subject: [PATCH 02/10] Revert "iOS: tighten the toolbar title's trailing reserve" This reverts commit fcee00c4b789f9059ee3e4e11a4f13b37636793d. --- .../MobileLeadingToolbarTitleWidth.swift | 11 ++------ .../MobileLeadingToolbarTitleWidthTests.swift | 25 ------------------- 2 files changed, 2 insertions(+), 34 deletions(-) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileLeadingToolbarTitleWidth.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileLeadingToolbarTitleWidth.swift index b12b4641e14b..f39eeef83337 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileLeadingToolbarTitleWidth.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileLeadingToolbarTitleWidth.swift @@ -33,16 +33,9 @@ struct MobileLeadingToolbarTitleWidth { static let backButtonReserve: CGFloat = 44 static let trailingReserveBase: CGFloat = 64 static let chatToggleReserve: CGFloat = 60 - /// Bar side margins, the back-to-title spacer, the title pill's own glass - /// padding, and the minimum title-to-trailing gap. Calibrated against a - /// 402pt iPhone 17 bar (16 + 17 side margins, 13 back gap, ~18 pill - /// padding, 13 minimum trailing gap ≈ the back-to-title gap) so the title - /// stops just short of the trailing cluster instead of leaving a dead gap. - static let barMarginsAndSpacing: CGFloat = 78 + static let barMarginsAndSpacing: CGFloat = 84 /// Horizontal glass-capsule chrome around one trailing item's content. - /// Measured: a 2-item trailing group renders ~33pt wider than the sum of - /// its content widths, so ~16pt per item, not the 24pt first estimated. - static let trailingItemChrome: CGFloat = 16 + static let trailingItemChrome: CGFloat = 24 /// Safe content-width reserve for a structural item that has not reported /// geometry yet. static let unmeasuredTrailingItemReserve: CGFloat = 64 diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileLeadingToolbarTitleWidthTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileLeadingToolbarTitleWidthTests.swift index 37a1ecb90b3c..287192a2a8c6 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileLeadingToolbarTitleWidthTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileLeadingToolbarTitleWidthTests.swift @@ -107,31 +107,6 @@ import Testing #expect(unmeasured.cap == cap(393)) } - @Test func measuredBranchLeavesOnlyAMinimalTrailingGap() { - // Dogfood calibration on a 402pt iPhone 17 bar (changes chip ~30 + - // trailing cluster 96 measured): the earlier 24pt/item chrome plus - // 84pt margin reserve left a ~35pt dead gap before the trailing - // cluster while the title truncated. The recalibrated reserves must - // hand that slack to the title, leaving only a back-gap-sized space. - let value = MobileLeadingToolbarTitleWidth( - contentWidth: 402, - hasBackButton: true, - hasTrailingCluster: true, - hasChatToggle: true, - measuredTrailingItemsWidth: 126, - measuredTrailingItemCount: 2, - trailingItemCount: 2 - ) - let expected: CGFloat = 402 - - MobileLeadingToolbarTitleWidth.backButtonReserve - - (126 + 2 * MobileLeadingToolbarTitleWidth.trailingItemChrome) - - MobileLeadingToolbarTitleWidth.barMarginsAndSpacing - - #expect(value.cap == expected) - // 100pt under the old reserves; the freed ~22pt goes to the title. - #expect(value.cap >= 120) - } - @Test func structuralItemWithoutMeasurementStillReservesSpace() { // The changes chip just appeared: the cluster has measured, the chip // has not. The cap must not expand into the chip's space, or the From 35b45a9fc7ff555a8440219a508f7cd68b023b58 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Sat, 22 Aug 2026 16:21:56 -0700 Subject: [PATCH 03/10] iOS: size the toolbar title from the measured bar gap The estimate-based trailing reserve must over-count chrome to stay safe, which leaves a dead gap between the truncated title pill and the trailing cluster. Instead of shrinking those constants (reverted: it collapsed trailing items into More on the phone), measure both edges of the gap: the fitted title label's leading edge and the leading-most trailing item's content edge, each captured with the pane width it was measured at. When every structural item and the title have reported at the current width (LTR only), the cap becomes that realized span minus a 40pt reserve (pill chrome + capsule chrome + a back-gap-sized minimum gap). The title's leading edge and the trailing positions do not depend on the title's width, so there is no feedback loop, and the title only consumes space the system actually rendered as empty, so it cannot over-commit the bar. Stale-width or incomplete measurements fall back to the unchanged estimate reserve. Co-Authored-By: Claude Fable 5 --- .../AgentChatDemoScreen.swift | 1 + .../MobileLeadingToolbarTitleWidth.swift | 24 +++++++- .../TrailingToolbarItemMeasurement.swift | 47 ++++++++++++++-- .../WorkspaceDetailView.swift | 45 ++++++++++++--- .../WorkspaceTitleMenu.swift | 14 ++++- .../WorkspaceTitleMenuValue.swift | 2 + .../MobileLeadingToolbarTitleWidthTests.swift | 56 +++++++++++++++++++ .../WorkspaceTitleMenuValueTests.swift | 1 + 8 files changed, 175 insertions(+), 15 deletions(-) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentChatDemoScreen.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentChatDemoScreen.swift index 8a52ee29e8d5..5c851a10d3f4 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentChatDemoScreen.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentChatDemoScreen.swift @@ -108,6 +108,7 @@ struct AgentChatDemoScreen: View { measuredTrailingItemsWidth: 0, measuredTrailingItemCount: 0, trailingItemCount: 0, + measuredTitleToTrailingSpan: nil, isEnabled: true, workspaceName: inlineWorkspaceTitle ?? "", hasUnread: false, diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileLeadingToolbarTitleWidth.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileLeadingToolbarTitleWidth.swift index f39eeef83337..facd208f4e11 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileLeadingToolbarTitleWidth.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileLeadingToolbarTitleWidth.swift @@ -29,6 +29,14 @@ struct MobileLeadingToolbarTitleWidth { /// How many trailing toolbar items are structurally visible right now; /// each carries its own glass capsule chrome around the measured content. let trailingItemCount: Int + /// Rendered distance from the title content's leading edge to the + /// leading-most trailing item content's leading edge, when every + /// structural trailing item and the title label have reported geometry at + /// the current pane width (LTR only). This is the system's realized + /// layout, so a cap derived from it can only consume space that is + /// actually empty on screen: unlike the estimate constants it can never + /// over-commit the bar and bounce trailing items into the More menu. + let measuredTitleToTrailingSpan: CGFloat? static let backButtonReserve: CGFloat = 44 static let trailingReserveBase: CGFloat = 64 @@ -41,6 +49,11 @@ struct MobileLeadingToolbarTitleWidth { static let unmeasuredTrailingItemReserve: CGFloat = 64 static let unmeasuredFallback: CGFloat = 140 static let floor: CGFloat = 96 + /// Subtracted from the measured span before the title may use it: the + /// title pill's trailing glass chrome plus the trailing capsule's leading + /// chrome (~13pt each) plus a minimum visual gap matching the + /// back-to-title spacing (~13pt). + static let spanGapReserve: CGFloat = 40 init( contentWidth: CGFloat, @@ -49,7 +62,8 @@ struct MobileLeadingToolbarTitleWidth { hasChatToggle: Bool, measuredTrailingItemsWidth: CGFloat = 0, measuredTrailingItemCount: Int = 0, - trailingItemCount: Int = 0 + trailingItemCount: Int = 0, + measuredTitleToTrailingSpan: CGFloat? = nil ) { self.contentWidth = contentWidth self.hasBackButton = hasBackButton @@ -58,10 +72,18 @@ struct MobileLeadingToolbarTitleWidth { self.measuredTrailingItemsWidth = measuredTrailingItemsWidth self.measuredTrailingItemCount = measuredTrailingItemCount self.trailingItemCount = trailingItemCount + self.measuredTitleToTrailingSpan = measuredTitleToTrailingSpan } var cap: CGFloat { guard contentWidth > 0 else { return Self.unmeasuredFallback } + // Realized-layout path: both edges of the gap were measured at this + // width, so size the title from actual positions. The title's leading + // edge and the trailing items' positions do not depend on the title's + // width, so this does not feed back into itself. + if let span = measuredTitleToTrailingSpan, span > 0 { + return max(0, span - Self.spanGapReserve) + } let leading = hasBackButton ? Self.backButtonReserve : 0 return max(0, contentWidth - leading - trailingReserve - Self.barMarginsAndSpacing) } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TrailingToolbarItemMeasurement.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TrailingToolbarItemMeasurement.swift index 3574f3f28da6..af3b0f8fb389 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TrailingToolbarItemMeasurement.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TrailingToolbarItemMeasurement.swift @@ -1,7 +1,20 @@ import SwiftUI +/// One trailing toolbar item's rendered content geometry, captured together +/// with the pane width it was measured at so a stale value from before a +/// rotation or split-width change is never mixed into current-width math. +struct TrailingToolbarItemGeometry: Equatable { + /// Rendered content width (inside the glass capsule chrome). + var width: CGFloat + /// Leading edge of the content in the global space, used to derive the + /// real title-to-trailing span. + var globalMinX: CGFloat + /// The pane width current when this measurement fired. + var contentWidth: CGFloat +} + extension View { - /// Reports this trailing toolbar item's rendered content width into the + /// Reports this trailing toolbar item's rendered content geometry into the /// shared measurement dictionary. /// /// The leading title menu caps its width to the bar's remaining space so @@ -12,12 +25,34 @@ extension View { /// remove an item clear its key from the condition that removed it. func measureTrailingToolbarItem( _ key: String, - into widths: Binding<[String: CGFloat]> + into geometry: Binding<[String: TrailingToolbarItemGeometry]>, + contentWidth: @escaping @autoclosure () -> CGFloat ) -> some View { - onGeometryChange(for: CGFloat.self) { proxy in - proxy.size.width - } action: { width in - widths.wrappedValue[key] = width + onGeometryChange(for: TrailingToolbarItemProbe.self) { proxy in + TrailingToolbarItemProbe( + width: proxy.size.width, + globalMinX: proxy.frame(in: .global).minX + ) + } action: { probe in + geometry.wrappedValue[key] = TrailingToolbarItemGeometry( + width: probe.width, + globalMinX: probe.globalMinX, + contentWidth: contentWidth() + ) } } } + +/// Equatable payload for `onGeometryChange`, which only re-fires when the +/// reduced value changes. +private struct TrailingToolbarItemProbe: Equatable { + var width: CGFloat + var globalMinX: CGFloat +} + +/// The fitted title label's leading edge, captured with the pane width it was +/// measured at (same staleness contract as `TrailingToolbarItemGeometry`). +struct WorkspaceTitleLabelEdge: Equatable { + var globalMinX: CGFloat + var contentWidth: CGFloat +} diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift index 7b5a42c86666..f3e27263fab0 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift @@ -81,7 +81,12 @@ struct WorkspaceDetailView: View { // deletes an entry (see the onChange handlers); a layout-driven // disappearance (overflow into More) cannot release a reservation and // make the collapse sticky. - @State private var trailingToolbarItemWidths: [String: CGFloat] = [:] + @State private var trailingToolbarItemGeometry: [String: TrailingToolbarItemGeometry] = [:] + /// The fitted title label's leading edge, with the pane width it was + /// measured at; pairs with the trailing item geometry to derive the + /// realized title-to-trailing span. + @State private var titleLabelLeadingEdge: WorkspaceTitleLabelEdge? + @Environment(\.layoutDirection) private var layoutDirection /// Terminal captured for the current "View as Text" sheet presentation. @State private var textSheetSurfaceID: String? /// Identity of the in-flight New Browser creation. A late RPC result must @@ -212,10 +217,10 @@ struct WorkspaceDetailView: View { // disappearance (overflow into More) never flips these conditions, // so it cannot release a reservation and make the collapse sticky. .onChange(of: workspaceChangesAreAvailable) { _, isAvailable in - if !isAvailable { trailingToolbarItemWidths["changes"] = nil } + if !isAvailable { trailingToolbarItemGeometry["changes"] = nil } } .onChange(of: altScreenNoticeIsVisible) { _, isVisible in - if !isVisible { trailingToolbarItemWidths["altscreen-notice"] = nil } + if !isVisible { trailingToolbarItemGeometry["altscreen-notice"] = nil } } .onAppear { refreshWorkspaceChangesHint() } .onChange(of: workspaceChangesHintEligibilityKey) { _, _ in @@ -369,7 +374,7 @@ struct WorkspaceDetailView: View { AltScreenNoticeButton { displaySettings.showAltScreenNotice = false } - .measureTrailingToolbarItem("altscreen-notice", into: $trailingToolbarItemWidths) + .measureTrailingToolbarItem("altscreen-notice", into: $trailingToolbarItemGeometry, contentWidth: contentWidth) } private var workspaceChangesToolbarContent: some View { @@ -381,12 +386,12 @@ struct WorkspaceDetailView: View { // The chrome sits on the terminal theme's background, not the // system scheme; resolve the counts' green/red for that. .environment(\.colorScheme, store.activeTerminalTheme.terminalColorScheme) - .measureTrailingToolbarItem("changes", into: $trailingToolbarItemWidths) + .measureTrailingToolbarItem("changes", into: $trailingToolbarItemGeometry, contentWidth: contentWidth) } private var trailingClusterToolbarContent: some View { toolbarTrailingCluster - .measureTrailingToolbarItem("trailing-cluster", into: $trailingToolbarItemWidths) + .measureTrailingToolbarItem("trailing-cluster", into: $trailingToolbarItemGeometry, contentWidth: contentWidth) } // Which trailing toolbar items are structurally in the bar right now. @@ -398,8 +403,27 @@ struct WorkspaceDetailView: View { return keys } + /// The realized distance from the title label's leading edge to the + /// leading-most trailing item's content, only when the title and every + /// structural trailing item have reported geometry at the current pane + /// width. Stale-width measurements (mid-rotation) and RTL (where the + /// global-x projection would need mirroring) return nil, falling back to + /// the estimate-based reserve. + private var measuredTitleToTrailingSpan: CGFloat? { + guard layoutDirection == .leftToRight, + let titleEdge = titleLabelLeadingEdge, + titleEdge.contentWidth == contentWidth else { return nil } + let geometries = structuralTrailingItemKeys.compactMap { trailingToolbarItemGeometry[$0] } + guard geometries.count == structuralTrailingItemKeys.count, + geometries.allSatisfy({ $0.contentWidth == contentWidth }), + let trailingLeadingEdge = geometries.map(\.globalMinX).min() + else { return nil } + let span = trailingLeadingEdge - titleEdge.globalMinX + return span > 0 ? span : nil + } + private var workspaceTitleToolbarMenu: some View { - let measuredWidths = structuralTrailingItemKeys.compactMap { trailingToolbarItemWidths[$0] } + let measuredWidths = structuralTrailingItemKeys.compactMap { trailingToolbarItemGeometry[$0]?.width } let value = WorkspaceTitleMenuValue( contentWidth: contentWidth, hasBackButton: backButtonConfiguration != nil, @@ -408,6 +432,7 @@ struct WorkspaceDetailView: View { measuredTrailingItemsWidth: measuredWidths.reduce(0, +), measuredTrailingItemCount: measuredWidths.count, trailingItemCount: structuralTrailingItemKeys.count, + measuredTitleToTrailingSpan: measuredTitleToTrailingSpan, isEnabled: hasTitleMenuActions, workspaceName: workspace.name, hasUnread: workspace.hasUnread, @@ -420,6 +445,12 @@ struct WorkspaceDetailView: View { ) return WorkspaceTitleMenu( value: value, + onLabelLeadingEdgeChange: { minX in + titleLabelLeadingEdge = WorkspaceTitleLabelEdge( + globalMinX: minX, + contentWidth: contentWidth + ) + }, menuContent: { WorkspaceTitleMenuContent( workspaceName: value.workspaceName, diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceTitleMenu.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceTitleMenu.swift index 72b90a05e772..f5966557cf70 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceTitleMenu.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceTitleMenu.swift @@ -2,6 +2,10 @@ import SwiftUI struct WorkspaceTitleMenu: View, Equatable { let value: WorkspaceTitleMenuValue + /// Reports the fitted label's leading edge in global space so the host + /// can derive the realized title-to-trailing span. Not part of equality: + /// geometry callbacks re-fire on layout changes regardless. + var onLabelLeadingEdgeChange: ((CGFloat) -> Void)? = nil @ViewBuilder let menuContent: () -> MenuContent @ViewBuilder let label: () -> Label @@ -36,7 +40,8 @@ struct WorkspaceTitleMenu: View, Equatable { hasChatToggle: value.hasChatToggle, measuredTrailingItemsWidth: value.measuredTrailingItemsWidth, measuredTrailingItemCount: value.measuredTrailingItemCount, - trailingItemCount: value.trailingItemCount + trailingItemCount: value.trailingItemCount, + measuredTitleToTrailingSpan: value.measuredTitleToTrailingSpan ).cap return label() @@ -45,5 +50,12 @@ struct WorkspaceTitleMenu: View, Equatable { maxWidth: cap, alignment: .leading ) + // The frame is leading-aligned, so its leading edge does not move + // as the cap grows; reporting it cannot feed back into the cap. + .onGeometryChange(for: CGFloat.self) { proxy in + proxy.frame(in: .global).minX + } action: { minX in + onLabelLeadingEdgeChange?(minX) + } } } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceTitleMenuValue.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceTitleMenuValue.swift index 4795a9d3e1fc..df6efb8aec8e 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceTitleMenuValue.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceTitleMenuValue.swift @@ -9,6 +9,8 @@ struct WorkspaceTitleMenuValue: Equatable { let measuredTrailingItemsWidth: CGFloat let measuredTrailingItemCount: Int let trailingItemCount: Int + /// Realized title-to-trailing span; see `MobileLeadingToolbarTitleWidth`. + let measuredTitleToTrailingSpan: CGFloat? let isEnabled: Bool let workspaceName: String let hasUnread: Bool diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileLeadingToolbarTitleWidthTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileLeadingToolbarTitleWidthTests.swift index 287192a2a8c6..77ad019b024a 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileLeadingToolbarTitleWidthTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileLeadingToolbarTitleWidthTests.swift @@ -107,6 +107,62 @@ import Testing #expect(unmeasured.cap == cap(393)) } + @Test func realizedSpanSizesTheTitleFromActualPositions() { + // Dogfood on a 402pt iPhone 17: the estimate-based reserve left a + // ~35pt dead gap before the trailing cluster while the title + // truncated. With both edges of that gap measured, the cap is the + // span minus only the pill/capsule chrome and a back-gap-sized + // minimum gap, so the freed space goes to the title. + let measured = MobileLeadingToolbarTitleWidth( + contentWidth: 402, + hasBackButton: true, + hasTrailingCluster: true, + hasChatToggle: true, + measuredTrailingItemsWidth: 126, + measuredTrailingItemCount: 2, + trailingItemCount: 2, + measuredTitleToTrailingSpan: 160 + ) + + #expect(measured.cap == 160 - MobileLeadingToolbarTitleWidth.spanGapReserve) + } + + @Test func realizedSpanNeverProducesANegativeCap() { + let cramped = MobileLeadingToolbarTitleWidth( + contentWidth: 402, + hasBackButton: true, + hasTrailingCluster: true, + hasChatToggle: true, + measuredTitleToTrailingSpan: 20 + ) + + #expect(cramped.cap == 0) + } + + @Test func missingSpanFallsBackToTheEstimateReserve() { + let withNilSpan = MobileLeadingToolbarTitleWidth( + contentWidth: 393, + hasBackButton: true, + hasTrailingCluster: true, + hasChatToggle: true, + measuredTrailingItemsWidth: 150, + measuredTrailingItemCount: 2, + trailingItemCount: 2, + measuredTitleToTrailingSpan: nil + ) + let estimate = MobileLeadingToolbarTitleWidth( + contentWidth: 393, + hasBackButton: true, + hasTrailingCluster: true, + hasChatToggle: true, + measuredTrailingItemsWidth: 150, + measuredTrailingItemCount: 2, + trailingItemCount: 2 + ) + + #expect(withNilSpan.cap == estimate.cap) + } + @Test func structuralItemWithoutMeasurementStillReservesSpace() { // The changes chip just appeared: the cluster has measured, the chip // has not. The cap must not expand into the chip's space, or the diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceTitleMenuValueTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceTitleMenuValueTests.swift index 7c5e01aac41b..0962d42f3595 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceTitleMenuValueTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceTitleMenuValueTests.swift @@ -57,6 +57,7 @@ import Testing measuredTrailingItemsWidth: 0, measuredTrailingItemCount: 0, trailingItemCount: 0, + measuredTitleToTrailingSpan: nil, isEnabled: true, workspaceName: "Workspace", hasUnread: false, From 9a3d8c1cb210a0ff58037da3d698136ed54fa2c5 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Sat, 22 Aug 2026 16:50:15 -0700 Subject: [PATCH 04/10] iOS: validate the measured bar gap by plausibility, not width stamps Stamping each geometry probe with the pane width captured at closure creation deadlocked at first mount: the title label measures before the pane width callback runs, stores a zero-width stamp, and never re-fires because its position never changes again, so the span stayed nil and the title never grew. Store plain positions instead and validate at use: a stale wider-layout measurement puts the trailing content past the current pane's end and is rejected; a stale narrower-layout value only under-sizes the title, which cannot over-commit the bar. Co-Authored-By: Claude Fable 5 --- .../TrailingToolbarItemMeasurement.swift | 38 +++++------------ .../WorkspaceDetailView.swift | 41 ++++++++++--------- 2 files changed, 32 insertions(+), 47 deletions(-) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TrailingToolbarItemMeasurement.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TrailingToolbarItemMeasurement.swift index af3b0f8fb389..f5811a49b222 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TrailingToolbarItemMeasurement.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TrailingToolbarItemMeasurement.swift @@ -1,16 +1,18 @@ import SwiftUI -/// One trailing toolbar item's rendered content geometry, captured together -/// with the pane width it was measured at so a stale value from before a -/// rotation or split-width change is never mixed into current-width math. +/// One trailing toolbar item's rendered content geometry. +/// +/// Values are plain positions with no width epoch: the span consumer rejects +/// measurements that are implausible for the current pane width (a stale +/// wider-layout edge lands past the pane's end), and stale narrower-layout +/// values only under-size the title, which is safe. Fresh geometry re-fires +/// the probes because real positions change whenever the bar re-lays out. struct TrailingToolbarItemGeometry: Equatable { /// Rendered content width (inside the glass capsule chrome). var width: CGFloat /// Leading edge of the content in the global space, used to derive the /// real title-to-trailing span. var globalMinX: CGFloat - /// The pane width current when this measurement fired. - var contentWidth: CGFloat } extension View { @@ -25,34 +27,16 @@ extension View { /// remove an item clear its key from the condition that removed it. func measureTrailingToolbarItem( _ key: String, - into geometry: Binding<[String: TrailingToolbarItemGeometry]>, - contentWidth: @escaping @autoclosure () -> CGFloat + into geometry: Binding<[String: TrailingToolbarItemGeometry]> ) -> some View { - onGeometryChange(for: TrailingToolbarItemProbe.self) { proxy in - TrailingToolbarItemProbe( + onGeometryChange(for: TrailingToolbarItemGeometry.self) { proxy in + TrailingToolbarItemGeometry( width: proxy.size.width, globalMinX: proxy.frame(in: .global).minX ) } action: { probe in - geometry.wrappedValue[key] = TrailingToolbarItemGeometry( - width: probe.width, - globalMinX: probe.globalMinX, - contentWidth: contentWidth() - ) + geometry.wrappedValue[key] = probe } } } -/// Equatable payload for `onGeometryChange`, which only re-fires when the -/// reduced value changes. -private struct TrailingToolbarItemProbe: Equatable { - var width: CGFloat - var globalMinX: CGFloat -} - -/// The fitted title label's leading edge, captured with the pane width it was -/// measured at (same staleness contract as `TrailingToolbarItemGeometry`). -struct WorkspaceTitleLabelEdge: Equatable { - var globalMinX: CGFloat - var contentWidth: CGFloat -} diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift index f3e27263fab0..e5d037351fd2 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift @@ -82,10 +82,10 @@ struct WorkspaceDetailView: View { // disappearance (overflow into More) cannot release a reservation and // make the collapse sticky. @State private var trailingToolbarItemGeometry: [String: TrailingToolbarItemGeometry] = [:] - /// The fitted title label's leading edge, with the pane width it was - /// measured at; pairs with the trailing item geometry to derive the - /// realized title-to-trailing span. - @State private var titleLabelLeadingEdge: WorkspaceTitleLabelEdge? + /// The fitted title label's leading edge in global space; pairs with the + /// trailing item geometry to derive the realized title-to-trailing span. + /// Leading-aligned, so it does not move as the title's cap changes. + @State private var titleLabelLeadingEdge: CGFloat? @Environment(\.layoutDirection) private var layoutDirection /// Terminal captured for the current "View as Text" sheet presentation. @State private var textSheetSurfaceID: String? @@ -374,7 +374,7 @@ struct WorkspaceDetailView: View { AltScreenNoticeButton { displaySettings.showAltScreenNotice = false } - .measureTrailingToolbarItem("altscreen-notice", into: $trailingToolbarItemGeometry, contentWidth: contentWidth) + .measureTrailingToolbarItem("altscreen-notice", into: $trailingToolbarItemGeometry) } private var workspaceChangesToolbarContent: some View { @@ -386,12 +386,12 @@ struct WorkspaceDetailView: View { // The chrome sits on the terminal theme's background, not the // system scheme; resolve the counts' green/red for that. .environment(\.colorScheme, store.activeTerminalTheme.terminalColorScheme) - .measureTrailingToolbarItem("changes", into: $trailingToolbarItemGeometry, contentWidth: contentWidth) + .measureTrailingToolbarItem("changes", into: $trailingToolbarItemGeometry) } private var trailingClusterToolbarContent: some View { toolbarTrailingCluster - .measureTrailingToolbarItem("trailing-cluster", into: $trailingToolbarItemGeometry, contentWidth: contentWidth) + .measureTrailingToolbarItem("trailing-cluster", into: $trailingToolbarItemGeometry) } // Which trailing toolbar items are structurally in the bar right now. @@ -405,21 +405,25 @@ struct WorkspaceDetailView: View { /// The realized distance from the title label's leading edge to the /// leading-most trailing item's content, only when the title and every - /// structural trailing item have reported geometry at the current pane - /// width. Stale-width measurements (mid-rotation) and RTL (where the - /// global-x projection would need mirroring) return nil, falling back to - /// the estimate-based reserve. + /// structural trailing item have reported geometry that is plausible for + /// the current pane width. A stale measurement from a wider layout puts + /// the trailing content past the pane's end and is rejected; a stale + /// narrower-layout value only under-sizes the title, which cannot + /// over-commit the bar. RTL returns nil (the global-x projection would + /// need mirroring), falling back to the estimate-based reserve. private var measuredTitleToTrailingSpan: CGFloat? { guard layoutDirection == .leftToRight, - let titleEdge = titleLabelLeadingEdge, - titleEdge.contentWidth == contentWidth else { return nil } + contentWidth > 0, + let titleMinX = titleLabelLeadingEdge else { return nil } let geometries = structuralTrailingItemKeys.compactMap { trailingToolbarItemGeometry[$0] } guard geometries.count == structuralTrailingItemKeys.count, - geometries.allSatisfy({ $0.contentWidth == contentWidth }), let trailingLeadingEdge = geometries.map(\.globalMinX).min() else { return nil } - let span = trailingLeadingEdge - titleEdge.globalMinX - return span > 0 ? span : nil + let trailingContentTotal = geometries.map(\.width).reduce(0, +) + guard trailingLeadingEdge > titleMinX, + trailingLeadingEdge + trailingContentTotal <= contentWidth + else { return nil } + return trailingLeadingEdge - titleMinX } private var workspaceTitleToolbarMenu: some View { @@ -446,10 +450,7 @@ struct WorkspaceDetailView: View { return WorkspaceTitleMenu( value: value, onLabelLeadingEdgeChange: { minX in - titleLabelLeadingEdge = WorkspaceTitleLabelEdge( - globalMinX: minX, - contentWidth: contentWidth - ) + titleLabelLeadingEdge = minX }, menuContent: { WorkspaceTitleMenuContent( From 9a8a9ab4b684ca4ec1428d5f389b250aa69d4029 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Sat, 22 Aug 2026 17:13:43 -0700 Subject: [PATCH 05/10] iOS: measure toolbar geometry in window space, not per-item islands Each ToolbarItem is bridged into the navigation bar as its own SwiftUI hosting island, so `.global` frames resolve per island and the title and trailing edges were never comparable; the span guard always failed and the title never grew. Probe the frames through a UIView that converts to the shared window space instead, reporting on layout, window attachment, and SwiftUI updates, deduplicated. DEBUG-only NSLog probes document the measured edges for dogfood verification. Co-Authored-By: Claude Fable 5 --- .../TrailingToolbarItemMeasurement.swift | 103 +++++++++++++++--- .../WorkspaceDetailView.swift | 4 + .../WorkspaceTitleMenu.swift | 6 +- 3 files changed, 95 insertions(+), 18 deletions(-) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TrailingToolbarItemMeasurement.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TrailingToolbarItemMeasurement.swift index f5811a49b222..074cd901d8fa 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TrailingToolbarItemMeasurement.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TrailingToolbarItemMeasurement.swift @@ -1,17 +1,21 @@ import SwiftUI +#if canImport(UIKit) +import UIKit +#endif /// One trailing toolbar item's rendered content geometry. /// -/// Values are plain positions with no width epoch: the span consumer rejects -/// measurements that are implausible for the current pane width (a stale -/// wider-layout edge lands past the pane's end), and stale narrower-layout -/// values only under-size the title, which is safe. Fresh geometry re-fires -/// the probes because real positions change whenever the bar re-lays out. +/// Positions are measured in the UIKit window space: each `ToolbarItem` is +/// bridged into the navigation bar as its own SwiftUI hosting island, so +/// SwiftUI's `.global` space resolves per-island and cross-item math needs +/// the shared window. Values carry no width epoch: the span consumer rejects +/// measurements implausible for the current pane width (a stale wider-layout +/// edge lands past the pane's end), and stale narrower-layout values only +/// under-size the title, which is safe. struct TrailingToolbarItemGeometry: Equatable { /// Rendered content width (inside the glass capsule chrome). var width: CGFloat - /// Leading edge of the content in the global space, used to derive the - /// real title-to-trailing span. + /// Leading edge of the content in the window space. var globalMinX: CGFloat } @@ -21,7 +25,7 @@ extension View { /// /// The leading title menu caps its width to the bar's remaining space so /// the trailing items are never squeezed into the overflow More menu. - /// Deliberately no `onDisappear` cleanup: overflowing into More also + /// Deliberately no removal on disappear: overflowing into More also /// removes the bar content, so clearing on disappear would release the /// reservation and make the collapse sticky. Callers that structurally /// remove an item clear its key from the condition that removed it. @@ -29,14 +33,85 @@ extension View { _ key: String, into geometry: Binding<[String: TrailingToolbarItemGeometry]> ) -> some View { - onGeometryChange(for: TrailingToolbarItemGeometry.self) { proxy in - TrailingToolbarItemGeometry( - width: proxy.size.width, - globalMinX: proxy.frame(in: .global).minX + measureWindowFrame { frame in + let next = TrailingToolbarItemGeometry( + width: frame.width, + globalMinX: frame.minX ) - } action: { probe in - geometry.wrappedValue[key] = probe + guard geometry.wrappedValue[key] != next else { return } + geometry.wrappedValue[key] = next + #if DEBUG + NSLog( + "cmux.toolbar.span probe key=%@ minX=%.1f width=%.1f", + key, frame.minX, frame.width + ) + #endif + } + } + + /// Reports the fitted title label's frame in window space; the leading + /// edge is stable under cap changes (the frame is leading-aligned). + func measureToolbarTitleFrame(_ onChange: @escaping (CGRect) -> Void) -> some View { + measureWindowFrame(onChange) + } + + @ViewBuilder + private func measureWindowFrame(_ onChange: @escaping (CGRect) -> Void) -> some View { + #if canImport(UIKit) + background(WindowFrameReader(onChange: onChange)) + #else + onGeometryChange(for: CGRect.self) { proxy in + proxy.frame(in: .global) + } action: { frame in + onChange(frame) } + #endif } } +#if canImport(UIKit) +/// Bridges out of the SwiftUI hosting island to read the probe's frame in the +/// shared UIKit window. Reports on layout and window attachment, deduplicated. +private struct WindowFrameReader: UIViewRepresentable { + let onChange: (CGRect) -> Void + + func makeUIView(context: Context) -> WindowFrameProbeView { + let view = WindowFrameProbeView() + view.isUserInteractionEnabled = false + view.backgroundColor = .clear + view.onChange = onChange + return view + } + + func updateUIView(_ view: WindowFrameProbeView, context: Context) { + view.onChange = onChange + // SwiftUI updates land after the layout that moved this view; frames + // are current here even when only an ancestor's position changed + // (position-only moves do not call layoutSubviews on this leaf). + view.reportIfChanged() + } +} + +final class WindowFrameProbeView: UIView { + var onChange: ((CGRect) -> Void)? + private var lastReported: CGRect? + + override func layoutSubviews() { + super.layoutSubviews() + reportIfChanged() + } + + override func didMoveToWindow() { + super.didMoveToWindow() + reportIfChanged() + } + + func reportIfChanged() { + guard let window else { return } + let frame = convert(bounds, to: window) + guard frame != lastReported else { return } + lastReported = frame + onChange?(frame) + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift index e5d037351fd2..3bd924664524 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift @@ -450,7 +450,11 @@ struct WorkspaceDetailView: View { return WorkspaceTitleMenu( value: value, onLabelLeadingEdgeChange: { minX in + guard titleLabelLeadingEdge != minX else { return } titleLabelLeadingEdge = minX + #if DEBUG + NSLog("cmux.toolbar.span probe key=title minX=%.1f", minX) + #endif }, menuContent: { WorkspaceTitleMenuContent( diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceTitleMenu.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceTitleMenu.swift index f5966557cf70..2b0d22a3cf30 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceTitleMenu.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceTitleMenu.swift @@ -52,10 +52,8 @@ struct WorkspaceTitleMenu: View, Equatable { ) // The frame is leading-aligned, so its leading edge does not move // as the cap grows; reporting it cannot feed back into the cap. - .onGeometryChange(for: CGFloat.self) { proxy in - proxy.frame(in: .global).minX - } action: { minX in - onLabelLeadingEdgeChange?(minX) + .measureToolbarTitleFrame { frame in + onLabelLeadingEdgeChange?(frame.minX) } } } From 131e8b992cbdc8d32ceba7c93069815a9409be37 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Sat, 22 Aug 2026 17:37:38 -0700 Subject: [PATCH 06/10] iOS: keep onGeometryChange widths; window probe only supplies edges The window-space probe replaced the working width measurement, but the trailing items' probes never mounted (only the title's did), so the reserve lost its measured widths, fell back to the unmeasured constants, and over-committed the bar into the More menu. Widths return to the proven onGeometryChange path so the worst case is exactly the pre-change behavior; the probe only contributes optional window-space edges for the realized span. A probe that leaves the window (item collapsed into More) now clears its edge, invalidating the span so the title falls back and the bar un-collapses, and probe lifecycle is DEBUG-logged to diagnose why trailing probes do not mount. Co-Authored-By: Claude Fable 5 --- .../TrailingToolbarItemMeasurement.swift | 99 ++++++++++++------- .../WorkspaceDetailView.swift | 15 +-- .../WorkspaceTitleMenu.swift | 11 ++- 3 files changed, 80 insertions(+), 45 deletions(-) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TrailingToolbarItemMeasurement.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TrailingToolbarItemMeasurement.swift index 074cd901d8fa..85fcd49bb58a 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TrailingToolbarItemMeasurement.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TrailingToolbarItemMeasurement.swift @@ -5,18 +5,21 @@ import UIKit /// One trailing toolbar item's rendered content geometry. /// -/// Positions are measured in the UIKit window space: each `ToolbarItem` is -/// bridged into the navigation bar as its own SwiftUI hosting island, so -/// SwiftUI's `.global` space resolves per-island and cross-item math needs -/// the shared window. Values carry no width epoch: the span consumer rejects -/// measurements implausible for the current pane width (a stale wider-layout -/// edge lands past the pane's end), and stale narrower-layout values only -/// under-size the title, which is safe. +/// Width comes from `onGeometryChange` (island-local sizes are correct) and +/// feeds the estimate-based reserve exactly as before. The leading edge is +/// measured separately in the shared UIKit window space, because each +/// `ToolbarItem` is bridged into the navigation bar as its own SwiftUI +/// hosting island and `.global` frames are not comparable across islands. +/// The edge is optional: the realized-span path only engages when every +/// structural item has one, and an item whose probe leaves the window (for +/// example, collapsed into the overflow More menu) clears its edge so the +/// span invalidates and the title falls back to the safe estimate reserve, +/// un-collapsing the bar. struct TrailingToolbarItemGeometry: Equatable { /// Rendered content width (inside the glass capsule chrome). - var width: CGFloat - /// Leading edge of the content in the window space. - var globalMinX: CGFloat + var width: CGFloat = 0 + /// Leading edge of the content in the window space, when known. + var globalMinX: CGFloat? } extension View { @@ -25,40 +28,42 @@ extension View { /// /// The leading title menu caps its width to the bar's remaining space so /// the trailing items are never squeezed into the overflow More menu. - /// Deliberately no removal on disappear: overflowing into More also - /// removes the bar content, so clearing on disappear would release the - /// reservation and make the collapse sticky. Callers that structurally - /// remove an item clear its key from the condition that removed it. + /// Width entries are deliberately never removed on disappear: overflowing + /// into More also removes the bar content, so clearing the width there + /// would release the reservation and make the collapse sticky. Callers + /// that structurally remove an item clear its key from the condition that + /// removed it. func measureTrailingToolbarItem( _ key: String, into geometry: Binding<[String: TrailingToolbarItemGeometry]> ) -> some View { - measureWindowFrame { frame in - let next = TrailingToolbarItemGeometry( - width: frame.width, - globalMinX: frame.minX - ) - guard geometry.wrappedValue[key] != next else { return } - geometry.wrappedValue[key] = next - #if DEBUG - NSLog( - "cmux.toolbar.span probe key=%@ minX=%.1f width=%.1f", - key, frame.minX, frame.width - ) - #endif + onGeometryChange(for: CGFloat.self) { proxy in + proxy.size.width + } action: { width in + guard geometry.wrappedValue[key]?.width != width else { return } + geometry.wrappedValue[key, default: TrailingToolbarItemGeometry()].width = width + } + .measureWindowFrame(probeKey: key) { frame in + guard geometry.wrappedValue[key]?.globalMinX != frame?.minX else { return } + geometry.wrappedValue[key, default: TrailingToolbarItemGeometry()].globalMinX = frame?.minX } } /// Reports the fitted title label's frame in window space; the leading /// edge is stable under cap changes (the frame is leading-aligned). - func measureToolbarTitleFrame(_ onChange: @escaping (CGRect) -> Void) -> some View { - measureWindowFrame(onChange) + func measureToolbarTitleFrame(_ onChange: @escaping (CGRect?) -> Void) -> some View { + measureWindowFrame(probeKey: "title", onChange) } + /// `onChange` receives nil when the probe leaves the window (the content + /// was removed from the bar, for example into the overflow More menu). @ViewBuilder - private func measureWindowFrame(_ onChange: @escaping (CGRect) -> Void) -> some View { + private func measureWindowFrame( + probeKey: String, + _ onChange: @escaping (CGRect?) -> Void + ) -> some View { #if canImport(UIKit) - background(WindowFrameReader(onChange: onChange)) + background(WindowFrameReader(probeKey: probeKey, onChange: onChange)) #else onGeometryChange(for: CGRect.self) { proxy in proxy.frame(in: .global) @@ -71,15 +76,21 @@ extension View { #if canImport(UIKit) /// Bridges out of the SwiftUI hosting island to read the probe's frame in the -/// shared UIKit window. Reports on layout and window attachment, deduplicated. +/// shared UIKit window. Reports on layout, window attachment, and SwiftUI +/// updates, deduplicated; reports nil on window detachment. private struct WindowFrameReader: UIViewRepresentable { - let onChange: (CGRect) -> Void + let probeKey: String + let onChange: (CGRect?) -> Void func makeUIView(context: Context) -> WindowFrameProbeView { let view = WindowFrameProbeView() view.isUserInteractionEnabled = false view.backgroundColor = .clear + view.probeKey = probeKey view.onChange = onChange + #if DEBUG + NSLog("cmux.toolbar.span probe-made key=%@", probeKey) + #endif return view } @@ -93,7 +104,8 @@ private struct WindowFrameReader: UIViewRepresentable { } final class WindowFrameProbeView: UIView { - var onChange: ((CGRect) -> Void)? + var probeKey = "" + var onChange: ((CGRect?) -> Void)? private var lastReported: CGRect? override func layoutSubviews() { @@ -103,6 +115,19 @@ final class WindowFrameProbeView: UIView { override func didMoveToWindow() { super.didMoveToWindow() + #if DEBUG + NSLog( + "cmux.toolbar.span probe-window key=%@ attached=%d", + probeKey, window == nil ? 0 : 1 + ) + #endif + if window == nil { + // Removed from the bar (e.g. the system collapsed the item into + // the More menu). Invalidate so the span consumer falls back. + lastReported = nil + onChange?(nil) + return + } reportIfChanged() } @@ -111,6 +136,12 @@ final class WindowFrameProbeView: UIView { let frame = convert(bounds, to: window) guard frame != lastReported else { return } lastReported = frame + #if DEBUG + NSLog( + "cmux.toolbar.span probe key=%@ minX=%.1f width=%.1f", + probeKey, frame.minX, frame.width + ) + #endif onChange?(frame) } } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift index 3bd924664524..23684c6739f2 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift @@ -416,8 +416,10 @@ struct WorkspaceDetailView: View { contentWidth > 0, let titleMinX = titleLabelLeadingEdge else { return nil } let geometries = structuralTrailingItemKeys.compactMap { trailingToolbarItemGeometry[$0] } - guard geometries.count == structuralTrailingItemKeys.count, - let trailingLeadingEdge = geometries.map(\.globalMinX).min() + guard geometries.count == structuralTrailingItemKeys.count else { return nil } + let edges = geometries.compactMap(\.globalMinX) + guard edges.count == geometries.count, + let trailingLeadingEdge = edges.min() else { return nil } let trailingContentTotal = geometries.map(\.width).reduce(0, +) guard trailingLeadingEdge > titleMinX, @@ -427,7 +429,11 @@ struct WorkspaceDetailView: View { } private var workspaceTitleToolbarMenu: some View { - let measuredWidths = structuralTrailingItemKeys.compactMap { trailingToolbarItemGeometry[$0]?.width } + // An entry can exist with only its window edge reported; only a real + // rendered width counts toward the estimate reserve. + let measuredWidths = structuralTrailingItemKeys + .compactMap { trailingToolbarItemGeometry[$0]?.width } + .filter { $0 > 0 } let value = WorkspaceTitleMenuValue( contentWidth: contentWidth, hasBackButton: backButtonConfiguration != nil, @@ -452,9 +458,6 @@ struct WorkspaceDetailView: View { onLabelLeadingEdgeChange: { minX in guard titleLabelLeadingEdge != minX else { return } titleLabelLeadingEdge = minX - #if DEBUG - NSLog("cmux.toolbar.span probe key=title minX=%.1f", minX) - #endif }, menuContent: { WorkspaceTitleMenuContent( diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceTitleMenu.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceTitleMenu.swift index 2b0d22a3cf30..1bd0c342ced6 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceTitleMenu.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceTitleMenu.swift @@ -2,10 +2,11 @@ import SwiftUI struct WorkspaceTitleMenu: View, Equatable { let value: WorkspaceTitleMenuValue - /// Reports the fitted label's leading edge in global space so the host - /// can derive the realized title-to-trailing span. Not part of equality: - /// geometry callbacks re-fire on layout changes regardless. - var onLabelLeadingEdgeChange: ((CGFloat) -> Void)? = nil + /// Reports the fitted label's leading edge in window space (nil when the + /// label leaves the bar) so the host can derive the realized + /// title-to-trailing span. Not part of equality: geometry callbacks + /// re-fire on layout changes regardless. + var onLabelLeadingEdgeChange: ((CGFloat?) -> Void)? = nil @ViewBuilder let menuContent: () -> MenuContent @ViewBuilder let label: () -> Label @@ -53,7 +54,7 @@ struct WorkspaceTitleMenu: View, Equatable { // The frame is leading-aligned, so its leading edge does not move // as the cap grows; reporting it cannot feed back into the cap. .measureToolbarTitleFrame { frame in - onLabelLeadingEdgeChange?(frame.minX) + onLabelLeadingEdgeChange?(frame?.minX) } } } From 4efb2990dc2d7a9cbc6616434bc2ea418f8256e3 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Sat, 22 Aug 2026 18:06:08 -0700 Subject: [PATCH 07/10] iOS: trim toolbar title reserves 12pt with a collapse-recovery ratchet Cross-island position measurement proved unreliable on iOS 26 (a grouped ToolbarItem's content can render composited without its own window attachment, and reported title edges drifted between runs), so the realized-span approach is dropped. Instead: return 12pt of the ~22pt dead gap measured on a 402pt iPhone 17 (chrome 24->20 per item, margins 84->80), well short of the 30pt trim that collapsed an iPhone 17 Pro Max, and back it with a detector. The always-structural trailing cluster's content leaving the window while the view lives is the observable signature of the system folding it into the More menu; that ratchets a 28pt recovery reserve on for the view's lifetime, strictly roomier than the original constants, so the bar un-collapses within a frame and a More collapse can never persist. Co-Authored-By: Claude Fable 5 --- .../AgentChatDemoScreen.swift | 2 +- .../MobileLeadingToolbarTitleWidth.swift | 51 +++---- .../TrailingToolbarItemMeasurement.swift | 143 +++++------------- .../WorkspaceDetailView.swift | 66 +++----- .../WorkspaceTitleMenu.swift | 12 +- .../WorkspaceTitleMenuValue.swift | 4 +- .../MobileLeadingToolbarTitleWidthTests.swift | 53 ++----- .../WorkspaceTitleMenuValueTests.swift | 2 +- 8 files changed, 106 insertions(+), 227 deletions(-) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentChatDemoScreen.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentChatDemoScreen.swift index 5c851a10d3f4..eb72af0dd855 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentChatDemoScreen.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentChatDemoScreen.swift @@ -108,7 +108,7 @@ struct AgentChatDemoScreen: View { measuredTrailingItemsWidth: 0, measuredTrailingItemCount: 0, trailingItemCount: 0, - measuredTitleToTrailingSpan: nil, + hadTrailingCollapse: false, isEnabled: true, workspaceName: inlineWorkspaceTitle ?? "", hasUnread: false, diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileLeadingToolbarTitleWidth.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileLeadingToolbarTitleWidth.swift index facd208f4e11..f8f61735f26b 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileLeadingToolbarTitleWidth.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileLeadingToolbarTitleWidth.swift @@ -12,7 +12,10 @@ import CoreGraphics /// specific items in the bar. The title therefore must never claim space the /// trailing items actually render with. Callers report the measured content /// widths of the trailing toolbar items; the estimate constants only cover the -/// frames before the first measurement arrives. +/// frames before the first measurement arrives. As a backstop, callers also +/// report when a trailing item's content left the bar (the system folded it +/// into More); that ratchets `collapseRecoveryReserve` on for the rest of the +/// view's lifetime so a collapse can never persist. struct MobileLeadingToolbarTitleWidth { let contentWidth: CGFloat let hasBackButton: Bool @@ -29,31 +32,33 @@ struct MobileLeadingToolbarTitleWidth { /// How many trailing toolbar items are structurally visible right now; /// each carries its own glass capsule chrome around the measured content. let trailingItemCount: Int - /// Rendered distance from the title content's leading edge to the - /// leading-most trailing item content's leading edge, when every - /// structural trailing item and the title label have reported geometry at - /// the current pane width (LTR only). This is the system's realized - /// layout, so a cap derived from it can only consume space that is - /// actually empty on screen: unlike the estimate constants it can never - /// over-commit the bar and bounce trailing items into the More menu. - let measuredTitleToTrailingSpan: CGFloat? + /// True once a trailing item's content left the bar while structurally + /// present (the system collapsed it into the More menu): the reserves + /// undershot this device's real chrome, so give the space back. + let hadTrailingCollapse: Bool static let backButtonReserve: CGFloat = 44 static let trailingReserveBase: CGFloat = 64 static let chatToggleReserve: CGFloat = 60 - static let barMarginsAndSpacing: CGFloat = 84 + /// Dogfood on a 402pt iPhone 17 measured ~22pt of dead gap between the + /// title pill and the trailing capsule under the original 84pt margin + + /// 24pt/item chrome reserves. Only part of that slack is returned to the + /// title (4pt here, 4pt per item below): larger-chrome devices proved + /// able to eat the full amount (a 30pt trim collapsed the trailing items + /// into More on an iPhone 17 Pro Max), and `collapseRecoveryReserve` + /// backstops the remainder. + static let barMarginsAndSpacing: CGFloat = 80 /// Horizontal glass-capsule chrome around one trailing item's content. - static let trailingItemChrome: CGFloat = 24 + static let trailingItemChrome: CGFloat = 20 /// Safe content-width reserve for a structural item that has not reported /// geometry yet. static let unmeasuredTrailingItemReserve: CGFloat = 64 + /// Added once a collapse was observed: more than the 12pt trimmed from + /// the estimate reserves, so the recovered layout is strictly roomier + /// than the original constants and the bar un-collapses. + static let collapseRecoveryReserve: CGFloat = 28 static let unmeasuredFallback: CGFloat = 140 static let floor: CGFloat = 96 - /// Subtracted from the measured span before the title may use it: the - /// title pill's trailing glass chrome plus the trailing capsule's leading - /// chrome (~13pt each) plus a minimum visual gap matching the - /// back-to-title spacing (~13pt). - static let spanGapReserve: CGFloat = 40 init( contentWidth: CGFloat, @@ -63,7 +68,7 @@ struct MobileLeadingToolbarTitleWidth { measuredTrailingItemsWidth: CGFloat = 0, measuredTrailingItemCount: Int = 0, trailingItemCount: Int = 0, - measuredTitleToTrailingSpan: CGFloat? = nil + hadTrailingCollapse: Bool = false ) { self.contentWidth = contentWidth self.hasBackButton = hasBackButton @@ -72,20 +77,14 @@ struct MobileLeadingToolbarTitleWidth { self.measuredTrailingItemsWidth = measuredTrailingItemsWidth self.measuredTrailingItemCount = measuredTrailingItemCount self.trailingItemCount = trailingItemCount - self.measuredTitleToTrailingSpan = measuredTitleToTrailingSpan + self.hadTrailingCollapse = hadTrailingCollapse } var cap: CGFloat { guard contentWidth > 0 else { return Self.unmeasuredFallback } - // Realized-layout path: both edges of the gap were measured at this - // width, so size the title from actual positions. The title's leading - // edge and the trailing items' positions do not depend on the title's - // width, so this does not feed back into itself. - if let span = measuredTitleToTrailingSpan, span > 0 { - return max(0, span - Self.spanGapReserve) - } let leading = hasBackButton ? Self.backButtonReserve : 0 - return max(0, contentWidth - leading - trailingReserve - Self.barMarginsAndSpacing) + let recovery = hadTrailingCollapse ? Self.collapseRecoveryReserve : 0 + return max(0, contentWidth - leading - trailingReserve - Self.barMarginsAndSpacing - recovery) } private var trailingReserve: CGFloat { diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TrailingToolbarItemMeasurement.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TrailingToolbarItemMeasurement.swift index 85fcd49bb58a..e54cd4bafea8 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TrailingToolbarItemMeasurement.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TrailingToolbarItemMeasurement.swift @@ -3,146 +3,87 @@ import SwiftUI import UIKit #endif -/// One trailing toolbar item's rendered content geometry. -/// -/// Width comes from `onGeometryChange` (island-local sizes are correct) and -/// feeds the estimate-based reserve exactly as before. The leading edge is -/// measured separately in the shared UIKit window space, because each -/// `ToolbarItem` is bridged into the navigation bar as its own SwiftUI -/// hosting island and `.global` frames are not comparable across islands. -/// The edge is optional: the realized-span path only engages when every -/// structural item has one, and an item whose probe leaves the window (for -/// example, collapsed into the overflow More menu) clears its edge so the -/// span invalidates and the title falls back to the safe estimate reserve, -/// un-collapsing the bar. -struct TrailingToolbarItemGeometry: Equatable { - /// Rendered content width (inside the glass capsule chrome). - var width: CGFloat = 0 - /// Leading edge of the content in the window space, when known. - var globalMinX: CGFloat? -} - extension View { - /// Reports this trailing toolbar item's rendered content geometry into the - /// shared measurement dictionary. + /// Reports this trailing toolbar item's rendered content width into the + /// shared measurement dictionary, and optionally reports when the item's + /// content leaves the window while still structurally present, which on + /// iOS means the system folded the item into the overflow More menu. /// /// The leading title menu caps its width to the bar's remaining space so - /// the trailing items are never squeezed into the overflow More menu. - /// Width entries are deliberately never removed on disappear: overflowing - /// into More also removes the bar content, so clearing the width there - /// would release the reservation and make the collapse sticky. Callers - /// that structurally remove an item clear its key from the condition that + /// the trailing items are never squeezed into the More menu. Width + /// entries are deliberately never removed on disappear: overflowing into + /// More also removes the bar content, so clearing the width there would + /// release the reservation and make the collapse sticky. Callers that + /// structurally remove an item clear its key from the condition that /// removed it. + /// + /// `onLeaveBar` is only wired for items that are always structurally + /// present: a conditional item's structural removal also detaches its + /// probe and would be indistinguishable from a collapse. + @ViewBuilder func measureTrailingToolbarItem( _ key: String, - into geometry: Binding<[String: TrailingToolbarItemGeometry]> + into widths: Binding<[String: CGFloat]>, + onLeaveBar: (@MainActor () -> Void)? = nil ) -> some View { - onGeometryChange(for: CGFloat.self) { proxy in + let measured = onGeometryChange(for: CGFloat.self) { proxy in proxy.size.width } action: { width in - guard geometry.wrappedValue[key]?.width != width else { return } - geometry.wrappedValue[key, default: TrailingToolbarItemGeometry()].width = width - } - .measureWindowFrame(probeKey: key) { frame in - guard geometry.wrappedValue[key]?.globalMinX != frame?.minX else { return } - geometry.wrappedValue[key, default: TrailingToolbarItemGeometry()].globalMinX = frame?.minX + widths.wrappedValue[key] = width } - } - - /// Reports the fitted title label's frame in window space; the leading - /// edge is stable under cap changes (the frame is leading-aligned). - func measureToolbarTitleFrame(_ onChange: @escaping (CGRect?) -> Void) -> some View { - measureWindowFrame(probeKey: "title", onChange) - } - - /// `onChange` receives nil when the probe leaves the window (the content - /// was removed from the bar, for example into the overflow More menu). - @ViewBuilder - private func measureWindowFrame( - probeKey: String, - _ onChange: @escaping (CGRect?) -> Void - ) -> some View { #if canImport(UIKit) - background(WindowFrameReader(probeKey: probeKey, onChange: onChange)) + measured.background(BarPresenceReader(probeKey: key, onLeaveBar: onLeaveBar)) #else - onGeometryChange(for: CGRect.self) { proxy in - proxy.frame(in: .global) - } action: { frame in - onChange(frame) - } + measured #endif } } #if canImport(UIKit) -/// Bridges out of the SwiftUI hosting island to read the probe's frame in the -/// shared UIKit window. Reports on layout, window attachment, and SwiftUI -/// updates, deduplicated; reports nil on window detachment. -private struct WindowFrameReader: UIViewRepresentable { +/// Bridges out of the SwiftUI hosting island to observe whether the item's +/// content is attached to a UIKit window. Detaching after having been +/// attached, while the SwiftUI view is still alive, is the observable +/// signature of the system moving the item into the overflow More menu. +private struct BarPresenceReader: UIViewRepresentable { let probeKey: String - let onChange: (CGRect?) -> Void + let onLeaveBar: (@MainActor () -> Void)? - func makeUIView(context: Context) -> WindowFrameProbeView { - let view = WindowFrameProbeView() + func makeUIView(context: Context) -> BarPresenceProbeView { + let view = BarPresenceProbeView() view.isUserInteractionEnabled = false view.backgroundColor = .clear view.probeKey = probeKey - view.onChange = onChange - #if DEBUG - NSLog("cmux.toolbar.span probe-made key=%@", probeKey) - #endif + view.onLeaveBar = onLeaveBar return view } - func updateUIView(_ view: WindowFrameProbeView, context: Context) { - view.onChange = onChange - // SwiftUI updates land after the layout that moved this view; frames - // are current here even when only an ancestor's position changed - // (position-only moves do not call layoutSubviews on this leaf). - view.reportIfChanged() + func updateUIView(_ view: BarPresenceProbeView, context: Context) { + view.onLeaveBar = onLeaveBar } } -final class WindowFrameProbeView: UIView { +final class BarPresenceProbeView: UIView { var probeKey = "" - var onChange: ((CGRect?) -> Void)? - private var lastReported: CGRect? - - override func layoutSubviews() { - super.layoutSubviews() - reportIfChanged() - } + var onLeaveBar: (@MainActor () -> Void)? + private var wasAttached = false override func didMoveToWindow() { super.didMoveToWindow() #if DEBUG NSLog( - "cmux.toolbar.span probe-window key=%@ attached=%d", + "cmux.toolbar.collapse probe key=%@ attached=%d", probeKey, window == nil ? 0 : 1 ) #endif - if window == nil { - // Removed from the bar (e.g. the system collapsed the item into - // the More menu). Invalidate so the span consumer falls back. - lastReported = nil - onChange?(nil) + if window != nil { + wasAttached = true return } - reportIfChanged() - } - - func reportIfChanged() { - guard let window else { return } - let frame = convert(bounds, to: window) - guard frame != lastReported else { return } - lastReported = frame - #if DEBUG - NSLog( - "cmux.toolbar.span probe key=%@ minX=%.1f width=%.1f", - probeKey, frame.minX, frame.width - ) - #endif - onChange?(frame) + guard wasAttached else { return } + wasAttached = false + MainActor.assumeIsolated { + onLeaveBar?() + } } } #endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift index 23684c6739f2..a391d661dd86 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift @@ -81,12 +81,12 @@ struct WorkspaceDetailView: View { // deletes an entry (see the onChange handlers); a layout-driven // disappearance (overflow into More) cannot release a reservation and // make the collapse sticky. - @State private var trailingToolbarItemGeometry: [String: TrailingToolbarItemGeometry] = [:] - /// The fitted title label's leading edge in global space; pairs with the - /// trailing item geometry to derive the realized title-to-trailing span. - /// Leading-aligned, so it does not move as the title's cap changes. - @State private var titleLabelLeadingEdge: CGFloat? - @Environment(\.layoutDirection) private var layoutDirection + @State private var trailingToolbarItemWidths: [String: CGFloat] = [:] + /// Ratchets on when the trailing cluster's content leaves the bar while + /// structurally present: the system folded it into the More menu, so the + /// estimate reserves undershot this device's chrome. Never cleared for + /// this view's lifetime; the extra recovery reserve un-collapses the bar. + @State private var trailingToolbarCollapseDetected = false /// Terminal captured for the current "View as Text" sheet presentation. @State private var textSheetSurfaceID: String? /// Identity of the in-flight New Browser creation. A late RPC result must @@ -217,10 +217,10 @@ struct WorkspaceDetailView: View { // disappearance (overflow into More) never flips these conditions, // so it cannot release a reservation and make the collapse sticky. .onChange(of: workspaceChangesAreAvailable) { _, isAvailable in - if !isAvailable { trailingToolbarItemGeometry["changes"] = nil } + if !isAvailable { trailingToolbarItemWidths["changes"] = nil } } .onChange(of: altScreenNoticeIsVisible) { _, isVisible in - if !isVisible { trailingToolbarItemGeometry["altscreen-notice"] = nil } + if !isVisible { trailingToolbarItemWidths["altscreen-notice"] = nil } } .onAppear { refreshWorkspaceChangesHint() } .onChange(of: workspaceChangesHintEligibilityKey) { _, _ in @@ -374,7 +374,7 @@ struct WorkspaceDetailView: View { AltScreenNoticeButton { displaySettings.showAltScreenNotice = false } - .measureTrailingToolbarItem("altscreen-notice", into: $trailingToolbarItemGeometry) + .measureTrailingToolbarItem("altscreen-notice", into: $trailingToolbarItemWidths) } private var workspaceChangesToolbarContent: some View { @@ -386,12 +386,19 @@ struct WorkspaceDetailView: View { // The chrome sits on the terminal theme's background, not the // system scheme; resolve the counts' green/red for that. .environment(\.colorScheme, store.activeTerminalTheme.terminalColorScheme) - .measureTrailingToolbarItem("changes", into: $trailingToolbarItemGeometry) + .measureTrailingToolbarItem("changes", into: $trailingToolbarItemWidths) } private var trailingClusterToolbarContent: some View { toolbarTrailingCluster - .measureTrailingToolbarItem("trailing-cluster", into: $trailingToolbarItemGeometry) + // Only the always-structural cluster wires collapse detection: a + // conditional item's structural removal also detaches its probe + // and would be indistinguishable from a More-menu collapse. + .measureTrailingToolbarItem( + "trailing-cluster", + into: $trailingToolbarItemWidths, + onLeaveBar: { trailingToolbarCollapseDetected = true } + ) } // Which trailing toolbar items are structurally in the bar right now. @@ -403,37 +410,8 @@ struct WorkspaceDetailView: View { return keys } - /// The realized distance from the title label's leading edge to the - /// leading-most trailing item's content, only when the title and every - /// structural trailing item have reported geometry that is plausible for - /// the current pane width. A stale measurement from a wider layout puts - /// the trailing content past the pane's end and is rejected; a stale - /// narrower-layout value only under-sizes the title, which cannot - /// over-commit the bar. RTL returns nil (the global-x projection would - /// need mirroring), falling back to the estimate-based reserve. - private var measuredTitleToTrailingSpan: CGFloat? { - guard layoutDirection == .leftToRight, - contentWidth > 0, - let titleMinX = titleLabelLeadingEdge else { return nil } - let geometries = structuralTrailingItemKeys.compactMap { trailingToolbarItemGeometry[$0] } - guard geometries.count == structuralTrailingItemKeys.count else { return nil } - let edges = geometries.compactMap(\.globalMinX) - guard edges.count == geometries.count, - let trailingLeadingEdge = edges.min() - else { return nil } - let trailingContentTotal = geometries.map(\.width).reduce(0, +) - guard trailingLeadingEdge > titleMinX, - trailingLeadingEdge + trailingContentTotal <= contentWidth - else { return nil } - return trailingLeadingEdge - titleMinX - } - private var workspaceTitleToolbarMenu: some View { - // An entry can exist with only its window edge reported; only a real - // rendered width counts toward the estimate reserve. - let measuredWidths = structuralTrailingItemKeys - .compactMap { trailingToolbarItemGeometry[$0]?.width } - .filter { $0 > 0 } + let measuredWidths = structuralTrailingItemKeys.compactMap { trailingToolbarItemWidths[$0] } let value = WorkspaceTitleMenuValue( contentWidth: contentWidth, hasBackButton: backButtonConfiguration != nil, @@ -442,7 +420,7 @@ struct WorkspaceDetailView: View { measuredTrailingItemsWidth: measuredWidths.reduce(0, +), measuredTrailingItemCount: measuredWidths.count, trailingItemCount: structuralTrailingItemKeys.count, - measuredTitleToTrailingSpan: measuredTitleToTrailingSpan, + hadTrailingCollapse: trailingToolbarCollapseDetected, isEnabled: hasTitleMenuActions, workspaceName: workspace.name, hasUnread: workspace.hasUnread, @@ -455,10 +433,6 @@ struct WorkspaceDetailView: View { ) return WorkspaceTitleMenu( value: value, - onLabelLeadingEdgeChange: { minX in - guard titleLabelLeadingEdge != minX else { return } - titleLabelLeadingEdge = minX - }, menuContent: { WorkspaceTitleMenuContent( workspaceName: value.workspaceName, diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceTitleMenu.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceTitleMenu.swift index 1bd0c342ced6..1cfb9246b5af 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceTitleMenu.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceTitleMenu.swift @@ -2,11 +2,6 @@ import SwiftUI struct WorkspaceTitleMenu: View, Equatable { let value: WorkspaceTitleMenuValue - /// Reports the fitted label's leading edge in window space (nil when the - /// label leaves the bar) so the host can derive the realized - /// title-to-trailing span. Not part of equality: geometry callbacks - /// re-fire on layout changes regardless. - var onLabelLeadingEdgeChange: ((CGFloat?) -> Void)? = nil @ViewBuilder let menuContent: () -> MenuContent @ViewBuilder let label: () -> Label @@ -42,7 +37,7 @@ struct WorkspaceTitleMenu: View, Equatable { measuredTrailingItemsWidth: value.measuredTrailingItemsWidth, measuredTrailingItemCount: value.measuredTrailingItemCount, trailingItemCount: value.trailingItemCount, - measuredTitleToTrailingSpan: value.measuredTitleToTrailingSpan + hadTrailingCollapse: value.hadTrailingCollapse ).cap return label() @@ -51,10 +46,5 @@ struct WorkspaceTitleMenu: View, Equatable { maxWidth: cap, alignment: .leading ) - // The frame is leading-aligned, so its leading edge does not move - // as the cap grows; reporting it cannot feed back into the cap. - .measureToolbarTitleFrame { frame in - onLabelLeadingEdgeChange?(frame?.minX) - } } } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceTitleMenuValue.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceTitleMenuValue.swift index df6efb8aec8e..7479138aaba3 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceTitleMenuValue.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceTitleMenuValue.swift @@ -9,8 +9,8 @@ struct WorkspaceTitleMenuValue: Equatable { let measuredTrailingItemsWidth: CGFloat let measuredTrailingItemCount: Int let trailingItemCount: Int - /// Realized title-to-trailing span; see `MobileLeadingToolbarTitleWidth`. - let measuredTitleToTrailingSpan: CGFloat? + /// Collapse-recovery ratchet; see `MobileLeadingToolbarTitleWidth`. + let hadTrailingCollapse: Bool let isEnabled: Bool let workspaceName: String let hasUnread: Bool diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileLeadingToolbarTitleWidthTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileLeadingToolbarTitleWidthTests.swift index 77ad019b024a..c2948bf6a4ce 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileLeadingToolbarTitleWidthTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileLeadingToolbarTitleWidthTests.swift @@ -107,60 +107,35 @@ import Testing #expect(unmeasured.cap == cap(393)) } - @Test func realizedSpanSizesTheTitleFromActualPositions() { - // Dogfood on a 402pt iPhone 17: the estimate-based reserve left a - // ~35pt dead gap before the trailing cluster while the title - // truncated. With both edges of that gap measured, the cap is the - // span minus only the pill/capsule chrome and a back-gap-sized - // minimum gap, so the freed space goes to the title. - let measured = MobileLeadingToolbarTitleWidth( + @Test func observedCollapseRatchetsTheTitleSmaller() { + // A trailing item's content left the bar while structurally present: + // the system folded it into the More menu, so the reserves undershot + // this device's chrome. The recovery reserve exceeds the 12pt trimmed + // from the estimates, so the recovered layout is strictly roomier + // than the original constants and the bar un-collapses. + let base = MobileLeadingToolbarTitleWidth( contentWidth: 402, hasBackButton: true, hasTrailingCluster: true, hasChatToggle: true, measuredTrailingItemsWidth: 126, measuredTrailingItemCount: 2, - trailingItemCount: 2, - measuredTitleToTrailingSpan: 160 + trailingItemCount: 2 ) - - #expect(measured.cap == 160 - MobileLeadingToolbarTitleWidth.spanGapReserve) - } - - @Test func realizedSpanNeverProducesANegativeCap() { - let cramped = MobileLeadingToolbarTitleWidth( + let recovered = MobileLeadingToolbarTitleWidth( contentWidth: 402, hasBackButton: true, hasTrailingCluster: true, hasChatToggle: true, - measuredTitleToTrailingSpan: 20 - ) - - #expect(cramped.cap == 0) - } - - @Test func missingSpanFallsBackToTheEstimateReserve() { - let withNilSpan = MobileLeadingToolbarTitleWidth( - contentWidth: 393, - hasBackButton: true, - hasTrailingCluster: true, - hasChatToggle: true, - measuredTrailingItemsWidth: 150, + measuredTrailingItemsWidth: 126, measuredTrailingItemCount: 2, trailingItemCount: 2, - measuredTitleToTrailingSpan: nil - ) - let estimate = MobileLeadingToolbarTitleWidth( - contentWidth: 393, - hasBackButton: true, - hasTrailingCluster: true, - hasChatToggle: true, - measuredTrailingItemsWidth: 150, - measuredTrailingItemCount: 2, - trailingItemCount: 2 + hadTrailingCollapse: true ) - #expect(withNilSpan.cap == estimate.cap) + #expect(recovered.cap + == base.cap - MobileLeadingToolbarTitleWidth.collapseRecoveryReserve) + #expect(MobileLeadingToolbarTitleWidth.collapseRecoveryReserve > 12) } @Test func structuralItemWithoutMeasurementStillReservesSpace() { diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceTitleMenuValueTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceTitleMenuValueTests.swift index 0962d42f3595..a9dd5db066d7 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceTitleMenuValueTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceTitleMenuValueTests.swift @@ -57,7 +57,7 @@ import Testing measuredTrailingItemsWidth: 0, measuredTrailingItemCount: 0, trailingItemCount: 0, - measuredTitleToTrailingSpan: nil, + hadTrailingCollapse: false, isEnabled: true, workspaceName: "Workspace", hasUnread: false, From 8b5b5a4775d2eedf9f930570e3bc0b4724e8b718 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Sat, 22 Aug 2026 19:26:13 -0700 Subject: [PATCH 08/10] iOS: pin the workspace-detail navigation bar on scrollable surfaces iOS 26 minimizes the navigation bar into a floating overflow pill when the content under it scrolls, so on the browser and chat surfaces the back button, workspace title, and trailing controls all vanish behind a single floating dots pill; the terminal never shows this because it has no system scroll view for UIKit to discover. The SwiftUI opt-out (toolbarMinimizeBehavior(.never)) does not exist in the iOS 26 SDK and UIKit 26 only exposes a minimize control for the tab bar, but the bar's scroll linkage is public since iOS 15: setContentScrollView(_:for:) selects which scroll view drives bar effects. A probe view re-points the pushed screen's top-edge content scroll view at a static never-scrolling stand-in, decoupling the bar so it stays expanded like the terminal. On iOS 27 the native opt-out is applied as well, gated on compiler(>=6.4) like the existing visibilityPriority branch. No practical unit test exists for this UIKit runtime behavior; verified on an isolated simulator by scrolling the browser surface. Co-Authored-By: Claude Fable 5 --- .../MobilePinnedNavigationBar.swift | 104 ++++++++++++++++++ .../WorkspaceDetailView.swift | 4 + 2 files changed, 108 insertions(+) create mode 100644 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePinnedNavigationBar.swift diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePinnedNavigationBar.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePinnedNavigationBar.swift new file mode 100644 index 000000000000..2a3394641dcd --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePinnedNavigationBar.swift @@ -0,0 +1,104 @@ +import SwiftUI +#if canImport(UIKit) +import UIKit +#endif + +extension View { + /// Keeps the navigation bar fully expanded over scrollable content. + /// + /// iOS 26 minimizes the navigation bar into a floating overflow pill when + /// the content under it scrolls (Safari-style), which hides the back + /// button, the workspace title, and the trailing controls behind a "…" + /// menu on the browser and chat surfaces. The SwiftUI opt-out + /// (`toolbarMinimizeBehavior(.never)`) does not exist in the iOS 26 SDK, + /// and UIKit's 26 SDK only exposes a minimize behavior for the tab bar. + /// The bar's scroll linkage does have a public control: the view + /// controller's top-edge content scroll view (iOS 15+), which UIKit + /// otherwise discovers automatically (the browser pane's web view). + /// Re-pointing that association at a static, never-scrolling stand-in + /// decouples the bar from the content, so it stays expanded exactly like + /// the terminal surface, where no system scroll view exists to discover. + /// + /// On iOS 27 the native opt-out is applied as well once an Xcode 27 + /// toolchain builds this target. + @ViewBuilder + func mobilePinnedNavigationBar() -> some View { + #if canImport(UIKit) + #if compiler(>=6.4) + if #available(iOS 27.0, *) { + background(PinnedNavigationBarApplier()) + .toolbarMinimizeBehavior(.never, for: .navigationBar) + } else { + background(PinnedNavigationBarApplier()) + } + #else + background(PinnedNavigationBarApplier()) + #endif + #else + self + #endif + } +} + +#if canImport(UIKit) +private struct PinnedNavigationBarApplier: UIViewRepresentable { + func makeUIView(context: Context) -> PinnedNavigationBarProbeView { + let view = PinnedNavigationBarProbeView() + view.isUserInteractionEnabled = false + view.backgroundColor = .clear + return view + } + + func updateUIView(_ view: PinnedNavigationBarProbeView, context: Context) { + view.applyIfNeeded() + } +} + +final class PinnedNavigationBarProbeView: UIView { + /// The stand-in the bar tracks instead of the real content: zero content, + /// scrolling disabled, so the bar's scroll edge never moves. + private let anchorScrollView: UIScrollView = { + let scrollView = UIScrollView() + scrollView.isScrollEnabled = false + return scrollView + }() + + override func didMoveToWindow() { + super.didMoveToWindow() + applyIfNeeded() + } + + override func layoutSubviews() { + super.layoutSubviews() + // Re-assert after layout passes: SwiftUI can re-derive the tracked + // scroll view for its own containers, and the association resets when + // the hosting controller re-parents during navigation transitions. + applyIfNeeded() + } + + func applyIfNeeded() { + guard window != nil, let target = barOwningViewController() else { return } + if target.contentScrollView(for: .top) !== anchorScrollView { + target.setContentScrollView(anchorScrollView, for: .top) + } + } + + /// The pushed screen's view controller: the ancestor whose parent is the + /// navigation controller (its navigation item drives the bar). Falls back + /// to the nearest ancestor view controller. + private func barOwningViewController() -> UIViewController? { + var responder: UIResponder? = next + var nearest: UIViewController? + while let current = responder { + if let viewController = current as? UIViewController { + nearest = nearest ?? viewController + if viewController.parent is UINavigationController { + return viewController + } + } + responder = current.next + } + return nearest + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift index 7b5a42c86666..613c2710005c 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift @@ -191,6 +191,10 @@ struct WorkspaceDetailView: View { .onGeometryChange(for: CGFloat.self) { $0.size.width } action: { contentWidth = $0 } .navigationTitle(systemNavigationTitle) .mobileTerminalNavigationChrome(theme: store.activeTerminalTheme) + // The browser and chat surfaces scroll; without this the system + // minimizes the whole bar into a floating "…" pill, unlike the + // terminal surface, which has no system scroll view. + .mobilePinnedNavigationBar() .toolbar { workspaceDetailToolbar } .task(id: chatRefreshKey) { await refreshChatSessions() } .task(id: workspace.rpcWorkspaceID.rawValue) { From 4696dfbb860ce4a01cd75b9930a5b4788d0a8af6 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Sun, 23 Aug 2026 14:05:56 -0700 Subject: [PATCH 09/10] iOS: browser-style surfaces title as workspace name, page as subtitle The browser, browser-stream, and simulator-stream surfaces showed the page or device title as the pill's only line, so the workspace identity vanished from the bar. Use the same two-line standard label as the terminal: workspace name on top, the surface's own title (page, tab, or device) as the subtitle. The single-line browser label token is unused after this and is removed. Co-Authored-By: Claude Fable 5 --- .../WorkspaceDetailView.swift | 18 +++++++++--------- .../WorkspaceTitleMenuLabelToken.swift | 1 - .../WorkspaceTitleMenuValueTests.swift | 2 +- 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift index 613c2710005c..4d9e6330c4ef 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift @@ -455,12 +455,6 @@ struct WorkspaceDetailView: View { subtitle: subtitle, style: .toolbarCompact ) - case .browser(let title): - Text(title) - .font(.headline) - .lineLimit(1) - .truncationMode(.tail) - .foregroundStyle(value.terminalTheme.terminalChromeForegroundColor) case .standard(let title, let subtitle): WorkspaceToolbarTitleView(title: title, subtitle: subtitle) } @@ -481,11 +475,17 @@ struct WorkspaceDetailView: View { subtitle: tabName(for: session) ) } else if let browser = activeBrowser { - return .browser(title: browser.title ?? workspace.name) + // Browser-style surfaces keep the workspace as the pill's title, + // like the terminal; the surface's own title (the page or tab) + // rides the subtitle line. + return .standard(title: workspace.name, subtitle: browser.title) } else if let browser = activeBrowserStream { - return .browser(title: browser.title ?? workspace.name) + return .standard(title: workspace.name, subtitle: browser.title) } else if let simulator = activeSimulatorStream { - return .browser(title: simulator.selectedDeviceName ?? simulator.title) + return .standard( + title: workspace.name, + subtitle: simulator.selectedDeviceName ?? simulator.title + ) } else { return .standard(title: workspace.name, subtitle: selectedToolbarSubtitle) } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceTitleMenuLabelToken.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceTitleMenuLabelToken.swift index b4da57caec1a..faa556a3ddf4 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceTitleMenuLabelToken.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceTitleMenuLabelToken.swift @@ -8,6 +8,5 @@ enum WorkspaceTitleMenuLabelToken: Equatable { titleOverride: String?, subtitle: String? ) - case browser(title: String) case standard(title: String, subtitle: String?) } diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceTitleMenuValueTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceTitleMenuValueTests.swift index 7c5e01aac41b..7f98459582c2 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceTitleMenuValueTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceTitleMenuValueTests.swift @@ -9,7 +9,7 @@ import Testing labelToken: .standard(title: "Workspace", subtitle: "Terminal") ) let browser = menuValue( - labelToken: .browser(title: "Workspace") + labelToken: .standard(title: "Workspace", subtitle: "GitHub - cmux") ) let chat = menuValue( labelToken: .chat( From 5a6e8e1e73395941f84661f2ab0e8f730744e0a6 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Sun, 23 Aug 2026 19:18:45 -0700 Subject: [PATCH 10/10] iOS: gate the collapse ratchet on content presence; harden the bar pin Review findings (CodeRabbit, Greptile): window detachment alone does not prove a More-menu collapse. A deeper push (artifact viewer) or a pop detaches the whole screen, cluster probe included, which would falsely latch the recovery ratchet and shrink the title 28pt for the rest of the view's lifetime. The cluster's detach now latches only while the screen's own content view is still window-attached, read at event time through a shared reference object so navigation transitions (content detaches first) are ignored and genuine overflow (content stays) is caught. The pinned-bar applier now fails closed when no ancestor is a navigation controller's child instead of re-pointing the nearest controller, and releases its content-scroll-view association on dismantle so the bar never tracks a deallocated probe's stand-in. Co-Authored-By: Claude Fable 5 --- .../MobilePinnedNavigationBar.swift | 33 +++++++--- .../TrailingToolbarItemMeasurement.swift | 66 +++++++++++++++++-- .../WorkspaceDetailView.swift | 22 +++++-- 3 files changed, 102 insertions(+), 19 deletions(-) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePinnedNavigationBar.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePinnedNavigationBar.swift index 2a3394641dcd..5680a0b8b0ab 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePinnedNavigationBar.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePinnedNavigationBar.swift @@ -52,6 +52,10 @@ private struct PinnedNavigationBarApplier: UIViewRepresentable { func updateUIView(_ view: PinnedNavigationBarProbeView, context: Context) { view.applyIfNeeded() } + + static func dismantleUIView(_ view: PinnedNavigationBarProbeView, coordinator: ()) { + view.clearAssociation() + } } final class PinnedNavigationBarProbeView: UIView { @@ -62,6 +66,10 @@ final class PinnedNavigationBarProbeView: UIView { scrollView.isScrollEnabled = false return scrollView }() + /// The controller currently pointed at the stand-in, so teardown can + /// release the association instead of leaving the bar tracking a + /// deallocated probe's scroll view. + private weak var appliedTarget: UIViewController? override func didMoveToWindow() { super.didMoveToWindow() @@ -80,25 +88,32 @@ final class PinnedNavigationBarProbeView: UIView { guard window != nil, let target = barOwningViewController() else { return } if target.contentScrollView(for: .top) !== anchorScrollView { target.setContentScrollView(anchorScrollView, for: .top) + appliedTarget = target } } + func clearAssociation() { + guard let target = appliedTarget, + target.contentScrollView(for: .top) === anchorScrollView + else { return } + target.setContentScrollView(nil, for: .top) + appliedTarget = nil + } + /// The pushed screen's view controller: the ancestor whose parent is the - /// navigation controller (its navigation item drives the bar). Falls back - /// to the nearest ancestor view controller. + /// navigation controller (its navigation item drives the bar). Fails + /// closed when no such ancestor exists (previews, sheets), rather than + /// re-pointing an unrelated controller's scroll edge. private func barOwningViewController() -> UIViewController? { var responder: UIResponder? = next - var nearest: UIViewController? while let current = responder { - if let viewController = current as? UIViewController { - nearest = nearest ?? viewController - if viewController.parent is UINavigationController { - return viewController - } + if let viewController = current as? UIViewController, + viewController.parent is UINavigationController { + return viewController } responder = current.next } - return nearest + return nil } } #endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TrailingToolbarItemMeasurement.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TrailingToolbarItemMeasurement.swift index e54cd4bafea8..3014625ca71c 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TrailingToolbarItemMeasurement.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TrailingToolbarItemMeasurement.swift @@ -3,11 +3,34 @@ import SwiftUI import UIKit #endif +/// Live, reference-typed presence flags shared between probe islands. UIKit +/// callbacks read and write these directly at event time; SwiftUI closure +/// captures of value state would go stale between renders and misreport +/// during navigation transitions. +@MainActor +final class WorkspaceBarPresence { + /// Whether the detail screen's content view is attached to a window. + /// False during whole-screen detachments (a deeper push, a pop, scene + /// teardown), when toolbar probes also detach for lifecycle reasons that + /// have nothing to do with the overflow More menu. + var detailContentAttached = false +} + extension View { + /// Tracks whether this view's content is attached to a UIKit window, + /// writing into the shared presence object in real time. + @ViewBuilder + func trackBarPresence(_ presence: WorkspaceBarPresence) -> some View { + #if canImport(UIKit) + background(ContentPresenceReader(presence: presence)) + #else + self + #endif + } + /// Reports this trailing toolbar item's rendered content width into the /// shared measurement dictionary, and optionally reports when the item's - /// content leaves the window while still structurally present, which on - /// iOS means the system folded the item into the overflow More menu. + /// content leaves the window while still structurally present. /// /// The leading title menu caps its width to the bar's remaining space so /// the trailing items are never squeezed into the More menu. Width @@ -18,8 +41,10 @@ extension View { /// removed it. /// /// `onLeaveBar` is only wired for items that are always structurally - /// present: a conditional item's structural removal also detaches its - /// probe and would be indistinguishable from a collapse. + /// present, and the callback must gate on the screen's content still + /// being attached (`WorkspaceBarPresence`): a whole-screen detachment + /// (deeper push, pop) also detaches this probe and is indistinguishable + /// from a More-menu collapse at this level. @ViewBuilder func measureTrailingToolbarItem( _ key: String, @@ -40,10 +65,39 @@ extension View { } #if canImport(UIKit) +private struct ContentPresenceReader: UIViewRepresentable { + let presence: WorkspaceBarPresence + + func makeUIView(context: Context) -> ContentPresenceProbeView { + let view = ContentPresenceProbeView() + view.isUserInteractionEnabled = false + view.backgroundColor = .clear + view.presence = presence + return view + } + + func updateUIView(_ view: ContentPresenceProbeView, context: Context) { + view.presence = presence + } +} + +final class ContentPresenceProbeView: UIView { + var presence: WorkspaceBarPresence? + + override func didMoveToWindow() { + super.didMoveToWindow() + let attached = window != nil + MainActor.assumeIsolated { + presence?.detailContentAttached = attached + } + } +} + /// Bridges out of the SwiftUI hosting island to observe whether the item's /// content is attached to a UIKit window. Detaching after having been -/// attached, while the SwiftUI view is still alive, is the observable -/// signature of the system moving the item into the overflow More menu. +/// attached, while the screen's own content stays on a window, is the +/// observable signature of the system moving the item into the overflow More +/// menu; the caller supplies that content-presence gate. private struct BarPresenceReader: UIViewRepresentable { let probeKey: String let onLeaveBar: (@MainActor () -> Void)? diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift index c2fea79eb292..b5248a8a0126 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift @@ -83,10 +83,15 @@ struct WorkspaceDetailView: View { // make the collapse sticky. @State private var trailingToolbarItemWidths: [String: CGFloat] = [:] /// Ratchets on when the trailing cluster's content leaves the bar while - /// structurally present: the system folded it into the More menu, so the - /// estimate reserves undershot this device's chrome. Never cleared for - /// this view's lifetime; the extra recovery reserve un-collapses the bar. + /// the screen's own content is still on a window: the system folded it + /// into the More menu, so the estimate reserves undershot this device's + /// chrome. Never cleared for this view's lifetime; the extra recovery + /// reserve un-collapses the bar. @State private var trailingToolbarCollapseDetected = false + /// Live window-attachment flags shared with the UIKit probes; reference + /// identity keeps event-time reads current where SwiftUI captures of + /// value state would be stale. + @State private var barPresence = WorkspaceBarPresence() /// Terminal captured for the current "View as Text" sheet presentation. @State private var textSheetSurfaceID: String? /// Identity of the in-flight New Browser creation. A late RPC result must @@ -181,6 +186,7 @@ struct WorkspaceDetailView: View { // minimizes the whole bar into a floating "…" pill, unlike the // terminal surface, which has no system scroll view. .mobilePinnedNavigationBar() + .trackBarPresence(barPresence) .toolbar { workspaceDetailToolbar } .task(id: workspace.rpcWorkspaceID.rawValue) { await store.refreshMobileBrowserPanels(workspaceID: workspace.rpcWorkspaceID.rawValue) @@ -380,7 +386,15 @@ struct WorkspaceDetailView: View { .measureTrailingToolbarItem( "trailing-cluster", into: $trailingToolbarItemWidths, - onLeaveBar: { trailingToolbarCollapseDetected = true } + onLeaveBar: { + // A deeper push or a pop detaches the whole screen, this + // content view included, before the bar items animate + // out; only a cluster detach while the content is still + // on a window is the More-menu collapse. + if barPresence.detailContentAttached { + trailingToolbarCollapseDetected = true + } + } ) }