Skip to content
Merged
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
1 change: 1 addition & 0 deletions apps/ios/ADE/Views/Hub/HubComposerDrawer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -755,6 +755,7 @@ struct HubInlineComposer: View {
guard !text.isEmpty else { return }
let restoredDraft = draft
let restoredAttachments = attachments
collapse()
draft = ""
attachments.removeAll()
Task {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
16 changes: 10 additions & 6 deletions apps/ios/ADE/Views/Work/WorkChatSessionView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -2073,18 +2074,21 @@ final class WorkChatComposerDraftState: ObservableObject {

func consumeSendableText() -> String {
let value = trimmedText
isFocused = false
text = ""
return value
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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
}
}

Expand Down Expand Up @@ -2144,8 +2148,8 @@ private struct WorkChatComposerSendButton: View {
if sent {
onSent()
} else {
draftState.restoreUnsentText(originalText)
attachments = restoredAttachments
draftState.restoreUnsentText(originalText)
}
}
}
Expand Down
20 changes: 20 additions & 0 deletions apps/ios/ADE/Views/Work/WorkComposerTypedTriggers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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()
}

Expand All @@ -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
Expand All @@ -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
Expand Down
18 changes: 9 additions & 9 deletions apps/ios/ADE/Views/Work/WorkNewChatScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down
40 changes: 40 additions & 0 deletions apps/ios/ADETests/WorkComposerTriggerDetectorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,46 @@ final class WorkComposerTriggerDetectorTests: XCTestCase {
XCTAssertEqual(textView.resignCount, 1)
XCTAssertFalse(textView.fakeIsFirstResponder)
}

@MainActor
func testSendingAndRestoringChatDraftUpdatesTextAndFocus() {
let draft = WorkChatComposerDraftState()
draft.text = " Ship this change "
draft.isFocused = true

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
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 {
Expand Down
10 changes: 10 additions & 0 deletions docs/features/sync-and-multi-device/ios-companion.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down