Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions apps/swift-ios/Features/Chat/FeatureComposerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)?
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions apps/swift-ios/Features/Chat/ThreadDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand Down
46 changes: 46 additions & 0 deletions apps/swift-ios/Tests/FeatureTests/ThreadKeyboardDismissTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading