From 0dcf243b700cd805241bf25e30b308e7c741fd90 Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Sun, 13 Sep 2026 17:00:20 +0800 Subject: [PATCH] Fix mobile Assistant interaction affordances --- .../AI/MobileAssistantConversation.swift | 3 +- .../AI/MobileAssistantInteraction.swift | 45 ++++++++++++++----- TFITests/MobileAssistantTests.swift | 27 +++++++---- TFITests/MobileNavigationParityTests.swift | 29 +++++++++++- 4 files changed, 81 insertions(+), 23 deletions(-) diff --git a/TFI/Features/AI/MobileAssistantConversation.swift b/TFI/Features/AI/MobileAssistantConversation.swift index 3a63f227..2632a7b9 100644 --- a/TFI/Features/AI/MobileAssistantConversation.swift +++ b/TFI/Features/AI/MobileAssistantConversation.swift @@ -430,7 +430,8 @@ private struct MobileAssistantSteeringQueue: View { Image(systemName: "xmark") .font(.caption.weight(.bold)) .frame(width: 28, height: 28) - .foregroundStyle(.secondary) + // Match the always-visible Permission control on iOS. + .foregroundStyle(Color.primary.opacity(0.75)) } .padding(.horizontal, 8) .padding(.vertical, 4) diff --git a/TFI/Features/AI/MobileAssistantInteraction.swift b/TFI/Features/AI/MobileAssistantInteraction.swift index 39126ad8..aee89f06 100644 --- a/TFI/Features/AI/MobileAssistantInteraction.swift +++ b/TFI/Features/AI/MobileAssistantInteraction.swift @@ -39,15 +39,17 @@ struct MobileAssistantInteraction: View { } } } else if let interaction = conversation.pendingUserInteraction { - MobileAssistantAdaptiveHeight { - switch interaction { - case .sshAuthentication(let request): + switch interaction { + case .sshAuthentication(let request): + MobileAssistantAdaptiveHeight { MobileAssistantAuthenticationForm(request: request, conversation: conversation, hosts: hosts) - case .sshHostKey(let request): - MobileAssistantHostKeyForm(request: request, conversation: conversation) - case .questions: - questions } + case .sshHostKey(let request): + MobileAssistantAdaptiveHeight { + MobileAssistantHostKeyForm(request: request, conversation: conversation) + } + case .questions: + questions } } } @@ -63,13 +65,33 @@ struct MobileAssistantInteraction: View { } } if let question = conversation.currentUserInputQuestion { - questionBlock(question) - .id(question.id) + // Match TFM: the decision controls remain reachable while only + // the current question gives way to scrolling. + ViewThatFits(in: .vertical) { + questionBlock(question) + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + scrollingQuestion(question) + } footer(for: question) } } } + private func scrollingQuestion(_ question: AgentUserQuestion) -> some View { + ScrollViewReader { proxy in + ScrollView { + questionBlock(question) + .frame(maxWidth: .infinity, alignment: .leading) + .id(question.id) + } + .scrollBounceBehavior(.basedOnSize) + .onChange(of: question.id, initial: true) { _, id in + proxy.scrollTo(id, anchor: .top) + } + } + } + @ViewBuilder private func questionBlock(_ question: AgentUserQuestion) -> some View { VStack(alignment: .leading, spacing: 8) { @@ -367,6 +389,8 @@ private struct MobileAssistantAdaptiveHeight: View { } var body: some View { + // The Assistant's bottom safe-area inset supplies the real ceiling, + // including keyboard avoidance. A local cap wastes usable screen space. ViewThatFits(in: .vertical) { content .frame(maxWidth: .infinity, alignment: .leading) @@ -376,8 +400,7 @@ private struct MobileAssistantAdaptiveHeight: View { } .scrollBounceBehavior(.basedOnSize) } - .frame(maxWidth: .infinity, maxHeight: 300, alignment: .topLeading) - .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .topLeading) } } diff --git a/TFITests/MobileAssistantTests.swift b/TFITests/MobileAssistantTests.swift index c2924f6f..6ba35140 100644 --- a/TFITests/MobileAssistantTests.swift +++ b/TFITests/MobileAssistantTests.swift @@ -15,7 +15,8 @@ import UniformTypeIdentifiers @Suite @MainActor struct MobileAssistantTests { - @Test func shortUserInputUsesItsNaturalHeightAndLongInputUsesTheCap() throws { + @Test func shortUserInputUsesItsNaturalHeightAndLongInputUsesAvailableHeight() throws { + let availableHeight: CGFloat = 640 let shortApp = try MobileAppModel(inMemory: true) let shortAssistant = shortApp.assistant.makeSceneModel() shortAssistant.conversation.present( @@ -24,7 +25,8 @@ struct MobileAssistantTests { ])) let shortHeight = measuredInteractionHeight( conversation: shortAssistant.conversation, - hosts: shortApp.library + hosts: shortApp.library, + availableHeight: availableHeight ) let longApp = try MobileAppModel(inMemory: true) @@ -47,16 +49,17 @@ struct MobileAssistantTests { ])) let longHeight = measuredInteractionHeight( conversation: longAssistant.conversation, - hosts: longApp.library + hosts: longApp.library, + availableHeight: availableHeight ) #expect(shortHeight > 0) #expect(shortHeight < 300) - #expect(abs(longHeight - 300) < 0.5) + #expect(abs(longHeight - availableHeight) < 0.5) #expect(shortHeight < longHeight) } - @Test func longApprovalUsesTheSameHeightCap() async throws { + @Test func longApprovalUsesAvailableHeightInsteadOfAFixedCap() async throws { let app = try MobileAppModel(inMemory: true) let assistant = app.assistant.makeSceneModel() let conversation = assistant.conversation @@ -88,9 +91,14 @@ struct MobileAssistantTests { let approval = Task { await conversation.requestApproval(request) } await Task.yield() - let height = measuredInteractionHeight(conversation: conversation, hosts: app.library) + let availableHeight: CGFloat = 640 + let height = measuredInteractionHeight( + conversation: conversation, + hosts: app.library, + availableHeight: availableHeight + ) - #expect(abs(height - 300) < 0.5) + #expect(abs(height - availableHeight) < 0.5) conversation.respondToApproval(allow: false) if case .deny = await approval.value { } else { @@ -369,13 +377,14 @@ struct MobileAssistantTests { private func measuredInteractionHeight( conversation: ConversationModel, hosts: HostLibraryModel, - width: CGFloat = 390 + width: CGFloat = 390, + availableHeight: CGFloat = 1_000 ) -> CGFloat { let controller = UIHostingController( rootView: MobileAssistantInteraction(conversation: conversation, hosts: hosts) .environment(\.dynamicTypeSize, .large) ) - return controller.sizeThatFits(in: CGSize(width: width, height: 1_000)).height + return controller.sizeThatFits(in: CGSize(width: width, height: availableHeight)).height } private func eventually(_ condition: @escaping @MainActor () -> Bool) async -> Bool { diff --git a/TFITests/MobileNavigationParityTests.swift b/TFITests/MobileNavigationParityTests.swift index 76948438..4d42df16 100644 --- a/TFITests/MobileNavigationParityTests.swift +++ b/TFITests/MobileNavigationParityTests.swift @@ -160,12 +160,32 @@ struct MobileNavigationParityTests { #expect(!bottomSurfaceContent.contains(".background(Color(uiColor: .systemBackground))")) #expect(interaction.contains("conversation.answerUserInputOption(option.id, in: questionID)")) #expect(!interaction.contains("conversation.toggleUserInputOption(option.id, in: questionID)")) - #expect(interaction.components(separatedBy: "MobileAssistantAdaptiveHeight {").count == 3) + #expect(interaction.components(separatedBy: "MobileAssistantAdaptiveHeight {").count == 4) #expect(interaction.contains("ViewThatFits(in: .vertical)")) #expect(interaction.contains(".scrollBounceBehavior(.basedOnSize)")) - #expect(interaction.contains(".frame(maxWidth: .infinity, maxHeight: 300, alignment: .topLeading)")) + #expect(!interaction.contains("maxHeight: 300")) #expect(!interaction.contains(".frame(maxHeight: 160)")) + let questions = try #require( + interaction.components(separatedBy: "private var questions: some View").last? + .components(separatedBy: "private func scrollingQuestion").first + ) + #expect(questions.contains("ViewThatFits(in: .vertical)")) + #expect(questions.contains("scrollingQuestion(question)")) + if let scrolling = questions.range(of: "scrollingQuestion(question)"), + let footer = questions.range(of: "footer(for: question)") + { + #expect(scrolling.lowerBound < footer.lowerBound) + } + + let scrollingQuestion = try #require( + interaction.components(separatedBy: "private func scrollingQuestion").last? + .components(separatedBy: "@ViewBuilder").first + ) + #expect(scrollingQuestion.contains("ScrollViewReader")) + #expect(scrollingQuestion.contains("proxy.scrollTo(id, anchor: .top)")) + #expect(!scrollingQuestion.contains("footer(for:")) + let secretField = try #require( interaction.components(separatedBy: "private func secretField").last? .components(separatedBy: "private func textField").first @@ -219,6 +239,11 @@ struct MobileNavigationParityTests { #expect(steeringQueue.contains("Image(systemName: \"xmark\")")) #expect(steeringQueue.contains(".font(.caption.weight(.bold))")) #expect(steeringQueue.contains(".frame(width: 28, height: 28)")) + let unqueueIcon = try #require( + steeringQueue.components(separatedBy: "Image(systemName: \"xmark\")").last? + .components(separatedBy: "}").first + ) + #expect(unqueueIcon.contains(".foregroundStyle(Color.primary.opacity(0.75))")) #expect(!steeringQueue.contains(".onHover")) let desktopSteeringQueue = try #require( desktopConversation.components(separatedBy: "struct SteeringQueueView").last)