diff --git a/apps/swift-ios/Features/Chat/FeatureComposerView.swift b/apps/swift-ios/Features/Chat/FeatureComposerView.swift index 146485c38dab..d8655672c01c 100644 --- a/apps/swift-ios/Features/Chat/FeatureComposerView.swift +++ b/apps/swift-ios/Features/Chat/FeatureComposerView.swift @@ -30,6 +30,7 @@ struct FeatureComposerView: View { private let powerFeatures: FeatureComposerPowerFeatures private let onSend: () -> Void private let onStop: () -> Void + private let showsKeyboardDismissControl: Bool private let onDismissKeyboard: (() -> Void)? private let onApprovalDecision: ((String, FeatureApprovalDecision) -> Void)? private let onUserInputSubmit: ((String, [String: FeatureInputAnswer]) -> Void)? @@ -52,6 +53,7 @@ struct FeatureComposerView: View { pendingUserInputs: [FeatureUserInput] = [], isResolvingRequest: Bool = false, powerFeatures: FeatureComposerPowerFeatures = .disabled, + showsKeyboardDismissControl: Bool = false, onDismissKeyboard: (() -> Void)? = nil, onApprovalDecision: ((String, FeatureApprovalDecision) -> Void)? = nil, onUserInputSubmit: ((String, [String: FeatureInputAnswer]) -> Void)? = nil @@ -73,6 +75,7 @@ struct FeatureComposerView: View { self.pendingUserInputs = pendingUserInputs self.isResolvingRequest = isResolvingRequest self.powerFeatures = powerFeatures + self.showsKeyboardDismissControl = showsKeyboardDismissControl self.onDismissKeyboard = onDismissKeyboard self.onApprovalDecision = onApprovalDecision self.onUserInputSubmit = onUserInputSubmit @@ -282,6 +285,14 @@ struct FeatureComposerView: View { private var composerFooter: some View { HStack(spacing: 2) { + if FeatureComposerKeyboardDismissPolicy.showsDismissControl( + isFocused: focused, + isEnabled: showsKeyboardDismissControl, + canDismiss: onDismissKeyboard != nil + ) { + dismissKeyboardButton + } + FeatureImageAttachmentPicker( attachments: $attachments, preparationState: $attachmentPreparation, @@ -314,6 +325,22 @@ struct FeatureComposerView: View { .padding(.bottom, 8) } + private var dismissKeyboardButton: some View { + Button("Hide keyboard", systemImage: "keyboard.chevron.compact.down", action: dismissKeyboard) + .labelStyle(.iconOnly) + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(T3Colors.textSecondary) + .frame(width: T3Metrics.minimumTapTarget, height: T3Metrics.minimumTapTarget) + .contentShape(Rectangle()) + .buttonStyle(.plain) + .accessibilityHint("Keeps your draft and shows the thread") + .accessibilityIdentifier("composer-dismiss-keyboard") + } + + private func dismissKeyboard() { + onDismissKeyboard?() + } + private var submitButton: some View { Button(action: performPrimaryAction) { Image(systemName: submitSymbol) @@ -584,6 +611,12 @@ struct FeatureComposerView: View { } } +enum FeatureComposerKeyboardDismissPolicy { + static func showsDismissControl(isFocused: Bool, isEnabled: Bool, canDismiss: Bool) -> Bool { + isFocused && isEnabled && canDismiss + } +} + enum FeatureComposerCollapsePolicy { static func shouldCollapse( isFocused: Bool, diff --git a/apps/swift-ios/Features/Chat/ThreadDetailView.swift b/apps/swift-ios/Features/Chat/ThreadDetailView.swift index 9131c0b41c86..35eb6698b0cb 100644 --- a/apps/swift-ios/Features/Chat/ThreadDetailView.swift +++ b/apps/swift-ios/Features/Chat/ThreadDetailView.swift @@ -559,6 +559,7 @@ public struct ThreadDetailView: View { pendingUserInputs: detail.userInputs, isResolvingRequest: model.isPerformingAction, powerFeatures: composerPowerFeatures, + showsKeyboardDismissControl: true, onDismissKeyboard: dismissKeyboard, onApprovalDecision: { id, decision in Task { await model.resolveApproval(id, decision: decision) } diff --git a/apps/swift-ios/Tests/FeatureTests/ThreadKeyboardDismissTests.swift b/apps/swift-ios/Tests/FeatureTests/ThreadKeyboardDismissTests.swift new file mode 100644 index 000000000000..4f52764f2fc8 --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/ThreadKeyboardDismissTests.swift @@ -0,0 +1,46 @@ +import Testing +@testable import T3Code + +@Suite("Thread keyboard dismissal") +struct ThreadKeyboardDismissTests { + @Test + func threadDismissControlAppearsWhileTheKeyboardIsUp() { + #expect(FeatureComposerKeyboardDismissPolicy.showsDismissControl( + isFocused: true, + isEnabled: true, + canDismiss: true + )) + } + + @Test + func dismissControlStaysHiddenWithoutEveryRequirement() { + #expect(FeatureComposerKeyboardDismissPolicy.showsDismissControl( + isFocused: false, + isEnabled: true, + canDismiss: true + ) == false) + #expect(FeatureComposerKeyboardDismissPolicy.showsDismissControl( + isFocused: true, + isEnabled: false, + canDismiss: true + ) == false) + #expect(FeatureComposerKeyboardDismissPolicy.showsDismissControl( + isFocused: true, + isEnabled: true, + canDismiss: false + ) == false) + } + + @Test + func dismissingTheKeyboardLeavesADraftedComposerExpanded() { + // Dismissal only drops focus. A composer holding a long draft must stay + // expanded so the draft is still visible and still editable. + #expect(FeatureComposerCollapsePolicy.shouldCollapse( + isFocused: false, + textIsEmpty: false, + attachmentsAreEmpty: true, + isAttachmentFlowActive: false, + isPreparingAttachments: false + ) == false) + } +}