Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/swift-file-length-budget.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,6 @@
710 Sources/TerminalSSHSessionDetector.swift
709 Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarWorkspaceReorderDropResolver.swift
706 CLI/CMUXCLI+Config.swift
700 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift
699 cmuxTests/TerminalNotificationClearAllTests.swift
698 cmuxTests/RestorableAgentHookProviderResumeTests.swift
696 cmuxTests/KeyboardShortcutContextTests.swift
Expand All @@ -168,6 +167,7 @@
661 Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteSessionCoordinator.swift
660 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridInputCatchUpTests.swift
655 Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeLifecycle.swift
652 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift
650 Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Import/Detection/BrowserInstalledBrowserDetector.swift
650 Sources/Panels/MarkdownRemoteImageLoader.swift
648 cmuxTests/TerminalNotificationQueueTests.swift
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ struct AgentChatDemoScreen: View {

@Environment(\.dismiss) private var dismiss
@State private var stack: DemoStack?
@State private var contentWidth: CGFloat = 0

init(style: AgentChatDemoScreenStyle = .standalone) {
self.style = style
Expand Down Expand Up @@ -60,7 +59,6 @@ struct AgentChatDemoScreen: View {
}
case .inlineWorkspace:
baseChatScreen(for: stack)
.onGeometryChange(for: CGFloat.self) { $0.size.width } action: { contentWidth = $0 }
.toolbar {
ToolbarItem(placement: .topBarLeading) {
WorkspaceBackButton(
Expand All @@ -70,12 +68,7 @@ struct AgentChatDemoScreen: View {
)
}
ToolbarItem(placement: .principal) {
WorkspaceTitleMenu(
contentWidth: contentWidth,
hasBackButton: true,
hasTrailingCluster: true,
hasChatToggle: true
) {
WorkspaceTitleMenu {
Button(L10n.string("mobile.workspace.rename.title", defaultValue: "Rename Workspace")) {}
.accessibilityIdentifier("MobileWorkspaceTitleRenameMenuItem")
Button(L10n.string("mobile.workspace.markRead", defaultValue: "Mark as Read")) {}
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ struct WorkspaceDetailDelayedTerminalPreviewView: View {
MobileTerminalPreview(id: Self.terminalID, name: Self.terminalTitle),
]
)
store.replaceForegroundWorkspaceState([workspace])
store.replaceForegroundWorkspaceState(Self.workspaces(with: workspace))
store.selectedWorkspaceID = Self.workspaceID
store.selectedTerminalID = Self.terminalID
if Self.showsChatToggle {
Expand Down Expand Up @@ -99,13 +99,33 @@ struct WorkspaceDetailDelayedTerminalPreviewView: View {
if usesRefreshingTerminalMenu {
return [refreshingWorkspace(generation: 0)]
}
return [
MobileWorkspacePreview(
return Self.workspaces(
with: MobileWorkspacePreview(
id: workspaceID,
name: workspaceTitle,
terminals: []
),
]
)
)
}

private static func workspaces(with selectedWorkspace: MobileWorkspacePreview) -> [MobileWorkspacePreview] {
[selectedWorkspace] + unreadFixtureWorkspaces
}

private static var unreadFixtureWorkspaces: [MobileWorkspacePreview] {
guard let rawValue = ProcessInfo.processInfo.environment["CMUX_UITEST_WORKSPACE_DETAIL_UNREAD_BACK_COUNT"],
let count = Int(rawValue),
count > 0 else {
return []
}
return (1...count).map { index in
MobileWorkspacePreview(
id: MobileWorkspacePreview.ID(rawValue: "workspace-unread-\(index)"),
name: "Unread Workspace \(index)",
hasUnread: true,
terminals: []
)
}
}

private static func refreshingWorkspace(generation: Int) -> MobileWorkspacePreview {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
#if os(iOS)
import CmuxAgentChat
import CmuxAgentChatUI
import CmuxMobileBrowser
import CmuxMobileSupport
import SwiftUI

extension WorkspaceDetailView {
@ToolbarContentBuilder
var workspaceDetailToolbar: some ToolbarContent {
ToolbarItem(id: "workspace-toolbar", placement: .principal) {
workspaceToolbarContainer
}
}

/// The whole top bar as a single principal item: back, title/subtitle, and
/// the trailing controls in one layout so `.layoutPriority` can rank them.
/// Each island keeps its own Liquid Glass backing so the bar still reads as
/// three separate capsules.
@ViewBuilder
private var workspaceToolbarContainer: some View {
if #available(iOS 26.0, *) {
GlassEffectContainer {
workspaceToolbarLayout
}
} else {
workspaceToolbarLayout
}
}

private var workspaceToolbarLayout: some View {
HStack(spacing: workspaceToolbarIslandSpacing) {
if backButtonConfiguration != nil {
workspaceBackToolbarIsland
}

// The title/subtitle yields first: lower layout priority than the
// trailing controls, so it truncates rather than pushing them out
// or collapsing them into the system overflow menu.
workspaceTitleToolbarMenu
.layoutPriority(0)

Spacer(minLength: 0)

workspaceTrailingToolbarIsland
.layoutPriority(1)
}
// The navigation bar measures a principal item at its ideal width and
// centers the result. Report the container width as the ideal so UIKit
// caps it to the inset toolbar region, while `maxWidth` keeps the
// layout compressible and fills the capped proposal without overflow.
.frame(idealWidth: workspaceToolbarIdealWidth, maxWidth: .infinity, alignment: .leading)
}

private var workspaceToolbarIdealWidth: CGFloat? {
toolbarContentWidth > 0 ? toolbarContentWidth : nil
}

@ViewBuilder
private var workspaceBackToolbarIsland: some View {
if let backButtonConfiguration, backButtonConfiguration.unreadCount > 0 {
workspaceBackToolbarButton
.mobileGlassCompactToolbarControl()
.layoutPriority(1)
} else {
workspaceBackToolbarButton
.mobileGlassCircle()
.layoutPriority(1)
}
}

@ViewBuilder
private var workspaceTrailingToolbarIsland: some View {
if #available(iOS 26.0, *) {
toolbarTrailingCluster
.buttonStyle(.plain)
.glassEffect(.regular.interactive(), in: .capsule)
} else {
toolbarTrailingCluster
}
}

private var workspaceToolbarIslandSpacing: CGFloat { 10 }

private var workspaceTitleToolbarMenu: some View {
WorkspaceTitleMenu(
isEnabled: hasTitleMenuActions,
menuContent: { titleMenuContent }
) {
toolbarTitleLabel
}
}

@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
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ struct WorkspaceDetailView: View {
@Environment(BrowserSurfaceStore.self) private var browserStore
/// Drives the destructive close-workspace confirmation dialog.
@State var isConfirmingClose = false
/// Detail content width, reported as the principal toolbar item's ideal
/// width so the navigation bar grants it the full inset region. Readable by
/// the toolbar layout; only this view's geometry callback should mutate it.
@State private(set) var toolbarContentWidth: CGFloat = 0
#if canImport(UIKit)
@State private var isFeedbackComposerPresented = false
@State private var feedbackText = ""
Expand All @@ -44,8 +48,6 @@ struct WorkspaceDetailView: View {
/// editable text (seeded with the current name when presented).
@State var isRenamePresented = false
@State var renameText = ""
/// Live pane width for capping the leading glass title pill.
@State private var contentWidth: CGFloat = 0
/// Terminal captured for the current "View as Text" sheet presentation.
@State private var textSheetSurfaceID: String?
@State var terminalPickerRows: [TerminalPickerMenuRow] = []
Expand All @@ -69,7 +71,9 @@ struct WorkspaceDetailView: View {
@Environment(\.scenePhase) var scenePhase
#endif
/// The active browser surface for this workspace, when a browser pane is open.
private var activeBrowser: BrowserSurfaceState? {
/// Non-private so the toolbar title label (WorkspaceDetailView+Toolbar.swift)
/// can read it.
var activeBrowser: BrowserSurfaceState? {
browserStore.activeBrowser(for: workspace.id.rawValue)
}

Expand All @@ -78,9 +82,9 @@ struct WorkspaceDetailView: View {

#if os(iOS)
content
.onGeometryChange(for: CGFloat.self) { $0.size.width } action: { contentWidth = $0 }
.navigationTitle(systemNavigationTitle)
.mobileTerminalNavigationChrome()
.onGeometryChange(for: CGFloat.self) { $0.size.width } action: { toolbarContentWidth = $0 }
.toolbar { workspaceDetailToolbar }
.task(id: chatRefreshKey) { await refreshChatSessions() }
.task(id: chatConversationWarmKey) { await runWarmChatConversation() }
Expand Down Expand Up @@ -114,62 +118,8 @@ struct WorkspaceDetailView: View {
#endif
}

#if os(iOS)
@ToolbarContentBuilder
private var workspaceDetailToolbar: some ToolbarContent {
if backButtonConfiguration != nil {
ToolbarItem(id: "workspace-back", placement: .topBarLeading) {
workspaceBackToolbarButton
}
if #available(iOS 26.0, *) {
ToolbarSpacer(.fixed, placement: .topBarLeading)
}
}
ToolbarItem(id: "workspace-title", placement: .topBarLeading) {
workspaceTitleToolbarMenu
}
ToolbarItem(id: "workspace-trailing", placement: .topBarTrailing) {
toolbarTrailingCluster
}
}

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,
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
// The top-bar toolbar (`workspaceDetailToolbar` and its islands) lives in
// WorkspaceDetailView+Toolbar.swift.

@ViewBuilder
private var detailSurfaceContent: some View {
Expand Down Expand Up @@ -323,8 +273,10 @@ struct WorkspaceDetailView: View {

#if os(iOS)
/// Leading back-button island; iOS 26 supplies toolbar glass.
/// Non-private so the toolbar layout (WorkspaceDetailView+Toolbar.swift) can
/// wrap it in a glass island.
@ViewBuilder
private var workspaceBackToolbarButton: some View {
var workspaceBackToolbarButton: some View {
if let backButtonConfiguration {
WorkspaceBackButton(
unreadCount: backButtonConfiguration.unreadCount,
Expand Down
Loading
Loading