Fix mobile Assistant interaction affordances

This commit is contained in:
Kaede Akino committed 2026-09-13 17:00:20 +08:00
1 parent dc7e5ca851
commit 0dcf243b70
4 files changed
+81 -23

No files matched your search

@@ -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)
@@ -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<Content: View>: 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<Content: View>: View {
}
.scrollBounceBehavior(.basedOnSize)
}
.frame(maxWidth: .infinity, maxHeight: 300, alignment: .topLeading)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .topLeading)
}
}
+18 -9
View File
@@ -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 {
+27 -2
View File
@@ -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)