ios: the labelled composer's dismiss X — wave a question off, back to a plain input (#1375) (#1386)
The native half of #1375 (web landed in PR #1381 — same semantics). While
Eleven's ask labels the composer ("Your name:"), a quiet X borrows the send
disc's slot (typing swaps Send back in, exactly the web's .composer-label-x
feel). Tapping it calls the model's cancelAnswer() — pill and hint drop, the
flow's waiter disarms — so the next plain message is an ordinary message,
never a swallowed answer; the parked step re-arms on the next cold open, like
the web after a reload. Escape is the keyboard twin wherever a hardware
keyboard exists (primarily the Mac), including with text already typed, via a
zero-size cancel-action stand-in while Send occupies the slot.
Shared LChatCore view, so one change covers iOS and Mac. The .id-keyed field
recreation (the AppKit prompt-change dance) is handed focus back by the
existing onChange handlers plus an explicit set on dismiss. Accessibility:
real button, "Dismiss question", 28pt target, secondary ink in light + dark.
Model tests pin the contract: dismiss clears label + hint, disarms the
pending answer (a later send is a plain transcript row), still clears with no
waiter armed, and the undismissed answer path keeps working.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2 files changed,
+131
−11
ios/LChatCore/Sources/LChatCore/ElevenChatView.swift+67 −11ios/LChatCore/Tests/LChatCoreTests/ElevenConversationModelTests.swift+64 −0
diff --git a/ios/LChatCore/Sources/LChatCore/ElevenChatView.swift b/ios/LChatCore/Sources/LChatCore/ElevenChatView.swift| index 8eb5e0d..011bf12 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/ElevenChatView.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/ElevenChatView.swift |
| @@ -104,21 +104,77 @@ public struct ElevenChatView: View { |
| private var composer: some View { |
| HStack(alignment: .bottom, spacing: 8) { |
| fieldPill |
| - Button(action: send) { |
| - Image(systemName: "arrow.up.circle.fill") |
| - .font(.system(size: 28)) |
| - .symbolRenderingMode(.palette) |
| - .foregroundStyle(.white, input.trimmingCharacters(in: .whitespaces).isEmpty |
| - ? AnyShapeStyle(.gray.opacity(0.5)) |
| - : AnyShapeStyle(theme.accent.resolved(scheme))) |
| + // A labelled ask's dismiss X (#1375) borrows the send disc's slot |
| + // while it's free — typing swaps Send back in, the exact feel of |
| + // the web's .composer-label-x. |
| + if model.composerLabel != nil && input.isEmpty { |
| + dismissAskButton |
| + } else { |
| + sendButton |
| } |
| - .buttonStyle(.plain) |
| - .disabled(input.trimmingCharacters(in: .whitespaces).isEmpty) |
| - .accessibilityLabel("Send") |
| - .padding(.bottom, 2) |
| } |
| .padding(.horizontal, 12) |
| .padding(.vertical, 8) |
| + .background(askEscapeCatcher) |
| + } |
| + |
| + private var sendButton: some View { |
| + Button(action: send) { |
| + Image(systemName: "arrow.up.circle.fill") |
| + .font(.system(size: 28)) |
| + .symbolRenderingMode(.palette) |
| + .foregroundStyle(.white, input.trimmingCharacters(in: .whitespaces).isEmpty |
| + ? AnyShapeStyle(.gray.opacity(0.5)) |
| + : AnyShapeStyle(theme.accent.resolved(scheme))) |
| + } |
| + .buttonStyle(.plain) |
| + .disabled(input.trimmingCharacters(in: .whitespaces).isEmpty) |
| + .accessibilityLabel("Send") |
| + .padding(.bottom, 2) |
| + } |
| + |
| + // Back to a regular input without answering: quiet ink in the send disc's |
| + // exact geometry (28pt — comfortably over the 24pt floor). Escape is the |
| + // keyboard twin wherever a hardware keyboard exists — primarily the Mac. |
| + private var dismissAskButton: some View { |
| + Button(action: dismissAsk) { |
| + Image(systemName: "xmark.circle.fill") |
| + .font(.system(size: 28)) |
| + .symbolRenderingMode(.hierarchical) |
| + .foregroundStyle(.secondary) |
| + } |
| + .buttonStyle(.plain) |
| + .keyboardShortcut(.cancelAction) |
| + .accessibilityLabel("Dismiss question") |
| + .padding(.bottom, 2) |
| + } |
| + |
| + // Escape with text already typed still dismisses (the web's Escape branch |
| + // isn't gated on an empty field): while Send occupies the slot, a |
| + // zero-size stand-in carries the cancel action — never rendered, never a |
| + // focus stop. Exactly one cancel-action button exists at a time. |
| + @ViewBuilder private var askEscapeCatcher: some View { |
| + if model.composerLabel != nil && !input.isEmpty { |
| + Button(action: dismissAsk) { Color.clear } |
| + .buttonStyle(.plain) |
| + .keyboardShortcut(.cancelAction) |
| + .frame(width: 0, height: 0) |
| + .opacity(0) |
| + .focusable(false) |
| + .accessibilityHidden(true) |
| + } |
| + } |
| + |
| + // Wave the question off: disarm the flow's waiter and drop the pill |
| + // (cancelAnswer clears label + hint), so the next plain message is an |
| + // ordinary message, never a swallowed answer — the parked step re-arms on |
| + // the next cold open, exactly like the web after a reload. Clearing the |
| + // label re-keys the field's .id (the AppKit recreate-on-prompt-change |
| + // dance), so the onChange handlers below hand focus back; the explicit |
| + // set covers the button tap itself stealing first responder on the Mac. |
| + private func dismissAsk() { |
| + model.cancelAnswer() |
| + DispatchQueue.main.async { focused = true } |
| } |
| // The bordered pill: optional inset label + the field (split out — one |
diff --git a/ios/LChatCore/Tests/LChatCoreTests/ElevenConversationModelTests.swift b/ios/LChatCore/Tests/LChatCoreTests/ElevenConversationModelTests.swift| new file mode 100644 |
| index 0000000..e608132 |
| --- /dev/null |
| +++ b/ios/LChatCore/Tests/LChatCoreTests/ElevenConversationModelTests.swift |
| @@ -0,0 +1,64 @@ |
| +import XCTest |
| +@testable import LChatCore |
| + |
| +// 🤖 The dismiss-a-labelled-ask contract (#1375), the native twin of the web's |
| +// "every cancel fires the cancelled hook" test: waving the question off clears |
| +// the composer pill (label + hint) AND disarms the flow's waiter, so the next |
| +// plain message is an ordinary message — never a swallowed answer. The parked |
| +// step re-arms on the next cold open, exactly like the web after a reload. |
| +@MainActor |
| +final class ElevenConversationModelTests: XCTestCase { |
| + |
| + // origin "" → no baseURL → api/socket are nil and every network path is a |
| + // quiet no-op, so the model exercises pure conversation state. |
| + private func makeModel() throws -> ElevenConversationModel { |
| + let instance = SavedInstance(origin: "", deviceToken: "t", screenName: "Paul", |
| + userPub: "", userPriv: "") |
| + let thread = ChatThread(id: "t1", kind: "self", title: "Eleven", preview: "", |
| + lastAt: 0, unread: false, pinned: false, hidden: false, |
| + muted: nil, members: [], threadKeyB64: "x", avatar: nil, |
| + isApp: true, appId: "setup", glyph: nil, badge: nil) |
| + return try XCTUnwrap(ElevenConversationModel(instance: instance, thread: thread, |
| + myUserId: "u1")) |
| + } |
| + |
| + func testDismissClearsPillAndDisarmsPendingAnswer() async throws { |
| + let model = try makeModel() |
| + model.composerLabel = "Your name:" |
| + model.composerHint = "Type your name" |
| + var answered: [String] = [] |
| + model.ask { answered.append($0.text) } |
| + |
| + model.cancelAnswer() |
| + |
| + XCTAssertNil(model.composerLabel, "dismiss drops the inset label") |
| + XCTAssertNil(model.composerHint, "dismiss drops the hint with it") |
| + await model.send("just a message") |
| + XCTAssertEqual(answered, [], "a later plain message is never a swallowed answer") |
| + guard case .mine(let text)? = model.rows.last?.kind else { |
| + return XCTFail("the plain message still lands in the transcript") |
| + } |
| + XCTAssertEqual(text, "just a message") |
| + } |
| + |
| + func testDismissWithNoWaiterStillClearsThePill() throws { |
| + // The web twin: cancelAnswer on an idle thread still clears the label. |
| + let model = try makeModel() |
| + model.composerLabel = "Your name:" |
| + model.composerHint = "Type your name" |
| + model.cancelAnswer() |
| + XCTAssertNil(model.composerLabel) |
| + XCTAssertNil(model.composerHint) |
| + } |
| + |
| + func testSendDeliversAnArmedAnswerAndDropsThePill() async throws { |
| + // The undismissed path keeps working: an armed ask consumes the send. |
| + let model = try makeModel() |
| + model.composerLabel = "Your name:" |
| + var answered: [String] = [] |
| + model.ask { answered.append($0.text) } |
| + await model.send("Paul") |
| + XCTAssertEqual(answered, ["Paul"]) |
| + XCTAssertNil(model.composerLabel, "the answered ask retires its label") |
| + } |
| +} |