Fix mobile Assistant interaction affordances
This commit is contained in:
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user