diff --git a/Memory/ARCHITECTURE.md b/Memory/ARCHITECTURE.md index 70da94de..74c17f3e 100644 --- a/Memory/ARCHITECTURE.md +++ b/Memory/ARCHITECTURE.md @@ -806,6 +806,11 @@ unsaved forms. Metrics has one independent stack in all widths; Vault search sho select that destination rather than constructing another Metrics page. Resizing never restarts live connections, transfers or Agent runs, or creates a new conversation. +Metrics detail and Assistant use the actual tab-bar placement to hide their toolbar +titles only beside the top tab bar. Assistant retains its conversation switcher as +an icon menu there; sidebar and bottom-bar layouts retain the original titles and +title menus. Assistant library pages retain their titles in every placement. + Editor saves do not gate layout changes. Their tasks, duplicate-submit protection, unsaved-dismiss confirmation and error feedback survive width changes. Sheets follow the presenting window's width using stable presentation modifiers. Cards use container diff --git a/TFI/Features/Assistant/MobileAssistantView.swift b/TFI/Features/Assistant/MobileAssistantView.swift index 640c099e..ceafdc75 100644 --- a/TFI/Features/Assistant/MobileAssistantView.swift +++ b/TFI/Features/Assistant/MobileAssistantView.swift @@ -21,6 +21,7 @@ struct MobileAssistantView: View { let app: MobileAppModel @Bindable var model: MobileAssistantSceneModel @Binding var libraryRoute: MobileAssistantLibraryRoute? + @Environment(\.tabBarPlacement) private var tabBarPlacement @State private var restoreComposerFocusAfterLibrary = false @State private var nextComposerFocusRequest = 0 @State private var composerFocusRequest: Int? @@ -41,11 +42,24 @@ struct MobileAssistantView: View { var body: some View { detail - .navigationTitle( - model.conversation.isEmpty ? String(localized: "Assistant") : model.conversation.derivedTitle - ) + .navigationTitle(conversationTitle) .navigationBarTitleDisplayMode(.inline) - .toolbarTitleMenu { conversationTitleMenu } + .toolbar(removing: usesConversationIcon ? .title : nil) + .toolbar { + if usesConversationIcon { + ToolbarItem(placement: .topBarLeading) { + Menu { + conversationTitleMenu + } label: { + Label("Conversations", systemImage: "bubble.left.and.bubble.right") + } + .labelStyle(.iconOnly) + .accessibilityValue(conversationTitle) + } + } else if libraryRoute == nil { + ToolbarTitleMenu { conversationTitleMenu } + } + } .toolbar { Button("New Conversation", systemImage: "plus.bubble", action: newConversation) .labelStyle(.iconOnly) @@ -133,6 +147,14 @@ struct MobileAssistantView: View { } } + private var conversationTitle: String { + model.conversation.isEmpty ? String(localized: "Assistant") : model.conversation.derivedTitle + } + + private var usesConversationIcon: Bool { + tabBarPlacement == .topBar && libraryRoute == nil + } + private var detail: some View { MobileAssistantConversation( app: app, diff --git a/TFI/Features/Metrics/MobileMetricsView.swift b/TFI/Features/Metrics/MobileMetricsView.swift index 883771f8..bb3cc7f9 100644 --- a/TFI/Features/Metrics/MobileMetricsView.swift +++ b/TFI/Features/Metrics/MobileMetricsView.swift @@ -71,10 +71,11 @@ struct MobileMetricsView: View { } } -private struct MobileMetricsDetailView: View { +struct MobileMetricsDetailView: View { let app: MobileAppModel let server: ServerMetric @Environment(\.dismiss) private var dismiss + @Environment(\.tabBarPlacement) private var tabBarPlacement @State private var page = MobileMetricsDetailPage.status @State private var observer = UUID() @State private var availableWidth: CGFloat = 0 @@ -124,6 +125,7 @@ private struct MobileMetricsDetailView: View { } .navigationTitle(server.name) .navigationBarTitleDisplayMode(.inline) + .toolbar(removing: tabBarPlacement == .topBar ? .title : nil) .toolbar { ToolbarItem(placement: .principal) { if !MobileMetricsDetailPage.showsBothColumns(width: availableWidth) { diff --git a/TFITests/MobileAdaptiveLayoutTests.swift b/TFITests/MobileAdaptiveLayoutTests.swift index 9d2824c3..717df44e 100644 --- a/TFITests/MobileAdaptiveLayoutTests.swift +++ b/TFITests/MobileAdaptiveLayoutTests.swift @@ -1,4 +1,5 @@ import SwiftUI +import TermindApplication import TermindDomain import Testing import UIKit @@ -102,6 +103,139 @@ struct MobileAdaptiveLayoutTests { } } + @Test(arguments: ["en", "zh-Hans", "zh-Hant"]) + func assistantTitleAdaptsWithoutLosingItsConversationMenuOrDrafts(language: String) async throws { + guard UIDevice.current.userInterfaceIdiom == .pad else { return } + let app = try MobileAppModel(inMemory: true) + let scene = MobileSceneModel(app: app) + scene.selectedDestination = .assistant + await scene.assistant.load() + let first = ConversationModel() + first.appendUserTurn("Investigate the production database latency") + first.customTitle = language == "en" ? "Investigate the production database latency" : "排查生产数据库延迟与连接问题" + first.isPinned = true + first.composer.draft = "Keep the first draft" + let second = ConversationModel() + second.appendUserTurn("Review the deployment") + second.composer.draft = "Keep the second draft" + scene.assistant.library.insertAndSelect(second) + scene.assistant.library.insertAndSelect(first) + try await withRoot(app: app, scene: scene, language: language) { controller in + let tabs = try #require(find(UITabBarController.self, in: controller)) + let navigation = try #require(navigation(titled: first.derivedTitle, in: controller)) + let page = try #require(navigation.topViewController) + for (sizeClass, sidebar): (UIUserInterfaceSizeClass, Bool) in [ + (.regular, false), (.regular, true), (.compact, false), (.regular, false), + ] { + resize(controller, to: sizeClass, width: sizeClass == .regular ? 1366 : 430) + tabs.sidebar.isHidden = !sidebar + try await settle() + let topBar = sizeClass == .regular && !sidebar + capture(controller, name: "assistant-title-\(sizeClass.rawValue)-\(sidebar)-\(language)") + #expect(navigation.topViewController === page) + #expect(scene.assistant.conversation === first) + #expect(first.composer.draft == "Keep the first draft") + #expect( + visibleLabels(in: navigation.navigationBar).contains { $0.text == first.derivedTitle } == !topBar) + let switcher = toolbarButton(namedOneOf: ["Conversations", "对话", "對話"], in: navigation) + #expect((switcher != nil) == topBar) + if let switcher { + let trailingButtons = + page.navigationItem.trailingItemGroups.flatMap(\.barButtonItems) + + (page.navigationItem.rightBarButtonItems ?? []) + // New Conversation and More, without a second overflow for the hidden title menu. + #expect(Set(trailingButtons.map(ObjectIdentifier.init)).count == 2) + let menu = try #require(switcher.menu) + let actions = try await resolvedMenuActions(menu, in: page.view) + #expect(actions.map(\.title).contains(first.derivedTitle)) + let selectSecond = try #require(actions.first { $0.title == second.derivedTitle }) + UIButton(primaryAction: selectSecond).sendActions(for: .primaryActionTriggered) + try await settle() + #expect(scene.assistant.conversation === second) + #expect(second.composer.draft == "Keep the second draft") + let updatedSwitcher = try #require( + toolbarButton(namedOneOf: ["Conversations", "对话", "對話"], in: navigation)) + let updatedMenu = try #require(updatedSwitcher.menu) + let updatedActions = try await resolvedMenuActions(updatedMenu, in: page.view) + let selectFirst = try #require(updatedActions.first { $0.title == first.derivedTitle }) + UIButton(primaryAction: selectFirst).sendActions(for: .primaryActionTriggered) + try await settle() + #expect(scene.assistant.conversation === first) + } + } + for route in [MobileAssistantLibraryRoute.conversations, .archived] { + scene.assistantLibraryRoute = route + try await settle() + let title = try #require(navigation.topViewController?.navigationItem.title) + #expect(!title.isEmpty && title != first.derivedTitle) + #expect(visibleLabels(in: navigation.navigationBar).contains { $0.text == title }) + capture(controller, name: "assistant-library-\(route.rawValue)-\(language)") + scene.assistantLibraryRoute = nil + try await settle() + #expect(navigation.topViewController === page) + #expect(!visibleLabels(in: navigation.navigationBar).contains { $0.text == first.derivedTitle }) + } + } + } + + @Test(arguments: ["en", "zh-Hans", "zh-Hant"]) + func metricsDetailTitleAdaptsWhileItsPickerAndNavigationSurvive(language: String) async throws { + guard UIDevice.current.userInterfaceIdiom == .pad else { return } + let app = try MobileAppModel(inMemory: true) + let scene = MobileSceneModel(app: app) + scene.selectedDestination = .metrics + let server = ServerMetric( + selection: MetricsSelection(id: UUID(), enabled: false), + name: language == "en" ? "Production database server in Singapore" : "新加坡生产环境数据库服务器") + var features = MobileFeatureRegistry() + for feature in MobileFeatureRegistry.standard().registrations { + features.register( + feature.id == .metrics + ? MobileFeatureRegistration(id: .metrics, title: "Metrics", symbol: "waveform.path.ecg") { app, _ in + AnyView(MetricsDetailFeature(app: app, server: server)) + } : feature) + } + try await withRoot(app: app, scene: scene, language: language, features: features) { controller in + let tabs = try #require(find(UITabBarController.self, in: controller)) + let navigation = try #require(navigation(titled: server.name, in: controller)) + let page = try #require(navigation.topViewController) + var selectedProcesses = false + for (sizeClass, width, sidebar): (UIUserInterfaceSizeClass, CGFloat, Bool) in [ + (.regular, 1024, false), (.regular, 600, false), (.regular, 1366, true), + (.compact, 430, false), (.regular, 1024, false), (.compact, 430, false), + ] { + resize(controller, to: sizeClass, width: width) + tabs.sidebar.isHidden = !sidebar + try await settle() + capture(controller, name: "metrics-title-\(Int(width))-\(sidebar)-\(language)") + #expect(navigation.topViewController === page) + #expect(navigation.viewControllers.count == 2) + #expect(page.navigationItem.title == server.name) + #expect(toolbarButton(namedOneOf: ["Metrics", "监控", "監控"], in: navigation) != nil) + if sidebar { + #expect(visibleLabels(in: navigation.navigationBar).contains { $0.text == server.name }) + } else if sizeClass == .regular { + #expect(!visibleLabels(in: navigation.navigationBar).contains { $0.text == server.name }) + } + // The bottom bar leaves the principal toolbar slot available for the existing picker. + if sizeClass == .compact { + let picker = try #require(find(UISegmentedControl.self, in: controller.view)) + #expect(picker.numberOfSegments == 2) + #expect(picker.selectedSegmentIndex == (selectedProcesses ? 1 : 0)) + picker.selectedSegmentIndex = 1 + picker.sendActions(for: .valueChanged) + selectedProcesses = true + try await settle() + #expect(find(UICollectionView.self, in: page.view) != nil) + } + } + navigation.popViewController(animated: false) + try await settle() + #expect(navigation.viewControllers.count == 1) + #expect(scene.selectedDestination == .metrics) + } + } + @Test func settingsDepthAndUnsavedFormsSurviveWidthChanges() async throws { let app = try MobileAppModel(inMemory: true) let scene = MobileSceneModel(app: app) @@ -398,6 +532,47 @@ struct MobileAdaptiveLayoutTests { return (view as? UILabel).map { [$0] } ?? view.subviews.flatMap { visibleLabels(in: $0) } } + private func toolbarButton(namedOneOf names: Set, in navigation: UINavigationController) -> UIBarButtonItem? + { + guard let item = navigation.topViewController?.navigationItem else { return nil } + let buttons = + item.leadingItemGroups.flatMap(\.barButtonItems) + + item.trailingItemGroups.flatMap(\.barButtonItems) + + (item.leftBarButtonItems ?? []) + (item.rightBarButtonItems ?? []) + return buttons.first { button in + (button.accessibilityLabel ?? button.title ?? button.customView?.accessibilityLabel).map(names.contains) + == true + } + } + + private func menuActions(_ menu: UIMenu) -> [UIAction] { + menu.children.flatMap { element -> [UIAction] in + if let action = element as? UIAction { return [action] } + if let submenu = element as? UIMenu { return menuActions(submenu) } + return [] + } + } + + private func resolvedMenuActions(_ menu: UIMenu, in view: UIView) async throws -> [UIAction] { + // SwiftUI supplies deferred menu elements. Present the native menu to ask UIKit to resolve them. + let button = UIButton(frame: CGRect(x: 20, y: 100, width: 44, height: 44)) + button.menu = menu + button.showsMenuAsPrimaryAction = true + view.addSubview(button) + defer { button.removeFromSuperview() } + button.performPrimaryAction() + try await settle() + let interaction = try #require(button.contextMenuInteraction) + var actions: [UIAction] = [] + interaction.updateVisibleMenu { resolved in + actions = menuActions(resolved) + return resolved + } + interaction.dismissMenu() + try await settle() + return actions + } + private func settle() async throws { try await Task.sleep(for: .milliseconds(500)) } private func capture(_ controller: UIViewController, name: String) { @@ -408,6 +583,20 @@ struct MobileAdaptiveLayoutTests { } } +private struct MetricsDetailFeature: View { + let app: MobileAppModel + let server: ServerMetric + @State private var showsDetail = true + + var body: some View { + Text("Metrics") + .navigationTitle("Metrics") + .navigationDestination(isPresented: $showsDetail) { + MobileMetricsDetailView(app: app, server: server) + } + } +} + @Observable @MainActor private final class SavingDraft { enum Failure: Error { case expected } var isPresented = true diff --git a/Termind.xcodeproj/project.pbxproj b/Termind.xcodeproj/project.pbxproj index cdb25129..46d0b693 100644 --- a/Termind.xcodeproj/project.pbxproj +++ b/Termind.xcodeproj/project.pbxproj @@ -1770,7 +1770,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = TFI/TFI.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 21; + CURRENT_PROJECT_VERSION = 22; GENERATE_INFOPLIST_FILE = YES; ICLOUD_CONTAINER_ENVIRONMENT = Development; INFOPLIST_FILE = TFI/Info.plist; @@ -2043,7 +2043,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = TFI/TFI.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 21; + CURRENT_PROJECT_VERSION = 22; GENERATE_INFOPLIST_FILE = YES; ICLOUD_CONTAINER_ENVIRONMENT = Production; INFOPLIST_FILE = TFI/Info.plist;