From 210b7eeb404f21fb2b481d1680ce480a02179300 Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Tue, 30 Jun 2026 12:10:47 -0700 Subject: [PATCH 01/13] test: require iOS workspace toolbar persistence --- ios/cmuxUITests/cmuxUITests.swift | 84 +++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/ios/cmuxUITests/cmuxUITests.swift b/ios/cmuxUITests/cmuxUITests.swift index 2466412d709b..9c1cbedc3c6b 100644 --- a/ios/cmuxUITests/cmuxUITests.swift +++ b/ios/cmuxUITests/cmuxUITests.swift @@ -301,6 +301,25 @@ final class cmuxUITests: XCTestCase { terminalID: "workspace-3-terminal-1", server: server ) + let freshBackButton = app.buttons["MobileWorkspaceBackButton"] + let freshTitleMenu = app.buttons["MobileWorkspaceTitleMenu"] + let freshTerminalDropdown = app.buttons["MobileTerminalDropdown"] + assertWorkspaceToolbarVisible( + backButton: freshBackButton, + titleMenu: freshTitleMenu, + terminalDropdown: freshTerminalDropdown, + in: app, + context: "fresh no-agent workspace immediately after create" + ) + RunLoop.current.run(until: Date().addingTimeInterval(5)) + assertWorkspaceToolbarVisible( + backButton: freshBackButton, + titleMenu: freshTitleMenu, + terminalDropdown: freshTerminalDropdown, + in: app, + context: "fresh no-agent workspace after 5s" + ) + assertBackButtonFrameStaysCompactAroundPress(freshBackButton, in: app) tap(app.buttons["MobileTerminalDropdown"], in: app) assertTerminalMenuItemExists("workspace-3-terminal-1", in: app) @@ -2097,6 +2116,71 @@ final class cmuxUITests: XCTestCase { return false } + @MainActor + private func assertWorkspaceToolbarVisible( + backButton: XCUIElement, + titleMenu: XCUIElement, + terminalDropdown: XCUIElement, + in app: XCUIApplication, + context: String, + file: StaticString = #filePath, + line: UInt = #line + ) { + XCTAssertTrue(backButton.waitForExistence(timeout: 4), "\(context): missing back button", file: file, line: line) + XCTAssertTrue(titleMenu.waitForExistence(timeout: 4), "\(context): missing title menu", file: file, line: line) + XCTAssertTrue(terminalDropdown.waitForExistence(timeout: 4), "\(context): missing terminal dropdown", file: file, line: line) + XCTAssertTrue( + waitForCompactToolbarHeightsToMatch( + titleMenu: titleMenu, + backButton: backButton, + surfacePicker: terminalDropdown, + tolerance: 2, + timeout: 4, + file: file, + line: line + ), + "\(context): toolbar items must keep compact native heights", + file: file, + line: line + ) + } + + @MainActor + private func assertBackButtonFrameStaysCompactAroundPress( + _ backButton: XCUIElement, + in app: XCUIApplication, + file: StaticString = #filePath, + line: UInt = #line + ) { + guard let before = waitForToolbarFrame(of: backButton, timeout: 4) else { + XCTFail("Back button has no usable frame before press", file: file, line: line) + return + } + let start = app.coordinate(withNormalizedOffset: .zero) + .withOffset(CGVector(dx: before.midX, dy: before.midY)) + let end = app.coordinate(withNormalizedOffset: .zero) + .withOffset(CGVector(dx: before.midX, dy: before.midY + 90)) + start.press(forDuration: 0.25, thenDragTo: end) + guard let after = waitForToolbarFrame(of: backButton, timeout: 4) else { + XCTFail("Back button disappeared after press", file: file, line: line) + return + } + XCTAssertLessThanOrEqual( + after.height, + before.height + 4, + "Back button press must not leave an enlarged chevron/control frame. before=\(before), after=\(after)", + file: file, + line: line + ) + XCTAssertLessThanOrEqual( + after.width, + before.width + 8, + "Back button press must not leave a stretched rectangular control frame. before=\(before), after=\(after)", + file: file, + line: line + ) + } + @MainActor private func tapCompactToolbarTitleMenu( _ titleMenu: XCUIElement, From a84d4df2e297b09890ea7398328d946e8aaebca6 Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Tue, 30 Jun 2026 12:11:22 -0700 Subject: [PATCH 02/13] fix: keep iOS workspace toolbar owner stable --- .../CmuxMobileShellUI/WorkspaceChatPane.swift | 61 +----- .../WorkspaceDetailView+AgentChat.swift | 21 --- .../WorkspaceDetailView.swift | 173 +++++++++--------- 3 files changed, 87 insertions(+), 168 deletions(-) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceChatPane.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceChatPane.swift index abd391df1c49..c9c194d726ab 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceChatPane.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceChatPane.swift @@ -12,25 +12,13 @@ import UIKit /// The agent chat rendered inline in the workspace detail, in place of the /// terminal, when chat mode is toggled on. There is no cover and no Done /// button: the same toolbar toggle flips back to the terminal. -struct WorkspaceChatPane: View { +struct WorkspaceChatPane: View { let session: ChatSessionDescriptor let conversation: ChatConversationStore let store: CMUXMobileShellStore - /// The owning workspace's name, shown as the header title (so the header - /// reads as the workspace, not the session's first prompt). - let workspaceName: String - /// The name of the tab/terminal this session lives on, shown as the - /// header subtitle. - let tabName: String? /// Composer draft, owned by the parent so it survives toggling back to /// the terminal and returning mid-thought. @Binding var draft: String - /// Compact-stack back button owned by the workspace toolbar. - let backButtonConfiguration: WorkspaceBackButtonConfiguration? - /// Whether the workspace title pill should open a menu. - let isTitleMenuEnabled: Bool - /// Workspace-scoped actions exposed from the title pill. - let titleMenuContent: () -> TitleMenuContent /// Flips chat mode off (the toggle's "back to terminal" path). let onExitChat: () -> Void @@ -38,9 +26,6 @@ struct WorkspaceChatPane: View { @State private var accessoryConfiguration = TerminalAccessoryConfiguration.shared @State private var isShowingShortcutSettings = false - /// Full content width, used to bound the centered toolbar header so a long - /// workspace name truncates before the trailing toolbar buttons. - @State private var contentWidth: CGFloat = 0 var body: some View { Group { @@ -53,56 +38,12 @@ struct WorkspaceChatPane: View { runsStoreTask: false, onOpenTerminal: openTerminal ) - // The host (workspace detail) owns the nav bar, so the live - // session-state header is supplied here rather than by ChatScreen, - // which would be dropped under the workspace's own chrome. - .onGeometryChange(for: CGFloat.self) { $0.size.width } action: { contentWidth = $0 } - .toolbar { - if backButtonConfiguration != nil { - ToolbarItem(placement: .topBarLeading) { - workspaceBackToolbarButton - } - } - ToolbarItem(placement: .principal) { - // WorkspaceDetailView mounts the terminal picker/chat - // toggle in a sibling trailing toolbar item. - WorkspaceTitleMenu( - contentWidth: contentWidth, - hasBackButton: backButtonConfiguration != nil, - hasTrailingCluster: true, - hasChatToggle: true, - isEnabled: isTitleMenuEnabled, - menuContent: titleMenuContent - ) { - ChatSessionHeaderView( - descriptor: conversation.descriptor, - agentState: conversation.agentState, - isConnected: conversation.isConnected, - titleOverride: workspaceName, - subtitle: tabName, - style: .toolbarCompact - ) - } - } - } } .sheet(isPresented: $isShowingShortcutSettings) { TerminalShortcutsSettingsView(scope: .agentChat) } } - @ViewBuilder - private var workspaceBackToolbarButton: some View { - if let backButtonConfiguration { - WorkspaceBackButton( - unreadCount: backButtonConfiguration.unreadCount, - badgeContrast: backButtonConfiguration.badgeContrast, - action: backButtonConfiguration.action - ) - .mobileGlassCompactToolbarControl() - } - } - /// The escape hatch: select the session's terminal surface, then leave /// chat mode so the terminal shows. private func openTerminal() { diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+AgentChat.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+AgentChat.swift index 20276351e8ee..4b1196644f23 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+AgentChat.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+AgentChat.swift @@ -92,15 +92,10 @@ extension WorkspaceDetailView { session: session, conversation: conversation, store: store, - workspaceName: workspace.name, - tabName: tabName(for: session), draft: Binding( get: { chatDrafts[session.id] ?? "" }, set: { chatDrafts[session.id] = $0 } ), - backButtonConfiguration: backButtonConfiguration, - isTitleMenuEnabled: hasTitleMenuActions, - titleMenuContent: { titleMenuContent }, onExitChat: { withAnimation(.snappy(duration: 0.28)) { isChatMode = false @@ -111,22 +106,6 @@ extension WorkspaceDetailView { .id(session.id) .frame(maxWidth: .infinity, maxHeight: .infinity) .mobileChatTopScrollEdgeLayout(legacyTopPadding: terminalTopPadding) - .mobileTerminalNavigationChrome() - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - toolbarTrailingCluster - } - } - .task(id: chatRefreshKey) { await refreshChatSessions() } - .closeWorkspaceConfirmation( - isPresented: $isConfirmingClose, - confirm: confirmCloseWorkspaceFromMenu - ) - .workspaceRenameDialog( - isPresented: $isRenamePresented, - text: $renameText, - onSave: commitRenameFromDialog - ) } else { Color.clear .task(id: session.id) { diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift index 2b11af258a36..9b367588b253 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift @@ -1,4 +1,5 @@ import CmuxAgentChat +import CmuxAgentChatUI import CmuxMobileBrowser import CmuxMobileDiagnostics import CmuxMobileShell @@ -91,19 +92,96 @@ struct WorkspaceDetailView: View { } var body: some View { - Group { + let content = Group { detailSurfaceContent } + #if os(iOS) - .task(id: chatConversationWarmKey) { - await runWarmChatConversation() + content + .onGeometryChange(for: CGFloat.self) { $0.size.width } action: { contentWidth = $0 } + .navigationTitle(systemNavigationTitle) + .mobileTerminalNavigationChrome() + .toolbar { workspaceDetailToolbar } + .task(id: chatRefreshKey) { await refreshChatSessions() } + .task(id: chatConversationWarmKey) { + await runWarmChatConversation() + } + .onChange(of: selectedTerminalID) { _, _ in + refreshCachedChatToggleAnchor() + } + .closeWorkspaceConfirmation( + isPresented: $isConfirmingClose, + confirm: confirmCloseWorkspaceFromMenu + ) + .sheet(isPresented: $isFeedbackComposerPresented) { + feedbackComposer + } + .sheet(isPresented: $isTextSheetPresented) { + TerminalTextSheetView(surfaceID: textSheetSurfaceID) + } + .workspaceRenameDialog( + isPresented: $isRenamePresented, + text: $renameText, + onSave: commitRenameFromDialog + ) + .mobileConnectionRecoveryOverlay(store: store, signOut: signOut) + #else + content + .closeWorkspaceConfirmation( + isPresented: $isConfirmingClose, + confirm: confirmCloseWorkspaceFromMenu + ) + .mobileConnectionRecoveryOverlay(store: store, signOut: signOut) + #endif + } + + #if os(iOS) + @ToolbarContentBuilder + private var workspaceDetailToolbar: some ToolbarContent { + if backButtonConfiguration != nil { + ToolbarItem(placement: .topBarLeading) { workspaceBackToolbarButton } } - .onChange(of: selectedTerminalID) { _, _ in - refreshCachedChatToggleAnchor() + ToolbarItem(placement: .principal) { + WorkspaceTitleMenu( + contentWidth: contentWidth, + hasBackButton: backButtonConfiguration != nil, + hasTrailingCluster: true, + hasChatToggle: shouldShowChatToggle, + isEnabled: hasTitleMenuActions, + menuContent: { titleMenuContent } + ) { + toolbarTitleLabel + } + } + ToolbarItem(placement: .topBarTrailing) { + toolbarTrailingCluster + } + } + + @ViewBuilder + private var toolbarTitleLabel: some View { + if isChatMode, + let session = chosenChatSession, + let conversation = chatConversationStores[session.id] { + ChatSessionHeaderView( + descriptor: conversation.descriptor, + agentState: conversation.agentState, + isConnected: conversation.isConnected, + titleOverride: workspace.name, + subtitle: tabName(for: session), + style: .toolbarCompact + ) + } else if let browser = activeBrowser { + Text(browser.title ?? workspace.name) + .font(.headline) + .lineLimit(1) + .truncationMode(.tail) + .foregroundStyle(TerminalPalette.foreground) + } else { + WorkspaceToolbarTitleView(title: workspace.name, subtitle: selectedToolbarSubtitle) } - #endif - .mobileConnectionRecoveryOverlay(store: store, signOut: signOut) } + #endif @ViewBuilder private var detailSurfaceContent: some View { @@ -136,43 +214,6 @@ struct WorkspaceDetailView: View { // Key on the surface id so switching/reopening rebuilds the WKWebView. .id(browser.id.rawValue) .frame(maxWidth: .infinity, maxHeight: .infinity) - .onGeometryChange(for: CGFloat.self) { $0.size.width } action: { contentWidth = $0 } - .navigationTitle("") - .mobileTerminalNavigationChrome() - .toolbar { - if backButtonConfiguration != nil { - ToolbarItem(placement: .topBarLeading) { workspaceBackToolbarButton } - } - ToolbarItem(placement: .principal) { - WorkspaceTitleMenu( - contentWidth: contentWidth, - hasBackButton: backButtonConfiguration != nil, - hasTrailingCluster: true, - hasChatToggle: shouldShowChatToggle, - isEnabled: hasTitleMenuActions, - menuContent: { titleMenuContent } - ) { - Text(browser.title ?? workspace.name) - .font(.headline) - .lineLimit(1) - .truncationMode(.tail) - .foregroundStyle(TerminalPalette.foreground) - } - } - ToolbarItem(placement: .topBarTrailing) { - toolbarTrailingCluster - } - } - .task(id: chatRefreshKey) { await refreshChatSessions() } - .closeWorkspaceConfirmation( - isPresented: $isConfirmingClose, - confirm: confirmCloseWorkspaceFromMenu - ) - .workspaceRenameDialog( - isPresented: $isRenamePresented, - text: $renameText, - onSave: commitRenameFromDialog - ) } #endif @@ -237,9 +278,6 @@ struct WorkspaceDetailView: View { #endif } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - #if os(iOS) - .onGeometryChange(for: CGFloat.self) { $0.size.width } action: { contentWidth = $0 } - #endif .overlay(alignment: .topLeading) { MobileMacConnectionStatusPill(host: host, status: connectionStatus) .padding(.top, 10) @@ -300,53 +338,14 @@ struct WorkspaceDetailView: View { #else .background(TerminalPalette.background) #endif + #if !os(iOS) .navigationTitle(systemNavigationTitle) .mobileTerminalNavigationChrome() - #if os(iOS) - .task(id: chatRefreshKey) { await refreshChatSessions() } - #endif .toolbar { - #if os(iOS) - if backButtonConfiguration != nil { - ToolbarItem(placement: .topBarLeading) { workspaceBackToolbarButton } - } - ToolbarItem(placement: .principal) { - WorkspaceTitleMenu( - contentWidth: contentWidth, - hasBackButton: backButtonConfiguration != nil, - hasTrailingCluster: true, - hasChatToggle: shouldShowChatToggle, - isEnabled: hasTitleMenuActions, - menuContent: { titleMenuContent } - ) { - WorkspaceToolbarTitleView(title: workspace.name, subtitle: selectedToolbarSubtitle) - } - } - ToolbarItem(placement: .topBarTrailing) { - toolbarTrailingCluster - } - #else ToolbarItem { terminalToolbarButtons } - #endif - } - .closeWorkspaceConfirmation( - isPresented: $isConfirmingClose, - confirm: confirmCloseWorkspaceFromMenu - ) - #if canImport(UIKit) - .sheet(isPresented: $isFeedbackComposerPresented) { - feedbackComposer - } - .sheet(isPresented: $isTextSheetPresented) { - TerminalTextSheetView(surfaceID: textSheetSurfaceID) } - .workspaceRenameDialog( - isPresented: $isRenamePresented, - text: $renameText, - onSave: commitRenameFromDialog - ) #endif } From bc3be3d0f63b6ff2ba41a4ea377f7bd4ada07b92 Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Tue, 30 Jun 2026 17:27:13 -0700 Subject: [PATCH 03/13] fix: align iOS workspace title glass --- .../MobileLeadingToolbarTitleWidth.swift | 29 +++++++ .../MobileNavTitleWidth.swift | 39 ---------- .../WorkspaceBackButton.swift | 5 +- .../WorkspaceDetailView.swift | 32 ++++---- .../WorkspaceTitleMenu.swift | 4 +- .../MobileLeadingToolbarTitleWidthTests.swift | 50 +++++++++++++ .../MobileNavTitleWidthTests.swift | 75 ------------------- 7 files changed, 102 insertions(+), 132 deletions(-) create mode 100644 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileLeadingToolbarTitleWidth.swift delete mode 100644 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileNavTitleWidth.swift create mode 100644 Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileLeadingToolbarTitleWidthTests.swift delete mode 100644 Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileNavTitleWidthTests.swift diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileLeadingToolbarTitleWidth.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileLeadingToolbarTitleWidth.swift new file mode 100644 index 000000000000..b50a29d4d379 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileLeadingToolbarTitleWidth.swift @@ -0,0 +1,29 @@ +import CoreGraphics + +/// Width math for the leading glass workspace title menu. +/// +/// The workspace title belongs beside the back button, not in the centered +/// principal slot. Reserve the trailing toolbar cluster and the leading back +/// control so the title truncates before it can underlap native toolbar items. +struct MobileLeadingToolbarTitleWidth { + let contentWidth: CGFloat + let hasBackButton: Bool + let hasTrailingCluster: Bool + let hasChatToggle: Bool + + static let backButtonReserve: CGFloat = 44 + static let trailingReserveBase: CGFloat = 60 + static let chatToggleReserve: CGFloat = 56 + static let barMarginsAndSpacing: CGFloat = 44 + static let unmeasuredFallback: CGFloat = 180 + static let floor: CGFloat = 96 + + var cap: CGFloat { + guard contentWidth > 0 else { return Self.unmeasuredFallback } + let leading = hasBackButton ? Self.backButtonReserve : 0 + let trailing = hasTrailingCluster + ? Self.trailingReserveBase + (hasChatToggle ? Self.chatToggleReserve : 0) + : 0 + return max(0, contentWidth - leading - trailing - Self.barMarginsAndSpacing) + } +} diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileNavTitleWidth.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileNavTitleWidth.swift deleted file mode 100644 index a53ce461b986..000000000000 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileNavTitleWidth.swift +++ /dev/null @@ -1,39 +0,0 @@ -import CoreGraphics - -/// Width math for the centered glass nav-bar title pill, factored out so the -/// "grow the middle as much as possible" rule is pure and testable. -/// -/// The title is a screen-centered `.principal` toolbar item, so it is bound by -/// TWICE the wider of the two visible side clusters (the leading custom back -/// button vs. the trailing terminal picker plus, when present, the chat toggle). -/// Reserving only the visible side chrome lets a long title use as much of the -/// center as it safely can before truncating. -struct MobileNavTitleWidth { - let contentWidth: CGFloat - let hasBackButton: Bool - let hasTrailingCluster: Bool - let hasChatToggle: Bool - - /// Reserved width of the leading cluster: the custom back button (chevron + - /// optional unread-count pill) plus the bar's leading margin. - static let leadingReserve: CGFloat = 84 - /// Reserved width of the trailing cluster with just the terminal picker. - static let trailingReserveBase: CGFloat = 60 - /// Extra width the agent-chat toggle adds to the trailing cluster. - static let chatToggleReserve: CGFloat = 56 - /// Fallback before the pane width has been measured. - static let unmeasuredFallback: CGFloat = 180 - /// Preferred minimum width when the safe centered slot can fit it. - static let floor: CGFloat = 96 - - /// Max safe width for the centered title pill. - var cap: CGFloat { - guard contentWidth > 0 else { return Self.unmeasuredFallback } - let leading = hasBackButton ? Self.leadingReserve : 0 - let trailing = hasTrailingCluster - ? Self.trailingReserveBase + (hasChatToggle ? Self.chatToggleReserve : 0) - : 0 - let widerSide = max(leading, trailing) - return max(0, contentWidth - 2 * widerSide) - } -} diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceBackButton.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceBackButton.swift index 19be3e26f5d4..4e98c96304fb 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceBackButton.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceBackButton.swift @@ -14,8 +14,10 @@ struct WorkspaceBackButton: View { Button(action: action) { HStack(spacing: 5) { Image(systemName: "chevron.backward") - .fontWeight(.semibold) + .font(.system(size: 17, weight: .semibold)) + .imageScale(.medium) .foregroundStyle(.primary) + .frame(width: 17, height: 22) if unreadCount > 0 { Text(countText) // Smaller than the chevron, on a small mono circle. @@ -27,7 +29,6 @@ struct WorkspaceBackButton: View { .background(badgeFillColor, in: .circle) } } - .contentShape(.rect) } .accessibilityLabel(accessibilityLabel) .accessibilityIdentifier("MobileWorkspaceBackButton") diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift index 322ed33804da..4540f9a941b2 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift @@ -50,7 +50,7 @@ struct WorkspaceDetailView: View { /// editable text (seeded with the current name when presented). @State var isRenamePresented = false @State var renameText = "" - /// Live pane width, used to width-cap the centered glass title pill so a long + /// Live pane width, used to width-cap the leading glass title pill so a long /// workspace name truncates instead of underlapping the toolbar buttons. @State private var contentWidth: CGFloat = 0 /// Captured at the moment the "View as Text" action is tapped so the @@ -138,19 +138,10 @@ struct WorkspaceDetailView: View { #if os(iOS) @ToolbarContentBuilder private var workspaceDetailToolbar: some ToolbarContent { - if backButtonConfiguration != nil { - ToolbarItem(placement: .topBarLeading) { workspaceBackToolbarButton } - } - ToolbarItem(placement: .principal) { - WorkspaceTitleMenu( - contentWidth: contentWidth, - hasBackButton: backButtonConfiguration != nil, - hasTrailingCluster: true, - hasChatToggle: shouldShowChatToggle, - isEnabled: hasTitleMenuActions, - menuContent: { titleMenuContent } - ) { - toolbarTitleLabel + ToolbarItem(placement: .topBarLeading) { + HStack(spacing: 8) { + workspaceBackToolbarButton + workspaceTitleToolbarMenu } } ToolbarItem(placement: .topBarTrailing) { @@ -158,6 +149,19 @@ struct WorkspaceDetailView: View { } } + private var workspaceTitleToolbarMenu: some View { + WorkspaceTitleMenu( + contentWidth: contentWidth, + hasBackButton: backButtonConfiguration != nil, + hasTrailingCluster: true, + hasChatToggle: shouldShowChatToggle, + isEnabled: hasTitleMenuActions, + menuContent: { titleMenuContent } + ) { + toolbarTitleLabel + } + } + @ViewBuilder private var toolbarTitleLabel: some View { if isChatMode, diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceTitleMenu.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceTitleMenu.swift index 1c2379a60b99..c2dda302ef02 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceTitleMenu.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceTitleMenu.swift @@ -28,7 +28,7 @@ struct WorkspaceTitleMenu: View { } private var fittedLabel: some View { - let cap = MobileNavTitleWidth( + let cap = MobileLeadingToolbarTitleWidth( contentWidth: contentWidth, hasBackButton: hasBackButton, hasTrailingCluster: hasTrailingCluster, @@ -37,7 +37,7 @@ struct WorkspaceTitleMenu: View { return label() .frame( - minWidth: min(MobileNavTitleWidth.floor, cap), + minWidth: min(MobileLeadingToolbarTitleWidth.floor, cap), maxWidth: cap, alignment: .leading ) diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileLeadingToolbarTitleWidthTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileLeadingToolbarTitleWidthTests.swift new file mode 100644 index 000000000000..b5e5512d40d8 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileLeadingToolbarTitleWidthTests.swift @@ -0,0 +1,50 @@ +import CoreGraphics +import Testing +@testable import CmuxMobileShellUI + +@Suite struct MobileLeadingToolbarTitleWidthTests { + private func cap( + _ contentWidth: CGFloat, + hasBackButton: Bool = true, + hasTrailingCluster: Bool = true, + hasChatToggle: Bool = true + ) -> CGFloat { + MobileLeadingToolbarTitleWidth( + contentWidth: contentWidth, + hasBackButton: hasBackButton, + hasTrailingCluster: hasTrailingCluster, + hasChatToggle: hasChatToggle + ).cap + } + + @Test func unmeasuredReturnsFallback() { + #expect(cap(0) == MobileLeadingToolbarTitleWidth.unmeasuredFallback) + } + + @Test func leadingTitleReservesBackAndTrailingControls() { + let expected = 393 + - MobileLeadingToolbarTitleWidth.backButtonReserve + - MobileLeadingToolbarTitleWidth.trailingReserveBase + - MobileLeadingToolbarTitleWidth.chatToggleReserve + - MobileLeadingToolbarTitleWidth.barMarginsAndSpacing + + #expect(cap(393) == expected) + } + + @Test func titleGainsRoomWithoutChatToggle() { + #expect(cap(393, hasChatToggle: false) > cap(393, hasChatToggle: true)) + } + + @Test func titleGainsRoomWithoutBackButton() { + #expect(cap(393, hasBackButton: false) > cap(393, hasBackButton: true)) + } + + @Test func noTrailingClusterDoesNotReserveChatToggle() { + let withoutTrailing = cap(393, hasTrailingCluster: false) + let expected = 393 + - MobileLeadingToolbarTitleWidth.backButtonReserve + - MobileLeadingToolbarTitleWidth.barMarginsAndSpacing + + #expect(withoutTrailing == expected) + } +} diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileNavTitleWidthTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileNavTitleWidthTests.swift deleted file mode 100644 index ddd85f609d18..000000000000 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileNavTitleWidthTests.swift +++ /dev/null @@ -1,75 +0,0 @@ -import CoreGraphics -import Testing -@testable import CmuxMobileShellUI - -@Suite struct MobileNavTitleWidthTests { - private func cap( - _ contentWidth: CGFloat, - hasBackButton: Bool = true, - hasTrailingCluster: Bool = true, - hasChatToggle: Bool = true - ) -> CGFloat { - MobileNavTitleWidth( - contentWidth: contentWidth, - hasBackButton: hasBackButton, - hasTrailingCluster: hasTrailingCluster, - hasChatToggle: hasChatToggle - ).cap - } - - @Test func unmeasuredReturnsFallback() { - #expect(cap(0) == MobileNavTitleWidth.unmeasuredFallback) - } - - @Test func growsWithPaneWidth() { - let narrow = cap(320) - let wide = cap(1024) - #expect(wide > narrow) - } - - @Test func moreRoomWithoutChatToggle() { - let withToggle = cap(393) - let withoutToggle = cap(393, hasChatToggle: false) - #expect(withoutToggle > withToggle) - } - - @Test func measuredCapNeverExceedsSafeCenteredSlot() { - let trailing = MobileNavTitleWidth.trailingReserveBase + MobileNavTitleWidth.chatToggleReserve - #expect(cap(320) == 320 - 2 * trailing) - } - - @Test func moreRoomWithoutBackButton() { - let withBack = cap(393, hasChatToggle: false) - let withoutBack = cap(393, hasBackButton: false, hasChatToggle: false) - #expect(withoutBack > withBack) - } - - @Test func centeredCapReservesWiderSideSymmetrically() { - let measured = cap(393) - let expected = 393 - 2 * max( - MobileNavTitleWidth.leadingReserve, - MobileNavTitleWidth.trailingReserveBase + MobileNavTitleWidth.chatToggleReserve - ) - #expect(measured == expected) - } - - /// The whole point of the change: the old flat 300pt reserve left only ~93pt - /// of title on a 393pt phone. The tight reserve must give a long title - /// noticeably more room, even with the chat toggle present. - @Test func growsMoreThanLegacyFlatReserve() { - let measured = cap(393) - #expect(measured > 393 - 300) - } - - @Test func noTrailingClusterDoesNotReserveChatToggle() { - let withTrailing = cap(393) - let withoutTrailing = cap(393, hasTrailingCluster: false) - - #expect(withoutTrailing == 393 - 2 * MobileNavTitleWidth.leadingReserve) - #expect(withoutTrailing > withTrailing) - } - - @Test func noSideClustersCanUseMeasuredWidth() { - #expect(cap(393, hasBackButton: false, hasTrailingCluster: false) == 393) - } -} From 32297e26f93c973f0b6adad762e63f90560c5b9b Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Tue, 30 Jun 2026 18:07:20 -0700 Subject: [PATCH 04/13] fix: use native iOS toolbar item styling --- .../CmuxMobileShellUI/WorkspaceDetailView.swift | 11 ++++++----- .../CmuxMobileShellUI/WorkspaceTitleMenu.swift | 9 +++++---- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift index 4540f9a941b2..8ac9474743de 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift @@ -138,12 +138,14 @@ struct WorkspaceDetailView: View { #if os(iOS) @ToolbarContentBuilder private var workspaceDetailToolbar: some ToolbarContent { - ToolbarItem(placement: .topBarLeading) { - HStack(spacing: 8) { + if backButtonConfiguration != nil { + ToolbarItem(placement: .topBarLeading) { workspaceBackToolbarButton - workspaceTitleToolbarMenu } } + ToolbarItem(placement: .topBarLeading) { + workspaceTitleToolbarMenu + } ToolbarItem(placement: .topBarTrailing) { toolbarTrailingCluster } @@ -360,7 +362,7 @@ struct WorkspaceDetailView: View { } #if os(iOS) - /// Compact-stack back button rendered as its own leading toolbar island. + /// Native leading toolbar back button. SwiftUI supplies toolbar styling. @ViewBuilder private var workspaceBackToolbarButton: some View { if let backButtonConfiguration { @@ -369,7 +371,6 @@ struct WorkspaceDetailView: View { badgeContrast: backButtonConfiguration.badgeContrast, action: backButtonConfiguration.action ) - .mobileGlassCompactToolbarControl() } } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceTitleMenu.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceTitleMenu.swift index c2dda302ef02..7d25a6d5b5dc 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceTitleMenu.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceTitleMenu.swift @@ -1,4 +1,3 @@ -import CmuxMobileSupport import SwiftUI struct WorkspaceTitleMenu: View { @@ -18,11 +17,13 @@ struct WorkspaceTitleMenu: View { } label: { fittedLabel } - .mobileGlassCompactToolbarControl() .accessibilityIdentifier("MobileWorkspaceTitleMenu") } else { - fittedLabel - .mobileGlassCompactNavigationTitle() + Button {} label: { + fittedLabel + } + .allowsHitTesting(false) + .accessibilityRemoveTraits(.isButton) .accessibilityIdentifier("MobileWorkspaceTitleMenu") } } From 4bd42935b017d1931fe7bff5caa6897762ff3109 Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Tue, 30 Jun 2026 18:12:31 -0700 Subject: [PATCH 05/13] fix: separate iOS leading toolbar items --- .../Sources/CmuxMobileShellUI/WorkspaceDetailView.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift index 8ac9474743de..e8f5a78b8e74 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift @@ -142,6 +142,9 @@ struct WorkspaceDetailView: View { ToolbarItem(placement: .topBarLeading) { workspaceBackToolbarButton } + if #available(iOS 26.0, *) { + ToolbarSpacer(.fixed, placement: .topBarLeading) + } } ToolbarItem(placement: .topBarLeading) { workspaceTitleToolbarMenu From c917839d5a432f7a6f7f615a415ac094f5d55263 Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Tue, 30 Jun 2026 18:21:29 -0700 Subject: [PATCH 06/13] fix: keep compact workspace route selection-owned --- .../Sources/CmuxMobileShellUI/WorkspaceShellView.swift | 4 ---- 1 file changed, 4 deletions(-) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift index 30ec2d9548b5..8b14f2321366 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift @@ -170,10 +170,6 @@ struct WorkspaceShellView: View { } store.selectedWorkspaceID = selectedWorkspaceID } - .onChange(of: store.workspaces.map(\.id)) { _, workspaceIDs in - compactNavigationPath.removeAll { !workspaceIDs.contains($0) } - autoOpenSelectedWorkspaceForSoakIfNeeded() - } .onAppear { autoOpenSelectedWorkspaceForSoakIfNeeded() } From 5681831a6f3ce589dbe300429bf485c15c2f28ee Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Tue, 30 Jun 2026 18:27:11 -0700 Subject: [PATCH 07/13] Revert "fix: keep compact workspace route selection-owned" This reverts commit c917839d5a432f7a6f7f615a415ac094f5d55263. --- .../Sources/CmuxMobileShellUI/WorkspaceShellView.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift index 8b14f2321366..30ec2d9548b5 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift @@ -170,6 +170,10 @@ struct WorkspaceShellView: View { } store.selectedWorkspaceID = selectedWorkspaceID } + .onChange(of: store.workspaces.map(\.id)) { _, workspaceIDs in + compactNavigationPath.removeAll { !workspaceIDs.contains($0) } + autoOpenSelectedWorkspaceForSoakIfNeeded() + } .onAppear { autoOpenSelectedWorkspaceForSoakIfNeeded() } From 7bb235578b7e1b4192e49c633d5f2d1927dac5c5 Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Wed, 1 Jul 2026 14:32:18 -0700 Subject: [PATCH 08/13] debug: trace iOS toolbar lifecycle --- .../WorkspaceDetailContainer.swift | 82 +++++++++++++------ .../WorkspaceDetailView.swift | 24 ++++++ .../WorkspaceShellView.swift | 15 ++++ 3 files changed, 94 insertions(+), 27 deletions(-) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailContainer.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailContainer.swift index b290b299c3a7..bb518ed2a5c5 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailContainer.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailContainer.swift @@ -1,3 +1,4 @@ +import CmuxMobileDiagnostics import CmuxMobileShell import CmuxMobileShellModel import CmuxMobileSupport @@ -37,35 +38,62 @@ struct WorkspaceDetailContainer: View { } var body: some View { - if let workspace { - WorkspaceDetailView( - host: store.connectedHostName, - connectionStatus: workspace.macConnectionStatus ?? store.macConnectionStatus, - workspace: workspace, - store: store, - createWorkspace: createWorkspace, - canCreateWorkspace: canCreateWorkspace, - createTerminal: { store.createTerminal(in: workspace.id) }, - closeWorkspace: closeWorkspaceClosure, - reportTerminalViewport: store.reportTerminalViewport, - sendTerminalInput: store.sendTerminalRawInput, - safeAreaContext: safeAreaContext, - backButtonConfiguration: backButtonConfiguration, - signOut: signOut - ) - .onAppear { - if store.selectedWorkspaceID != workspace.id { - store.selectedWorkspaceID = workspace.id + Group { + if let workspace { + WorkspaceDetailView( + host: store.connectedHostName, + connectionStatus: workspace.macConnectionStatus ?? store.macConnectionStatus, + workspace: workspace, + store: store, + createWorkspace: createWorkspace, + canCreateWorkspace: canCreateWorkspace, + createTerminal: { store.createTerminal(in: workspace.id) }, + closeWorkspace: closeWorkspaceClosure, + reportTerminalViewport: store.reportTerminalViewport, + sendTerminalInput: store.sendTerminalRawInput, + safeAreaContext: safeAreaContext, + backButtonConfiguration: backButtonConfiguration, + signOut: signOut + ) + .onAppear { + MobileDebugLog.anchormux( + "toolbar.container.detailAppear requested=\(workspaceID?.rawValue ?? "nil") resolved=\(workspace.id.rawValue) selected=\(store.selectedWorkspaceID?.rawValue ?? "nil") terminals=\(workspace.terminals.count) back=\(backButtonConfiguration != nil)" + ) + if store.selectedWorkspaceID != workspace.id { + store.selectedWorkspaceID = workspace.id + } } + .onDisappear { + MobileDebugLog.anchormux( + "toolbar.container.detailDisappear requested=\(workspaceID?.rawValue ?? "nil") resolved=\(workspace.id.rawValue) selected=\(store.selectedWorkspaceID?.rawValue ?? "nil")" + ) + } + .task(id: workspace.id) { + await store.openWorkspace(workspace.id) + } + } else { + ContentUnavailableView( + L10n.string("mobile.workspace.emptyTitle", defaultValue: "No Workspace"), + systemImage: "rectangle.stack" + ) } - .task(id: workspace.id) { - await store.openWorkspace(workspace.id) - } - } else { - ContentUnavailableView( - L10n.string("mobile.workspace.emptyTitle", defaultValue: "No Workspace"), - systemImage: "rectangle.stack" - ) } + .onAppear { + MobileDebugLog.anchormux("toolbar.container.appear \(debugSignature)") + } + .onChange(of: debugSignature) { _, signature in + MobileDebugLog.anchormux("toolbar.container.change \(signature)") + } + } + + private var debugSignature: String { + [ + "requested=\(workspaceID?.rawValue ?? "nil")", + "resolved=\(workspace?.id.rawValue ?? "nil")", + "selected=\(store.selectedWorkspaceID?.rawValue ?? "nil")", + "selectedTerminal=\(store.selectedTerminalID?.rawValue ?? "nil")", + "workspaces=\(store.workspaces.map(\.id.rawValue).joined(separator: ","))", + "back=\(backButtonConfiguration != nil)", + ].joined(separator: " ") } } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift index e8f5a78b8e74..b73ca564b1b3 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift @@ -107,8 +107,18 @@ struct WorkspaceDetailView: View { await runWarmChatConversation() } .onChange(of: selectedTerminalID) { _, _ in + MobileDebugLog.anchormux("toolbar.detail.selectedTerminal \(debugToolbarSignature)") refreshCachedChatToggleAnchor() } + .onChange(of: debugToolbarSignature) { _, signature in + MobileDebugLog.anchormux("toolbar.detail.change \(signature)") + } + .onAppear { + MobileDebugLog.anchormux("toolbar.detail.appear \(debugToolbarSignature)") + } + .onDisappear { + MobileDebugLog.anchormux("toolbar.detail.disappear \(debugToolbarSignature)") + } .closeWorkspaceConfirmation( isPresented: $isConfirmingClose, confirm: confirmCloseWorkspaceFromMenu @@ -167,6 +177,20 @@ struct WorkspaceDetailView: View { } } + private var debugToolbarSignature: String { + [ + "workspace=\(workspace.id.rawValue)", + "selected=\(store.selectedWorkspaceID?.rawValue ?? "nil")", + "selectedTerminal=\(selectedTerminalID ?? "nil")", + "terminals=\(workspace.terminals.map(\.id.rawValue).joined(separator: ","))", + "back=\(backButtonConfiguration != nil)", + "titleActions=\(hasTitleMenuActions)", + "chatToggle=\(shouldShowChatToggle)", + "browser=\(activeBrowser?.id.rawValue ?? "nil")", + "width=\(Int(contentWidth))", + ].joined(separator: " ") + } + @ViewBuilder private var toolbarTitleLabel: some View { if isChatMode, diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift index 30ec2d9548b5..c3ad0dfdc47d 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift @@ -1,4 +1,5 @@ import Foundation +import CmuxMobileDiagnostics import CmuxMobileShell import CmuxMobileShellModel import CmuxMobileWorkspace @@ -144,6 +145,9 @@ struct WorkspaceShellView: View { } } .onChange(of: store.selectedWorkspaceID) { _, selectedWorkspaceID in + MobileDebugLog.anchormux( + "toolbar.shell.selected workspace=\(selectedWorkspaceID?.rawValue ?? "nil") path=\(debugCompactNavigationPath) pendingCreate=\(pendingCompactCreateNavigationWorkspaceIDs != nil)" + ) if let createdPath = WorkspaceShellCompactNavigationPolicy.pathForCreatedWorkspaceSelection( currentPath: compactNavigationPath, selectedWorkspaceID: selectedWorkspaceID, @@ -161,6 +165,9 @@ struct WorkspaceShellView: View { autoOpenSelectedWorkspaceForSoakIfNeeded() } .onChange(of: compactNavigationPath) { _, path in + MobileDebugLog.anchormux( + "toolbar.shell.path path=\(path.map(\.rawValue).joined(separator: ",")) selected=\(store.selectedWorkspaceID?.rawValue ?? "nil")" + ) guard let selectedWorkspaceID = path.last else { return } @@ -171,7 +178,11 @@ struct WorkspaceShellView: View { store.selectedWorkspaceID = selectedWorkspaceID } .onChange(of: store.workspaces.map(\.id)) { _, workspaceIDs in + MobileDebugLog.anchormux( + "toolbar.shell.workspaceIDs ids=\(workspaceIDs.map(\.rawValue).joined(separator: ",")) pathBefore=\(debugCompactNavigationPath)" + ) compactNavigationPath.removeAll { !workspaceIDs.contains($0) } + MobileDebugLog.anchormux("toolbar.shell.workspaceIDs pathAfter=\(debugCompactNavigationPath)") autoOpenSelectedWorkspaceForSoakIfNeeded() } .onAppear { @@ -230,6 +241,10 @@ struct WorkspaceShellView: View { } } + private var debugCompactNavigationPath: String { + compactNavigationPath.map(\.rawValue).joined(separator: ",") + } + /// Apply (and clear) a pending deep-link navigation intent. On the compact /// stack this pushes the workspace; on the split layout the store's /// selection already presents the detail column, so consuming just clears From cdd503698878c021f0b2c641bcca3defd9e31613 Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Wed, 1 Jul 2026 21:12:04 -0700 Subject: [PATCH 09/13] fix: fold iOS chat toolbar glass into toolbar PR --- .../ChatKeyboardTrackingViewController.swift | 33 +++++++++++++------ .../Screen/ChatScrollEdgeCoordinator.swift | 17 +++++++--- .../ChatTranscriptUITableView.swift | 11 +++++-- ios/cmuxUITests/cmuxUITests.swift | 16 ++++++++- 4 files changed, 60 insertions(+), 17 deletions(-) diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/ChatKeyboardTrackingViewController.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/ChatKeyboardTrackingViewController.swift index 0626875c3008..bc993c933a43 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/ChatKeyboardTrackingViewController.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/ChatKeyboardTrackingViewController.swift @@ -23,6 +23,7 @@ final class ChatKeyboardTrackingViewController private let keyboardContentView = UIView(frame: .zero) private let transcriptClipView = UIView(frame: .zero) + private let bottomChromeContainerView = UIView(frame: .zero) private let composerBackgroundView = UIVisualEffectView(effect: nil) let transcriptHostingController: UIHostingController let composerHostingController: UIHostingController @@ -90,11 +91,16 @@ final class ChatKeyboardTrackingViewController transcriptHostingController.view.translatesAutoresizingMaskIntoConstraints = false transcriptClipView.addSubview(transcriptHostingController.view) + bottomChromeContainerView.backgroundColor = .clear + bottomChromeContainerView.clipsToBounds = false + bottomChromeContainerView.translatesAutoresizingMaskIntoConstraints = false + keyboardContentView.addSubview(bottomChromeContainerView) + composerBackgroundView.translatesAutoresizingMaskIntoConstraints = false composerBackgroundView.isUserInteractionEnabled = false composerBackgroundView.clipsToBounds = true configureComposerBackground() - keyboardContentView.addSubview(composerBackgroundView) + bottomChromeContainerView.addSubview(composerBackgroundView) addChild(composerHostingController) composerHostingController.view.backgroundColor = .clear @@ -103,7 +109,7 @@ final class ChatKeyboardTrackingViewController composerHostingController.view.translatesAutoresizingMaskIntoConstraints = false composerHostingController.view.setContentHuggingPriority(.required, for: .vertical) composerHostingController.view.setContentCompressionResistancePriority(.required, for: .vertical) - keyboardContentView.addSubview(composerHostingController.view) + bottomChromeContainerView.addSubview(composerHostingController.view) installLayoutConstraints() transcriptHostingController.didMove(toParent: self) @@ -151,14 +157,19 @@ final class ChatKeyboardTrackingViewController transcriptHostingController.view.bottomAnchor.constraint(equalTo: transcriptClipView.bottomAnchor), transcriptHeightConstraint, - composerBackgroundView.topAnchor.constraint(equalTo: composerHostingController.view.topAnchor), - composerBackgroundView.leadingAnchor.constraint(equalTo: keyboardContentView.leadingAnchor), - composerBackgroundView.trailingAnchor.constraint(equalTo: keyboardContentView.trailingAnchor), - composerBackgroundView.bottomAnchor.constraint(equalTo: keyboardContentView.bottomAnchor), + bottomChromeContainerView.topAnchor.constraint(equalTo: composerHostingController.view.topAnchor), + bottomChromeContainerView.leadingAnchor.constraint(equalTo: keyboardContentView.leadingAnchor), + bottomChromeContainerView.trailingAnchor.constraint(equalTo: keyboardContentView.trailingAnchor), + bottomChromeContainerView.bottomAnchor.constraint(equalTo: keyboardContentView.bottomAnchor), + + composerBackgroundView.topAnchor.constraint(equalTo: bottomChromeContainerView.topAnchor), + composerBackgroundView.leadingAnchor.constraint(equalTo: bottomChromeContainerView.leadingAnchor), + composerBackgroundView.trailingAnchor.constraint(equalTo: bottomChromeContainerView.trailingAnchor), + composerBackgroundView.bottomAnchor.constraint(equalTo: bottomChromeContainerView.bottomAnchor), - composerHostingController.view.leadingAnchor.constraint(equalTo: keyboardContentView.leadingAnchor), - composerHostingController.view.trailingAnchor.constraint(equalTo: keyboardContentView.trailingAnchor), - composerHostingController.view.bottomAnchor.constraint(equalTo: keyboardContentView.bottomAnchor), + composerHostingController.view.leadingAnchor.constraint(equalTo: bottomChromeContainerView.leadingAnchor), + composerHostingController.view.trailingAnchor.constraint(equalTo: bottomChromeContainerView.trailingAnchor), + composerHostingController.view.bottomAnchor.constraint(equalTo: bottomChromeContainerView.bottomAnchor), composerHeightConstraint, ]) } @@ -302,6 +313,7 @@ final class ChatKeyboardTrackingViewController keyboardContentView.layer.removeAllAnimations() transcriptClipView.layer.removeAllAnimations() transcriptHostingController.view.layer.removeAllAnimations() + bottomChromeContainerView.layer.removeAllAnimations() composerBackgroundView.layer.removeAllAnimations() composerHostingController.view.layer.removeAllAnimations() } @@ -419,12 +431,13 @@ final class ChatKeyboardTrackingViewController scrollEdgeCoordinator.configure( tableView: tables.first, owner: self, - composerView: composerHostingController.view + bottomChromeView: bottomChromeContainerView ) } private func updateComposerVisibility() { guard isViewLoaded else { return } + bottomChromeContainerView.isHidden = !showsComposer composerHostingController.view.isHidden = !showsComposer composerBackgroundView.isHidden = !showsComposer updateMeasuredGeometryConstants() diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/ChatScrollEdgeCoordinator.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/ChatScrollEdgeCoordinator.swift index 89de728e9fd0..89c409ff842a 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/ChatScrollEdgeCoordinator.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/ChatScrollEdgeCoordinator.swift @@ -11,11 +11,11 @@ final class ChatScrollEdgeCoordinator { func configure( tableView: ChatTranscriptUITableView?, owner: UIViewController, - composerView: UIView + bottomChromeView: UIView ) { configureEdgeEffect(for: tableView) configureContentScrollView(tableView, owner: owner) - configureBottomInteraction(tableView, composerView: composerView) + configureBottomInteraction(tableView, bottomChromeView: bottomChromeView) } func reset() { @@ -57,7 +57,7 @@ final class ChatScrollEdgeCoordinator { private func configureBottomInteraction( _ tableView: ChatTranscriptUITableView?, - composerView: UIView + bottomChromeView: UIView ) { if #available(iOS 26.0, *) { guard let tableView else { @@ -71,14 +71,20 @@ final class ChatScrollEdgeCoordinator { } else { interaction = UIScrollEdgeElementContainerInteraction() interaction.edge = .bottom - composerView.addInteraction(interaction) + bottomChromeView.addInteraction(interaction) bottomInteraction = interaction } if bottomInteractionTableView !== tableView { + #if DEBUG + bottomInteractionTableView?.recordBottomEdgeElementContainerRegistration(false) + #endif interaction.scrollView = tableView bottomInteractionTableView = tableView } + #if DEBUG + tableView.recordBottomEdgeElementContainerRegistration(true) + #endif } } @@ -86,6 +92,9 @@ final class ChatScrollEdgeCoordinator { if let interaction = bottomInteraction { interaction.view?.removeInteraction(interaction) } + #if DEBUG + bottomInteractionTableView?.recordBottomEdgeElementContainerRegistration(false) + #endif bottomInteraction = nil bottomInteractionTableView = nil } diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptUITableView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptUITableView.swift index b1f79d17d098..8d8293327b5d 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptUITableView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptUITableView.swift @@ -40,6 +40,7 @@ final class ChatTranscriptUITableView: UITableView { private var debugTopEdgeEffectSoft = false private var debugBottomEdgeEffectSoft = false private var debugTopContentScrollViewRegistered = false + private var debugBottomEdgeElementContainerRegistered = false #endif #if DEBUG @@ -89,6 +90,11 @@ final class ChatTranscriptUITableView: UITableView { debugTopContentScrollViewRegistered = isRegistered updateDebugAccessibilityValue() } + + func recordBottomEdgeElementContainerRegistration(_ isRegistered: Bool) { + debugBottomEdgeElementContainerRegistered = isRegistered + updateDebugAccessibilityValue() + } #endif func keyboardViewportSnapshot() -> MobileScrollViewportSnapshot { @@ -236,7 +242,7 @@ final class ChatTranscriptUITableView: UITableView { let presentationGap = composerPresentationMinY - presentationFrameMaxY recordKeyboardAnimationGap(presentationGap) return String( - format: "frameMinY=%.2f;frameMaxY=%.2f;frameHeight=%.2f;presentationFrameMaxY=%.2f;boundsHeight=%.2f;offsetY=%.2f;adjustedTopInset=%.2f;adjustedBottomInset=%.2f;visibleTopY=%.2f;visibleBottomY=%.2f;contentHeight=%.2f;distanceFromBottom=%.2f;keyboardEvents=%d;keyboardOverlap=%.2f;keyboardTargetOverlap=%.2f;keyboardGuideOverlap=%.2f;keyboardBottomConstraint=%.2f;composerMinY=%.2f;composerPresentationMinY=%.2f;presentationGap=%.2f;topChromeOverlayInset=%.2f;composerOverlayBottomInset=%.2f;keyboardAnimationActive=%d;keyboardAnimationProgress=%.2f;keyboardTransitionDuration=%.3f;maxAnimationPresentationGap=%.2f;keyboardAnimationSamples=%d;topEdgeEffectSoft=%d;bottomEdgeEffectSoft=%d;topContentScrollViewRegistered=%d", + format: "frameMinY=%.2f;frameMaxY=%.2f;frameHeight=%.2f;presentationFrameMaxY=%.2f;boundsHeight=%.2f;offsetY=%.2f;adjustedTopInset=%.2f;adjustedBottomInset=%.2f;visibleTopY=%.2f;visibleBottomY=%.2f;contentHeight=%.2f;distanceFromBottom=%.2f;keyboardEvents=%d;keyboardOverlap=%.2f;keyboardTargetOverlap=%.2f;keyboardGuideOverlap=%.2f;keyboardBottomConstraint=%.2f;composerMinY=%.2f;composerPresentationMinY=%.2f;presentationGap=%.2f;topChromeOverlayInset=%.2f;composerOverlayBottomInset=%.2f;keyboardAnimationActive=%d;keyboardAnimationProgress=%.2f;keyboardTransitionDuration=%.3f;maxAnimationPresentationGap=%.2f;keyboardAnimationSamples=%d;topEdgeEffectSoft=%d;bottomEdgeEffectSoft=%d;topContentScrollViewRegistered=%d;bottomEdgeElementContainerRegistered=%d", locale: Locale(identifier: "en_US_POSIX"), frameInWindow.minY, frameInWindow.maxY, @@ -267,7 +273,8 @@ final class ChatTranscriptUITableView: UITableView { keyboardDebugAnimationSampleCount, debugTopEdgeEffectSoft ? 1 : 0, debugBottomEdgeEffectSoft ? 1 : 0, - debugTopContentScrollViewRegistered ? 1 : 0 + debugTopContentScrollViewRegistered ? 1 : 0, + debugBottomEdgeElementContainerRegistered ? 1 : 0 ) } diff --git a/ios/cmuxUITests/cmuxUITests.swift b/ios/cmuxUITests/cmuxUITests.swift index 9c1cbedc3c6b..4caaa7d213fc 100644 --- a/ios/cmuxUITests/cmuxUITests.swift +++ b/ios/cmuxUITests/cmuxUITests.swift @@ -1366,6 +1366,18 @@ final class cmuxUITests: XCTestCase { file: file, line: line ) + XCTAssertTrue( + afterKeyboard.bottomEdgeEffectSoft, + "Chat transcript must keep the soft bottom scroll-edge effect while the keyboard is up from \(scrollPosition), so bottom chrome blends instead of drawing a hard separator. before=\(beforeKeyboard) after=\(afterKeyboard)", + file: file, + line: line + ) + XCTAssertTrue( + afterKeyboard.bottomEdgeElementContainerRegistered, + "The keyboard-up shortcut row and input bar must remain registered as the bottom scroll-edge element container. Missing registration leaves a hard line between the transcript and bottom chrome. before=\(beforeKeyboard) after=\(afterKeyboard)", + file: file, + line: line + ) let keyboardFrame = keyboardFrameAfterFocus( in: app, overlap: afterKeyboard.keyboardOverlap, @@ -2286,9 +2298,10 @@ final class cmuxUITests: XCTestCase { let topEdgeEffectSoft: Bool let bottomEdgeEffectSoft: Bool let topContentScrollViewRegistered: Bool + let bottomEdgeElementContainerRegistered: Bool var description: String { - "frameMinY=\(frameMinY), frameMaxY=\(frameMaxY), frameHeight=\(frameHeight), presentationFrameMaxY=\(presentationFrameMaxY), boundsHeight=\(boundsHeight), offsetY=\(offsetY), adjustedTopInset=\(adjustedTopInset), adjustedBottomInset=\(adjustedBottomInset), visibleTopY=\(visibleTopY), visibleBottomY=\(visibleBottomY), contentHeight=\(contentHeight), distanceFromBottom=\(distanceFromBottom), keyboardEvents=\(keyboardEvents), keyboardOverlap=\(keyboardOverlap), keyboardTargetOverlap=\(keyboardTargetOverlap), composerMinY=\(composerMinY), composerPresentationMinY=\(composerPresentationMinY), presentationGap=\(presentationGap), topChromeOverlayInset=\(topChromeOverlayInset), composerOverlayBottomInset=\(composerOverlayBottomInset), keyboardAnimationActive=\(keyboardAnimationActive), keyboardAnimationProgress=\(keyboardAnimationProgress), keyboardTransitionDuration=\(keyboardTransitionDuration), maxAnimationPresentationGap=\(maxAnimationPresentationGap), keyboardAnimationSamples=\(keyboardAnimationSamples), topEdgeEffectSoft=\(topEdgeEffectSoft), bottomEdgeEffectSoft=\(bottomEdgeEffectSoft), topContentScrollViewRegistered=\(topContentScrollViewRegistered)" + "frameMinY=\(frameMinY), frameMaxY=\(frameMaxY), frameHeight=\(frameHeight), presentationFrameMaxY=\(presentationFrameMaxY), boundsHeight=\(boundsHeight), offsetY=\(offsetY), adjustedTopInset=\(adjustedTopInset), adjustedBottomInset=\(adjustedBottomInset), visibleTopY=\(visibleTopY), visibleBottomY=\(visibleBottomY), contentHeight=\(contentHeight), distanceFromBottom=\(distanceFromBottom), keyboardEvents=\(keyboardEvents), keyboardOverlap=\(keyboardOverlap), keyboardTargetOverlap=\(keyboardTargetOverlap), composerMinY=\(composerMinY), composerPresentationMinY=\(composerPresentationMinY), presentationGap=\(presentationGap), topChromeOverlayInset=\(topChromeOverlayInset), composerOverlayBottomInset=\(composerOverlayBottomInset), keyboardAnimationActive=\(keyboardAnimationActive), keyboardAnimationProgress=\(keyboardAnimationProgress), keyboardTransitionDuration=\(keyboardTransitionDuration), maxAnimationPresentationGap=\(maxAnimationPresentationGap), keyboardAnimationSamples=\(keyboardAnimationSamples), topEdgeEffectSoft=\(topEdgeEffectSoft), bottomEdgeEffectSoft=\(bottomEdgeEffectSoft), topContentScrollViewRegistered=\(topContentScrollViewRegistered), bottomEdgeElementContainerRegistered=\(bottomEdgeElementContainerRegistered)" } var effectiveFrameMaxY: CGFloat { @@ -2343,6 +2356,7 @@ final class cmuxUITests: XCTestCase { self.topEdgeEffectSoft = (values["topEdgeEffectSoft"] ?? 0) >= 0.5 self.bottomEdgeEffectSoft = (values["bottomEdgeEffectSoft"] ?? 0) >= 0.5 self.topContentScrollViewRegistered = (values["topContentScrollViewRegistered"] ?? 0) >= 0.5 + self.bottomEdgeElementContainerRegistered = (values["bottomEdgeElementContainerRegistered"] ?? 0) >= 0.5 } } From eea731723da59eaf098c4689e91216cfb550c7cf Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Wed, 1 Jul 2026 23:29:10 -0700 Subject: [PATCH 10/13] Fix iOS keyboard chrome overlap constraint --- .../Screen/ChatKeyboardTrackingViewController.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/ChatKeyboardTrackingViewController.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/ChatKeyboardTrackingViewController.swift index 5c0a40bb877d..23b7486e6e5c 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/ChatKeyboardTrackingViewController.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/ChatKeyboardTrackingViewController.swift @@ -137,7 +137,7 @@ final class ChatKeyboardTrackingViewController let transcriptClipTopConstraint = transcriptClipView.topAnchor.constraint(equalTo: keyboardContentView.topAnchor) let transcriptClipBottomConstraint = transcriptClipView.bottomAnchor.constraint(equalTo: keyboardContentView.bottomAnchor) let transcriptHeightConstraint = transcriptHostingController.view.heightAnchor.constraint(equalToConstant: 0) - let composerBottomConstraint = composerHostingController.view.bottomAnchor.constraint(equalTo: keyboardContentView.bottomAnchor) + let composerBottomConstraint = bottomChromeContainerView.bottomAnchor.constraint(equalTo: keyboardContentView.bottomAnchor) self.composerHeightConstraint = composerHeightConstraint self.transcriptClipTopConstraint = transcriptClipTopConstraint self.transcriptClipBottomConstraint = transcriptClipBottomConstraint @@ -163,7 +163,7 @@ final class ChatKeyboardTrackingViewController bottomChromeContainerView.topAnchor.constraint(equalTo: composerHostingController.view.topAnchor), bottomChromeContainerView.leadingAnchor.constraint(equalTo: keyboardContentView.leadingAnchor), bottomChromeContainerView.trailingAnchor.constraint(equalTo: keyboardContentView.trailingAnchor), - bottomChromeContainerView.bottomAnchor.constraint(equalTo: keyboardContentView.bottomAnchor), + composerBottomConstraint, composerBackgroundView.topAnchor.constraint(equalTo: bottomChromeContainerView.topAnchor), composerBackgroundView.leadingAnchor.constraint(equalTo: bottomChromeContainerView.leadingAnchor), From 12acdebe573a1ad2154f9936b70f0b8b2bdcf76d Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Wed, 1 Jul 2026 23:43:25 -0700 Subject: [PATCH 11/13] Keep iOS toolbar debug signatures debug-only --- .../CmuxMobileShellUI/WorkspaceDetailContainer.swift | 8 ++++++++ .../Sources/CmuxMobileShellUI/WorkspaceDetailView.swift | 6 ++++++ 2 files changed, 14 insertions(+) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailContainer.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailContainer.swift index bb518ed2a5c5..d7f386ea8722 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailContainer.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailContainer.swift @@ -56,18 +56,22 @@ struct WorkspaceDetailContainer: View { signOut: signOut ) .onAppear { + #if DEBUG MobileDebugLog.anchormux( "toolbar.container.detailAppear requested=\(workspaceID?.rawValue ?? "nil") resolved=\(workspace.id.rawValue) selected=\(store.selectedWorkspaceID?.rawValue ?? "nil") terminals=\(workspace.terminals.count) back=\(backButtonConfiguration != nil)" ) + #endif if store.selectedWorkspaceID != workspace.id { store.selectedWorkspaceID = workspace.id } } + #if DEBUG .onDisappear { MobileDebugLog.anchormux( "toolbar.container.detailDisappear requested=\(workspaceID?.rawValue ?? "nil") resolved=\(workspace.id.rawValue) selected=\(store.selectedWorkspaceID?.rawValue ?? "nil")" ) } + #endif .task(id: workspace.id) { await store.openWorkspace(workspace.id) } @@ -78,14 +82,17 @@ struct WorkspaceDetailContainer: View { ) } } + #if DEBUG .onAppear { MobileDebugLog.anchormux("toolbar.container.appear \(debugSignature)") } .onChange(of: debugSignature) { _, signature in MobileDebugLog.anchormux("toolbar.container.change \(signature)") } + #endif } + #if DEBUG private var debugSignature: String { [ "requested=\(workspaceID?.rawValue ?? "nil")", @@ -96,4 +103,5 @@ struct WorkspaceDetailContainer: View { "back=\(backButtonConfiguration != nil)", ].joined(separator: " ") } + #endif } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift index 14f7c1ecb7b7..fb802c964a9e 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift @@ -107,9 +107,12 @@ struct WorkspaceDetailView: View { await runWarmChatConversation() } .onChange(of: selectedTerminalID) { _, _ in + #if DEBUG MobileDebugLog.anchormux("toolbar.detail.selectedTerminal \(debugToolbarSignature)") + #endif refreshCachedChatToggleAnchor() } + #if DEBUG .onChange(of: debugToolbarSignature) { _, signature in MobileDebugLog.anchormux("toolbar.detail.change \(signature)") } @@ -119,6 +122,7 @@ struct WorkspaceDetailView: View { .onDisappear { MobileDebugLog.anchormux("toolbar.detail.disappear \(debugToolbarSignature)") } + #endif .closeWorkspaceConfirmation( isPresented: $isConfirmingClose, confirm: confirmCloseWorkspaceFromMenu @@ -177,6 +181,7 @@ struct WorkspaceDetailView: View { } } + #if DEBUG private var debugToolbarSignature: String { [ "workspace=\(workspace.id.rawValue)", @@ -190,6 +195,7 @@ struct WorkspaceDetailView: View { "width=\(Int(contentWidth))", ].joined(separator: " ") } + #endif @ViewBuilder private var toolbarTitleLabel: some View { From 91d6d5d8c564157cd6316fd6d95bec939c59c0c8 Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Wed, 1 Jul 2026 23:58:17 -0700 Subject: [PATCH 12/13] Trim iOS toolbar file length growth --- .../ChatKeyboardTrackingViewController.swift | 21 +--- .../WorkspaceDetailView.swift | 106 ++++-------------- 2 files changed, 26 insertions(+), 101 deletions(-) diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/ChatKeyboardTrackingViewController.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/ChatKeyboardTrackingViewController.swift index 23b7486e6e5c..16a580dfded2 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/ChatKeyboardTrackingViewController.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/ChatKeyboardTrackingViewController.swift @@ -9,7 +9,6 @@ final class ChatKeyboardTrackingViewController get { transcriptHostingController.rootView } set { transcriptHostingController.rootView = newValue } } - var composerView: Composer { get { composerHostingController.rootView } set { composerHostingController.rootView = newValue } @@ -117,11 +116,7 @@ final class ChatKeyboardTrackingViewController composerHostingController.didMove(toParent: self) updateComposerVisibility() - let observer = NotificationCenter.default.addObserver( - forName: UIResponder.keyboardWillChangeFrameNotification, - object: nil, - queue: .main - ) { [weak self] notification in + let observer = NotificationCenter.default.addObserver(forName: UIResponder.keyboardWillChangeFrameNotification, object: nil, queue: .main) { [weak self] notification in guard let transition = MobileKeyboardTransition(notification: notification) else { return } @@ -470,9 +465,7 @@ final class ChatKeyboardTrackingViewController func trackedTranscriptTables(in view: UIView) -> [ChatTranscriptUITableView] { if let table = view as? ChatTranscriptUITableView { - // Stop at the transcript table itself; descending into its own - // cells/hosted row views would re-walk the entire transcript on - // every layout/keyboard geometry update. + // Stop at the transcript table itself to avoid walking hosted rows. return [table] } var tables: [ChatTranscriptUITableView] = [] @@ -488,16 +481,10 @@ final class ChatKeyboardTrackingViewController ) -> Bool { guard let window = view.window else { return false } let point = touch.location(in: window) - let transcriptFrame = transcriptHostingController.view.convert( - transcriptHostingController.view.bounds, - to: window - ) + let transcriptFrame = transcriptHostingController.view.convert(transcriptHostingController.view.bounds, to: window) guard transcriptFrame.contains(point) else { return false } guard !excludedKeyboardDismissFrame.contains(point) else { return false } - let composerFrame = composerHostingController.view.convert( - composerHostingController.view.bounds, - to: window - ) + let composerFrame = composerHostingController.view.convert(composerHostingController.view.bounds, to: window) return !composerFrame.contains(point) } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift index fb802c964a9e..7667c7d61aa0 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift @@ -31,13 +31,9 @@ struct WorkspaceDetailView: View { let safeAreaContext: MobileTerminalSafeAreaContext let backButtonConfiguration: WorkspaceBackButtonConfiguration? let signOut: (() -> Void)? - /// Phone-local browser surfaces, injected from the app root. When this - /// workspace has an active browser surface the detail view presents a - /// browser pane in place of the terminal; otherwise it shows the terminal. + /// Phone-local browser surfaces, injected from the app root. @Environment(BrowserSurfaceStore.self) private var browserStore - /// Drives the destructive close-workspace confirmation dialog launched from - /// the top-bar menu. Owned here (not in the menu builder) so the dialog stays - /// attached to the detail view across menu open/close cycles. + /// Drives the destructive close-workspace confirmation dialog. @State var isConfirmingClose = false #if canImport(UIKit) @State private var isFeedbackComposerPresented = false @@ -50,18 +46,12 @@ struct WorkspaceDetailView: View { /// editable text (seeded with the current name when presented). @State var isRenamePresented = false @State var renameText = "" - /// Live pane width, used to width-cap the leading glass title pill so a long - /// workspace name truncates instead of underlapping the toolbar buttons. + /// Live pane width for capping the leading glass title pill. @State private var contentWidth: CGFloat = 0 - /// Captured at the moment the "View as Text" action is tapped so the - /// sheet keeps showing the terminal the user asked about even if the - /// workspace selection changes underneath it (e.g. Mac-side sync) while - /// the sheet is open; the sheet loads its snapshot once per presentation. + /// Terminal captured for the current "View as Text" sheet presentation. @State private var textSheetSurfaceID: String? @State private var terminalPickerRows: [TerminalPickerMenuRow] = [] - /// Chat-mode toggle: when on (and a session exists) the detail renders - /// the agent chat inline in place of the terminal. The toolbar button - /// flips this; there is no cover and no Done button. + /// Chat-mode toggle for inline agent chat in place of the terminal. @State var isChatMode = false /// The session chat mode was entered on, pinned so a newer session /// sorting first cannot swap the conversation out from under the user @@ -69,21 +59,13 @@ struct WorkspaceDetailView: View { @State var pinnedChatSessionID: String? @State var chatSessions: [ChatSessionDescriptor] = [] @State var chatSessionsWorkspaceID: String? - /// Last terminal id whose cached snapshot said it had a chat session. This - /// keeps the toolbar affordance and title spacing stable while SwiftUI - /// swaps between terminal and chat mode, or while transport is reconnecting. + /// Last terminal id whose cached snapshot said it had a chat session. @State var cachedChatToggleTerminalID: String? /// Per-session chat stores kept warm while the workspace detail is visible. - /// The selected terminal's store runs even when terminal rendering is shown, - /// so toggling into chat presents the already-loaded transcript instead of - /// treating terminal mode as "no GUI data loaded." @State var chatConversationStores: [String: ChatConversationStore] = [:] /// Per-session composer drafts, surviving toggles back to the terminal. @State var chatDrafts: [String: String] = [:] - /// App lifecycle phase. Returning from `.background` re-pulls the session - /// list: pushes are best-effort and can be dropped while suspended, so on - /// foreground we re-read the host's authoritative list rather than trust - /// that every push arrived. + /// App lifecycle phase used to re-pull chat sessions on foreground. @Environment(\.scenePhase) var scenePhase #endif /// The active browser surface for this workspace, when a browser pane is open. @@ -255,12 +237,8 @@ struct WorkspaceDetailView: View { #endif private func detailContent() -> some View { - // `GhosttySurfaceView` owns the bottom accessory bar: it docks the - // `TerminalInputAccessoryAction` toolbar persistently at the bottom - // (above the keyboard when up, above the home indicator when down) and - // reserves its height in the terminal grid. The SwiftUI bar that used to - // live here has been removed so the two stacked toolbars from - // dogfood iosfin no longer fight for the same screen edge. + // `GhosttySurfaceView` owns the bottom accessory bar and reserves its + // height in the terminal grid. Group { #if os(iOS) if let terminalID = selectedTerminal?.id.rawValue { @@ -268,24 +246,14 @@ struct WorkspaceDetailView: View { surfaceID: terminalID, store: store, fontSize: MobileTerminalFontPreference.defaultSize, - // While the composer is presented the terminal input proxy - // must not grab first responder on attach. This covers both - // composer states: mid-compose (the field owns the keyboard - // and a surface re-create from switching terminals must not - // steal it back) and the default-open presentation (the field - // is visible but unfocused — iMessage semantics — so the - // keyboard stays DOWN until the user taps the terminal or the - // field). + // Do not let a terminal reattach steal focus while the + // composer owns or intentionally withholds the keyboard. autoFocusOnWindowAttach: store.shouldAutoFocusTerminalSurface(terminalID) && !store.isComposerPresented, isComposerActive: store.isComposerPresented ) - // Identity must track the selected terminal. The representable's - // coordinator binds its byte sink to the surfaceID at make time and - // `updateUIView` is a no-op, so without a per-terminal id SwiftUI - // reuses the first terminal's surface and the dropdown never switches. - // Keying on terminalID tears down the old surface (unregistering its - // sink via dismantleUIView) and builds the newly-selected one. + // Identity must track the selected terminal because the + // coordinator binds its byte sink to the surfaceID at make time. .id(terminalID) .onAppear { store.consumeTerminalAutoFocusSuppression(for: terminalID) @@ -297,13 +265,7 @@ struct WorkspaceDetailView: View { // avoidance; otherwise the view ALSO shrinks for the keyboard // and the reservation double-counts (extra gap when open). .ignoresSafeArea(.keyboard, edges: .bottom) - // Keep the grid INSIDE the top safe area and add extra blank top - // padding so the first rows sit clear of the Dynamic Island and - // the nav bar instead of being stuck in the non-visible area - // behind them. The padded region shows the terminal background - // (the window-filling `.background` below extends under the bar), - // so it reads as blank terminal color, and the glass title pill - // floats over it. + // Keep the grid clear of the Dynamic Island and nav bar. .padding(.top, terminalTopPadding) } else { TerminalPalette.background @@ -321,10 +283,7 @@ struct WorkspaceDetailView: View { .padding(.leading, 10) } .overlay { - // When the phone is not connected to this Mac, show a clear - // reconnecting/offline state with an action instead of a black - // terminal (the recurring "black screen" — a dropped connection left - // the user staring at an unrendered surface). + // Show a reconnecting/offline state instead of a black terminal. if connectionStatus != .connected { TerminalDisconnectedOverlay(status: connectionStatus, host: host) { Task { @@ -339,12 +298,7 @@ struct WorkspaceDetailView: View { } } #if os(iOS) && DEBUG - // Store-side composer seam (DEBUG/UI-test only): exposes the source-of-truth - // store flags that drive the surface's composer mirror, so a UI test can assert - // the store and surface agree across repeated open/close cycles and that the - // draft (`terminalInputText`) survives. Zero-size + read live on every query; - // never compiled into a shipping build. Pairs with `MobileComposerDockProbe` - // on the surface side. + // DEBUG/UI-test-only store-side composer probe. .overlay { ComposerStoreProbe( isComposerPresented: store.isComposerPresented, @@ -354,21 +308,14 @@ struct WorkspaceDetailView: View { } #endif #if os(iOS) - // The whole bottom dock (terminal grid / composer band / accessory toolbar / - // keyboard) is owned by `GhosttySurfaceView` in one coordinate system. The - // iMessage composer is mounted INTO the surface's composer band by - // `GhosttySurfaceRepresentable` (a `UIHostingController`), not added here as a - // `safeAreaInset`. There is no second layout system reaching into the - // surface's bottom, so the accessory toolbar can never be reparented out (its - // buttons can never disappear) and a composer-grow pushes only the terminal up. + // The whole bottom dock is owned by `GhosttySurfaceView` in one + // coordinate system, so composer growth pushes only the terminal up. .mobileTerminalSafeAreaExpansion( context: safeAreaContext, includesBottom: true ) .background { - // Fill the whole window, including under the translucent nav bar, so - // the glass tints the terminal's own dark color rather than the page - // background. + // Fill under translucent chrome with the terminal's own color. TerminalPalette.background .ignoresSafeArea(.container, edges: [.horizontal, .top, .bottom]) } @@ -393,8 +340,7 @@ struct WorkspaceDetailView: View { } #if os(iOS) - /// Leading back-button island. No explicit `.buttonStyle(.glass)`: iOS 26 backs - /// toolbar items in glass; a 2nd layer renders as an oversized square (see chat pane). + /// Leading back-button island; iOS 26 supplies toolbar glass. @ViewBuilder private var workspaceBackToolbarButton: some View { if let backButtonConfiguration { @@ -428,16 +374,8 @@ struct WorkspaceDetailView: View { .accessibilityIdentifier("MobileTerminalNewWorkspaceButton") } - // The surface picker is a native SwiftUI `Menu`, which renders as the platform menu - // (a `UIMenu` on iOS). That gives the standard menu gesture for free: a - // single tap opens it, and a press-and-drag from the button onto an item - // followed by a release selects that item. The previous `Button` + - // `.popover` was two separate hit-test sessions (tap to present, then tap an - // item), so it never supported press-drag-release. Selection still routes - // through `selectTerminalFromPicker`, which dismisses the keyboard, so the - // chrome behavior is preserved; only keyboard-dismiss-on-open is dropped - // because `Menu` has no will-open hook (the menu simply floats over the live - // keyboard like any nav-bar menu). + // Native menu keeps press-drag-release selection and routes through + // `selectTerminalFromPicker`; keyboard-dismiss-on-open is unavailable. var terminalPickerToolbarButton: some View { let liveRows = terminalPickerLiveRows let rows = terminalPickerRows.isEmpty ? liveRows : terminalPickerRows From cb6fa074ebde4741d0f4cb101f9aa6c0ba1e6482 Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Thu, 2 Jul 2026 00:05:55 -0700 Subject: [PATCH 13/13] Gate terminal text sheet to terminal mode --- .../Sources/CmuxMobileShellUI/WorkspaceDetailView.swift | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift index 7667c7d61aa0..86aaaa8b78d4 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift @@ -442,11 +442,9 @@ struct WorkspaceDetailView: View { #if canImport(UIKit) Section { - // Only while the terminal pane is showing: in browser mode the - // terminal surface is dismantled (nothing to capture) and the - // sheet modifier lives on `detailContent`, so the armed flag - // would pop the sheet later when the browser closes. - if activeBrowser == nil { + // Only while the terminal pane is showing: browser and chat modes + // do not mount a terminal surface for text capture. + if activeBrowser == nil && !isChatMode { Button(action: openTextSheetFromMenu) { Label( L10n.string("mobile.terminal.viewAsText", defaultValue: "View as Text"),