From 4b90652c1fa3c686c20c82221928e04b3ce92df2 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Wed, 19 Aug 2026 10:37:30 +1000 Subject: [PATCH 1/2] feat(swift-ios): let a long draft give the thread back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A grown composer plus the keyboard covered the whole transcript with no way back to reading it. Two iOS-conventional exits, both draft-preserving: - The transcript now uses `keyboardDismissMode = .interactive` and tracks the drag itself. The transcript is laid out above the keyboard, so `.interactive` alone never engages; a downward drag past 16pt — reaching back through a bottom-anchored thread — dismisses immediately wherever it starts. Upward nudges toward the latest turn keep the draft editable, replacing the previous dismiss-on-any-drag behaviour. - The composer footer gains a `keyboard.chevron.compact.down` control while focused. It only drops focus, so the draft and its caret survive, and it is opt-in per call site — the thread page passes a handler, other composers do not. Co-Authored-By: Claude Opus 5 (1M context) --- .../Features/Chat/FeatureComposerView.swift | 33 +++++++++++++ .../Features/Chat/ThreadDetailView.swift | 1 + .../ThreadKeyboardDismissTests.swift | 46 +++++++++++++++++++ 3 files changed, 80 insertions(+) create mode 100644 apps/swift-ios/Tests/FeatureTests/ThreadKeyboardDismissTests.swift diff --git a/apps/swift-ios/Features/Chat/FeatureComposerView.swift b/apps/swift-ios/Features/Chat/FeatureComposerView.swift index 146485c38dab..8c1bfcb7c9fb 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.wrappedValue, + 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) + } +} From 7fa7bd12463e263f9ea39d5627dbe2588d68b00d Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Sat, 29 Aug 2026 23:42:02 +1000 Subject: [PATCH 2/2] fix(swift-ios): read focused binding correctly --- apps/swift-ios/Features/Chat/FeatureComposerView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/swift-ios/Features/Chat/FeatureComposerView.swift b/apps/swift-ios/Features/Chat/FeatureComposerView.swift index 8c1bfcb7c9fb..d8655672c01c 100644 --- a/apps/swift-ios/Features/Chat/FeatureComposerView.swift +++ b/apps/swift-ios/Features/Chat/FeatureComposerView.swift @@ -286,7 +286,7 @@ struct FeatureComposerView: View { private var composerFooter: some View { HStack(spacing: 2) { if FeatureComposerKeyboardDismissPolicy.showsDismissControl( - isFocused: focused.wrappedValue, + isFocused: focused, isEnabled: showsKeyboardDismissControl, canDismiss: onDismissKeyboard != nil ) {