From 152e8582f0ddee403f0c33872ab160dbe33eb7ee Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:23:02 -0400 Subject: [PATCH 1/2] fix(ios): collapse chat composer after send --- .../ios/ADE/Views/Hub/HubComposerDrawer.swift | 1 + .../PersonalChats/PersonalChatsScreen.swift | 1 + .../ADE/Views/Work/WorkChatSessionView.swift | 2 ++ .../Work/WorkComposerTypedTriggers.swift | 20 +++++++++++ .../ADE/Views/Work/WorkNewChatScreen.swift | 18 +++++----- .../WorkComposerTriggerDetectorTests.swift | 36 +++++++++++++++++++ .../sync-and-multi-device/ios-companion.md | 10 ++++++ 7 files changed, 79 insertions(+), 9 deletions(-) diff --git a/apps/ios/ADE/Views/Hub/HubComposerDrawer.swift b/apps/ios/ADE/Views/Hub/HubComposerDrawer.swift index ffa451635..356374311 100644 --- a/apps/ios/ADE/Views/Hub/HubComposerDrawer.swift +++ b/apps/ios/ADE/Views/Hub/HubComposerDrawer.swift @@ -755,6 +755,7 @@ struct HubInlineComposer: View { guard !text.isEmpty else { return } let restoredDraft = draft let restoredAttachments = attachments + collapse() draft = "" attachments.removeAll() Task { diff --git a/apps/ios/ADE/Views/PersonalChats/PersonalChatsScreen.swift b/apps/ios/ADE/Views/PersonalChats/PersonalChatsScreen.swift index 5c8425a2a..8ecd421d2 100644 --- a/apps/ios/ADE/Views/PersonalChats/PersonalChatsScreen.swift +++ b/apps/ios/ADE/Views/PersonalChats/PersonalChatsScreen.swift @@ -552,6 +552,7 @@ struct PersonalChatNewScreen: View { private func create() async { let prompt = draft.trimmingCharacters(in: .whitespacesAndNewlines) guard canCreateChat, !prompt.isEmpty, !busy else { return } + composerFocused = false busy = true errorMessage = nil defer { busy = false } diff --git a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift index 1c40fd46b..9d6e7ae81 100644 --- a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift +++ b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift @@ -2062,6 +2062,7 @@ private struct WorkContextUsagePopover: View { final class WorkChatComposerDraftState: ObservableObject { @Published var text = "" + @Published var isFocused = false var trimmedText: String { text.trimmingCharacters(in: .whitespacesAndNewlines) @@ -2073,6 +2074,7 @@ final class WorkChatComposerDraftState: ObservableObject { func consumeSendableText() -> String { let value = trimmedText + isFocused = false text = "" return value } diff --git a/apps/ios/ADE/Views/Work/WorkComposerTypedTriggers.swift b/apps/ios/ADE/Views/Work/WorkComposerTypedTriggers.swift index 771e53f8c..ce1f08270 100644 --- a/apps/ios/ADE/Views/Work/WorkComposerTypedTriggers.swift +++ b/apps/ios/ADE/Views/Work/WorkComposerTypedTriggers.swift @@ -525,6 +525,7 @@ struct WorkComposerTextView: UIViewRepresentable { context.coordinator.setText(draftState.text, resetChips: true) } context.coordinator.applyPlaceholder(placeholder) + context.coordinator.applyFocusRequest(draftState.isFocused, to: textView) context.coordinator.updateHeight() return textView } @@ -544,6 +545,7 @@ struct WorkComposerTextView: UIViewRepresentable { if draftState.text != textView.text { context.coordinator.setText(draftState.text, resetChips: false) } + context.coordinator.applyFocusRequest(draftState.isFocused, to: textView) context.coordinator.updateHeight() } @@ -559,6 +561,7 @@ struct WorkComposerTextView: UIViewRepresentable { private var chips: [(range: NSRange, text: String)] = [] private var placeholderLabel: UILabel? private var triggerInputTraitsActive = false + private var lastFocusRequest: Bool? init(_ parent: WorkComposerTextView) { self.parent = parent @@ -571,6 +574,23 @@ struct WorkComposerTextView: UIViewRepresentable { ] } + func textViewDidBeginEditing(_ textView: UITextView) { + if !parent.draftState.isFocused { parent.draftState.isFocused = true } + } + + func textViewDidEndEditing(_ textView: UITextView) { + if parent.draftState.isFocused { parent.draftState.isFocused = false } + } + + func applyFocusRequest(_ isFocused: Bool, to textView: UITextView) { + if isFocused, !textView.isFirstResponder { + textView.becomeFirstResponder() + } else if !isFocused, lastFocusRequest == true, textView.isFirstResponder { + textView.resignFirstResponder() + } + lastFocusRequest = isFocused + } + private func chipAttributes(kind: WorkComposerTriggerKind) -> [NSAttributedString.Key: Any] { let tint = UIColor(ADEColor.providerChatAccent(for: parent.controller.provider)) let font: UIFont diff --git a/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift b/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift index dc75ad3fd..b1e144ea6 100644 --- a/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift +++ b/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift @@ -679,6 +679,14 @@ struct WorkNewChatScreen: View { laneSelector sessionActionChips + + // Keep activity in the scrollable content instead of pinning it + // above the composer. When the keyboard appears, the composer can + // expand into this space without lifting the activity card with it. + WorkUsageActivityCarousel() + .environmentObject(syncService) + .padding(.top, 2) + .fixedSize(horizontal: false, vertical: true) } .padding(.horizontal, 20) .padding(.vertical, 16) @@ -689,15 +697,6 @@ struct WorkNewChatScreen: View { await MobileUsageQuotaStore.shared.load(using: syncService, refresh: true) } - // Kept outside the scroll view so lane selection can scroll away while - // the activity card stays pinned immediately above the composer. The - // keyboard lifts the composer without coupling it to chart scrolling. - WorkUsageActivityCarousel() - .environmentObject(syncService) - .padding(.horizontal, 20) - .padding(.bottom, 8) - .fixedSize(horizontal: false, vertical: true) - if let autoCreateStatus, busy { HStack(spacing: 8) { ProgressView().controlSize(.mini) @@ -1378,6 +1377,7 @@ private struct WorkNewChatComposerBar: View { guard !text.isEmpty else { return } let restoredDraft = draft let restoredAttachments = attachments + composerFocused = false draft = "" attachments.removeAll() Task { diff --git a/apps/ios/ADETests/WorkComposerTriggerDetectorTests.swift b/apps/ios/ADETests/WorkComposerTriggerDetectorTests.swift index ecb4e2354..a83538bcd 100644 --- a/apps/ios/ADETests/WorkComposerTriggerDetectorTests.swift +++ b/apps/ios/ADETests/WorkComposerTriggerDetectorTests.swift @@ -156,6 +156,42 @@ final class WorkComposerTriggerDetectorTests: XCTestCase { XCTAssertEqual(textView.resignCount, 1) XCTAssertFalse(textView.fakeIsFirstResponder) } + + @MainActor + func testSendingChatDraftClearsTextAndDismissesComposerFocus() { + let draft = WorkChatComposerDraftState() + draft.text = " Ship this change " + draft.isFocused = true + + XCTAssertEqual(draft.consumeSendableText(), "Ship this change") + XCTAssertEqual(draft.text, "") + XCTAssertFalse(draft.isFocused) + } + + @MainActor + func testChatComposerAppliesRequestedFocusTransitions() { + let draft = WorkChatComposerDraftState() + let controller = WorkComposerSuggestionController() + var measuredHeight: CGFloat = 24 + let parent = WorkComposerTextView( + draftState: draft, + controller: controller, + canCompose: true, + placeholder: "Message ADE…", + measuredHeight: Binding(get: { measuredHeight }, set: { measuredHeight = $0 }) + ) + let coordinator = WorkComposerTextView.Coordinator(parent) + let textView = FocusRecordingTextView() + textView.resetRecording() + + coordinator.applyFocusRequest(false, to: textView) + coordinator.applyFocusRequest(true, to: textView) + coordinator.applyFocusRequest(false, to: textView) + + XCTAssertEqual(textView.becomeCount, 1) + XCTAssertEqual(textView.resignCount, 1) + XCTAssertFalse(textView.fakeIsFirstResponder) + } } private final class FocusRecordingTextView: UITextView { diff --git a/docs/features/sync-and-multi-device/ios-companion.md b/docs/features/sync-and-multi-device/ios-companion.md index f66a3a63d..8026cdf57 100644 --- a/docs/features/sync-and-multi-device/ios-companion.md +++ b/docs/features/sync-and-multi-device/ios-companion.md @@ -1036,6 +1036,16 @@ stored choice. `WorkNewChatScreen` captures the active project id when pushed; changes so a hub-created session cannot accidentally launch with the previous project's interface mode. +Submitting a valid prompt dismisses the keyboard across every mobile chat +composer: Work session chat clears the observable `UITextView` focus request, +Work new-chat and personal new-chat clear their focus bindings, and the Hub +inline composer collapses its full panel. The prompt field therefore returns to +its compact resting state without an interactive keyboard swipe that can +conflict with chat navigation. On `WorkNewChatScreen`, the cross-client activity +carousel is part of the main scroll content rather than a pinned sibling above +the composer, so keyboard presentation gives an expanding multi-line prompt the +available space instead of lifting the activity panel with it. + Mobile chat image attachments use the same host-side temp attachment contract as desktop. Work and Hub chat composers expose an add-attachment control beside the permission/model controls; its menu currently has one action, Attach from camera From 0d2f49a6d09a95c5ce7de1e750cfa1ebac198c82 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:39:18 -0400 Subject: [PATCH 2/2] =?UTF-8?q?ship:=20iteration=201=20=E2=80=94=20restore?= =?UTF-8?q?=20focus=20after=20failed=20send?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/ios/ADE/Views/Work/WorkChatSessionView.swift | 14 ++++++++------ .../WorkComposerTriggerDetectorTests.swift | 6 +++++- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift index 9d6e7ae81..335f2bf11 100644 --- a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift +++ b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift @@ -2081,12 +2081,14 @@ final class WorkChatComposerDraftState: ObservableObject { func restoreUnsentText(_ value: String) { let currentDraft = trimmedText - guard currentDraft != value else { return } - if currentDraft.isEmpty { - text = value - } else { - text = "\(value)\n\(text)" + if currentDraft != value { + if currentDraft.isEmpty { + text = value + } else { + text = "\(value)\n\(text)" + } } + isFocused = true } } @@ -2146,8 +2148,8 @@ private struct WorkChatComposerSendButton: View { if sent { onSent() } else { - draftState.restoreUnsentText(originalText) attachments = restoredAttachments + draftState.restoreUnsentText(originalText) } } } diff --git a/apps/ios/ADETests/WorkComposerTriggerDetectorTests.swift b/apps/ios/ADETests/WorkComposerTriggerDetectorTests.swift index a83538bcd..5f9a72be1 100644 --- a/apps/ios/ADETests/WorkComposerTriggerDetectorTests.swift +++ b/apps/ios/ADETests/WorkComposerTriggerDetectorTests.swift @@ -158,7 +158,7 @@ final class WorkComposerTriggerDetectorTests: XCTestCase { } @MainActor - func testSendingChatDraftClearsTextAndDismissesComposerFocus() { + func testSendingAndRestoringChatDraftUpdatesTextAndFocus() { let draft = WorkChatComposerDraftState() draft.text = " Ship this change " draft.isFocused = true @@ -166,6 +166,10 @@ final class WorkComposerTriggerDetectorTests: XCTestCase { XCTAssertEqual(draft.consumeSendableText(), "Ship this change") XCTAssertEqual(draft.text, "") XCTAssertFalse(draft.isFocused) + + draft.restoreUnsentText("Ship this change") + XCTAssertEqual(draft.text, "Ship this change") + XCTAssertTrue(draft.isFocused) } @MainActor