eleven / messenger Public

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>
Paul Campbell 7f6e0565e3b763f4040a386c4dc6f6174e4b363c parent 6998f2b
2 files changed, +131 −11
  • ios/LChatCore/Sources/LChatCore/ElevenChatView.swift +67 −11
  • ios/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")
+ }
+}