eleven / messenger Public

Mac: default-command routing — typing into To-do adds a to-do (#373)

* mac: default-command routing — typing into To-do adds a to-do

Wires the Mac-native feed to the AppDefaultCommand rule already shipped
in LChatCore (#363): in a To-do thread with no bot member, plain text
(or "/add …") emits todo.add instead of sending a dead chat message.
The composer placeholder becomes "Add a to-do…" and the attach
affordances (+ button, paste-to-stage, Finder drop) are gated off —
text-only surface, matching web and iOS.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* mac,ios,core: review fixes — shared routing outcome, no dead bubbles, inert affordances

High-effort review findings on the Mac wiring, fixed at the root:
- AppDefaultCommand.routing now returns event/swallow/passthrough and
  ChatFeedModel.routeDefaultCommand is the ONE shared composer entry
  point (iOS + Mac call it) — a bare "/add" is consumed, never posted
  as a dead chat bubble, and the copy-pasted block is gone.
- "/todo buy milk" typed IN the To-do thread adds, like the web.
- Mac paste/drop are no longer claimed-then-dropped in text-only
  threads: empty type lists mean ⌘V falls back to normal text paste
  and the "Drop to attach" highlight never invites a refused drop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell ead1ca33b40c5422b8ccd9439c7d6dcd1d70cf08 parent 77ebd5a
4 files changed, +121 −46
  • ios/LChat/Views/NativeChatScreen.swift +10 −8
  • ios/LChatCore/Sources/LChatCore/AppDefaultCommand.swift +51 −14
  • ios/LChatCore/Tests/LChatCoreTests/AppDefaultCommandTests.swift +25 −14
  • ios/LChatMac/NativeFeedView.swift +35 −10
diff --git a/ios/LChat/Views/NativeChatScreen.swift b/ios/LChat/Views/NativeChatScreen.swift
index 4123c4c..168e3f8 100644
--- a/ios/LChat/Views/NativeChatScreen.swift
+++ b/ios/LChat/Views/NativeChatScreen.swift
@@ -764,14 +764,16 @@ struct NativeChatScreen: View {
break
}
}
- // Default-command thread (To-do): plain text — or its own local verb,
- // "/add buy milk" — becomes the app event instead of a chat message
- // nobody reads. Mirrors the web composer + apps.js defaultCommandFor;
- // other slash text still falls through to a normal send, like the web.
- if staged.isEmpty, replyTo == nil, let dc = defaultCommand,
- let eventText = AppDefaultCommand.route(draft, spec: dc) {
- if model.sendAppEvent(type: dc.eventType, text: eventText) { draft = "" }
- return
+ // Default-command thread (To-do): plain text — or one of its own verbs,
+ // "/add buy milk" / "/todo buy milk" — becomes the app event instead of
+ // a chat message nobody reads; a bare verb is consumed, never posted.
+ // The routing contract is shared with the Mac via routeDefaultCommand.
+ if staged.isEmpty, replyTo == nil, let dc = defaultCommand {
+ switch model.routeDefaultCommand(draft, spec: dc) {
+ case .sent: draft = ""; return
+ case .kept: return // bare verb or down socket — draft stays put
+ case .notHandled: break
+ }
}
if model.send(draft, attachments: staged.compactMap(\.attachment), replyTo: replyTo) {
draft = ""
diff --git a/ios/LChatCore/Sources/LChatCore/AppDefaultCommand.swift b/ios/LChatCore/Sources/LChatCore/AppDefaultCommand.swift
index 59afa0f..c3e40bf 100644
--- a/ios/LChatCore/Sources/LChatCore/AppDefaultCommand.swift
+++ b/ios/LChatCore/Sources/LChatCore/AppDefaultCommand.swift
@@ -10,14 +10,17 @@ import Foundation
public enum AppDefaultCommand {
public struct Spec: Equatable {
public let eventType: String // the app event plain text emits ("todo.add")
- public let command: String // the local verb it sugar-coats ("/add")
+ // The verbs that add IN this thread, verb-stripped: the local command
+ // first ("/add" — the drift gate pins it to todo.js defaultCommand),
+ // then the app's global alias ("/todo"), which the web also runs here.
+ public let verbs: [String]
public let placeholder: String // the composer's advertisement of the routing
}
// One entry per app that declares a defaultCommand on the web — keep in
// sync with web/static/apps/*.js (like AppChatProjection's tables).
static let specs: [String: Spec] = [
- "todo": Spec(eventType: "todo.add", command: "/add", placeholder: "Add a to-do…"),
+ "todo": Spec(eventType: "todo.add", verbs: ["/add", "/todo"], placeholder: "Add a to-do…"),
]
/// The default-command spec for a thread, or nil when the rule is off:
@@ -27,19 +30,53 @@ public enum AppDefaultCommand {
return specs[appID]
}
- /// The event text a composer draft should emit under `spec`, or nil to send
- /// the draft as a normal message. Plain text routes wholesale; the app's own
- /// local verb ("/add buy milk") routes with the verb stripped; any other
- /// slash text is left alone (a "/" always means command, never content).
- public static func route(_ text: String, spec: Spec) -> String? {
+ /// What the composer should do with a draft in a default-command thread.
+ public enum Routing: Equatable {
+ case event(String) // emit the app event carrying this text
+ // Consumed with nothing to emit (a bare verb): the caller keeps the
+ // draft and sends NOTHING — the native stand-in for the web's private
+ // usage reply, and never a dead chat bubble.
+ case swallow
+ case passthrough // not ours — the normal send path decides
+ }
+
+ /// Classify a draft. Plain text routes wholesale; one of the app's verbs
+ /// ("/add buy milk", or the global "/todo buy milk" exactly as the web runs
+ /// it in-thread) routes with the verb stripped; a bare verb is swallowed;
+ /// any other slash text passes through (a "/" always means command).
+ public static func routing(_ text: String, spec: Spec) -> Routing {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !trimmed.isEmpty else { return nil }
- if trimmed.first != "/" { return trimmed }
- if trimmed.lowercased() == spec.command { return nil } // bare verb — nothing to add
- if trimmed.lowercased().hasPrefix(spec.command + " ") {
- let args = trimmed.dropFirst(spec.command.count).trimmingCharacters(in: .whitespaces)
- return args.isEmpty ? nil : args
+ guard !trimmed.isEmpty else { return .passthrough }
+ if trimmed.first != "/" { return .event(trimmed) }
+ let lower = trimmed.lowercased()
+ for verb in spec.verbs {
+ if lower == verb { return .swallow } // bare verb — nothing to add
+ if lower.hasPrefix(verb + " ") {
+ let args = trimmed.dropFirst(verb.count).trimmingCharacters(in: .whitespaces)
+ return args.isEmpty ? .swallow : .event(args)
+ }
+ }
+ return .passthrough
+ }
+}
+
+extension ChatFeedModel {
+ /// The composer-facing outcome of routing a draft through a thread's
+ /// default command — the ONE shared entry point both native composers call
+ /// (NativeChatScreen on iOS, NativeFeedView on the Mac), so the platforms
+ /// can't drift on the contract.
+ public enum DefaultCommandOutcome {
+ case sent // event emitted — clear the draft
+ case kept // consumed, nothing sent (bare verb, or a down socket) — keep the draft
+ case notHandled // not ours — continue to the normal send path
+ }
+
+ public func routeDefaultCommand(_ draft: String, spec: AppDefaultCommand.Spec) -> DefaultCommandOutcome {
+ switch AppDefaultCommand.routing(draft, spec: spec) {
+ case .passthrough: return .notHandled
+ case .swallow: return .kept
+ case .event(let text):
+ return sendAppEvent(type: spec.eventType, text: text) ? .sent : .kept
}
- return nil
}
}
diff --git a/ios/LChatCore/Tests/LChatCoreTests/AppDefaultCommandTests.swift b/ios/LChatCore/Tests/LChatCoreTests/AppDefaultCommandTests.swift
index f581c71..7b00c3a 100644
--- a/ios/LChatCore/Tests/LChatCoreTests/AppDefaultCommandTests.swift
+++ b/ios/LChatCore/Tests/LChatCoreTests/AppDefaultCommandTests.swift
@@ -38,25 +38,36 @@ final class AppDefaultCommandTests: XCTestCase {
func testPlainTextRoutes() throws {
let dc = try XCTUnwrap(AppDefaultCommand.spec(appID: "todo", hasBot: false))
- XCTAssertEqual(AppDefaultCommand.route("buy milk", spec: dc), "buy milk")
- XCTAssertEqual(AppDefaultCommand.route(" buy milk ", spec: dc), "buy milk")
- XCTAssertNil(AppDefaultCommand.route(" ", spec: dc))
+ XCTAssertEqual(AppDefaultCommand.routing("buy milk", spec: dc), .event("buy milk"))
+ XCTAssertEqual(AppDefaultCommand.routing(" buy milk ", spec: dc), .event("buy milk"))
+ XCTAssertEqual(AppDefaultCommand.routing(" ", spec: dc), .passthrough,
+ "empty drafts are the composer's no-op, not ours")
}
- func testLocalVerbRoutesWithVerbStripped() throws {
+ func testVerbsRouteWithVerbStripped() throws {
let dc = try XCTUnwrap(AppDefaultCommand.spec(appID: "todo", hasBot: false))
- XCTAssertEqual(AppDefaultCommand.route("/add buy milk", spec: dc), "buy milk")
- XCTAssertEqual(AppDefaultCommand.route("/ADD buy milk", spec: dc), "buy milk")
- XCTAssertNil(AppDefaultCommand.route("/add", spec: dc), "bare verb — nothing to add")
- XCTAssertNil(AppDefaultCommand.route("/add ", spec: dc))
+ XCTAssertEqual(AppDefaultCommand.routing("/add buy milk", spec: dc), .event("buy milk"))
+ XCTAssertEqual(AppDefaultCommand.routing("/ADD buy milk", spec: dc), .event("buy milk"))
+ // The global alias adds here too, exactly as the web runs /todo in-thread.
+ XCTAssertEqual(AppDefaultCommand.routing("/todo buy milk", spec: dc), .event("buy milk"))
}
- func testOtherSlashTextNeverRoutes() throws {
+ func testBareVerbIsSwallowedNeverPosted() throws {
+ // The web answers a bare verb with a private usage reply; native has no
+ // notice surface, so it consumes the draft-send instead — under no
+ // circumstances may "/add" land in the thread as a chat bubble.
let dc = try XCTUnwrap(AppDefaultCommand.spec(appID: "todo", hasBot: false))
- XCTAssertNil(AppDefaultCommand.route("/theme lagoon", spec: dc),
- "a leading / always means command, never content")
- XCTAssertNil(AppDefaultCommand.route("/added to cart", spec: dc),
- "the verb must be its own token, not a prefix")
+ XCTAssertEqual(AppDefaultCommand.routing("/add", spec: dc), .swallow)
+ XCTAssertEqual(AppDefaultCommand.routing("/add ", spec: dc), .swallow)
+ XCTAssertEqual(AppDefaultCommand.routing("/todo", spec: dc), .swallow)
+ }
+
+ func testOtherSlashTextPassesThrough() throws {
+ let dc = try XCTUnwrap(AppDefaultCommand.spec(appID: "todo", hasBot: false))
+ XCTAssertEqual(AppDefaultCommand.routing("/theme lagoon", spec: dc), .passthrough,
+ "a leading / always means command, never content")
+ XCTAssertEqual(AppDefaultCommand.routing("/added to cart", spec: dc), .passthrough,
+ "the verb must be its own token, not a prefix")
}
// ---- drift gates vs web/static/apps/todo.js ----------------------------
@@ -65,7 +76,7 @@ final class AppDefaultCommandTests: XCTestCase {
let js = try String(contentsOf: repoRoot.appending(path: "web/static/apps/todo.js"),
encoding: .utf8)
let dc = try XCTUnwrap(AppDefaultCommand.specs["todo"])
- XCTAssertEqual(jsString(js, key: "defaultCommand"), dc.command,
+ XCTAssertEqual(jsString(js, key: "defaultCommand"), dc.verbs.first,
"Swift default command has drifted from todo.js")
XCTAssertEqual(jsString(js, key: "composerPlaceholder"), dc.placeholder,
"Swift placeholder has drifted from todo.js")
diff --git a/ios/LChatMac/NativeFeedView.swift b/ios/LChatMac/NativeFeedView.swift
index 6a3e945..1f9b39f 100644
--- a/ios/LChatMac/NativeFeedView.swift
+++ b/ios/LChatMac/NativeFeedView.swift
@@ -57,9 +57,14 @@ struct NativeFeedView: View {
let members = Set(thread.members)
self.hasBot = roster.contains { members.contains($0.user_id) && $0.isAgent }
self.appIntro = thread.appId.flatMap { AppChatProjection.intros[$0] }
+ // The app-thread's default command (To-do: plain text adds a to-do) —
+ // the web's defaultCommandFor, so every composer routes identically.
+ self.defaultCommand = AppDefaultCommand.spec(appID: thread.appId, hasBot: self.hasBot)
self.threadID = thread.id
}
+ private let defaultCommand: AppDefaultCommand.Spec?
+
private let threadID: String
// The companion app's start-of-history intro (chatIntro on the web),
@@ -338,7 +343,10 @@ struct NativeFeedView: View {
// Drop-to-attach: files dragged from the Finder stage exactly like the
// + button's picks (downscale/allowlist/encrypt), with the web's
// drop-overlay highlight while hovering.
- .onDrop(of: [.fileURL], isTargeted: $dropTargeted) { providers in
+ // In a default-command thread (text-only surface) the type list is
+ // empty, so the feed is NOT a drop target — no "Drop to attach"
+ // highlight inviting a drop that would only be refused.
+ .onDrop(of: defaultCommand == nil ? [.fileURL] : [], isTargeted: $dropTargeted) { providers in
for provider in providers {
_ = provider.loadObject(ofClass: URL.self) { url, _ in
guard let url else { return }
@@ -708,17 +716,23 @@ struct NativeFeedView: View {
private var composerRow: some View {
HStack(alignment: .bottom, spacing: 8) {
// The web composer's "+": stage photos and allowlisted documents.
- Button(action: pickFiles) {
- Image(systemName: "plus.circle.fill")
- .font(.system(size: 28)) // same weight as the send button
- .foregroundStyle(.tertiary)
+ // Gone in a default-command thread (To-do) — text-only surface,
+ // matching the web and iOS composers.
+ if defaultCommand == nil {
+ Button(action: pickFiles) {
+ Image(systemName: "plus.circle.fill")
+ .font(.system(size: 28)) // same weight as the send button
+ .foregroundStyle(.tertiary)
+ }
+ .buttonStyle(.plain)
+ .padding(.bottom, 4)
}
- .buttonStyle(.plain)
- .padding(.bottom, 4)
- TextField("Message", text: $draft, axis: .vertical)
+ TextField(defaultCommand?.placeholder ?? "Message", text: $draft, axis: .vertical)
// Paste an image (or a copied file) straight onto the draft -
- // it stages as an upload, like the web composer's paste.
- .onPasteCommand(of: [.png, .jpeg, .tiff, .image, .fileURL]) { providers in
+ // it stages as an upload, like the web composer's paste. In a
+ // default-command thread the type list is empty, so ⌘V is NOT
+ // claimed here and the field's normal text paste still works.
+ .onPasteCommand(of: defaultCommand == nil ? [.png, .jpeg, .tiff, .image, .fileURL] : []) { providers in
handlePaste(providers)
}
.textFieldStyle(.plain)
@@ -868,6 +882,17 @@ struct NativeFeedView: View {
break // fall through — a bot answers /help and bot commands
}
}
+ // Default-command thread (To-do): plain text — or one of its own verbs,
+ // "/add buy milk" / "/todo buy milk" — becomes the app event instead of
+ // a chat message nobody reads; a bare verb is consumed, never posted.
+ // The routing contract is shared with iOS via routeDefaultCommand.
+ if staged.isEmpty, replyTo == nil, let dc = defaultCommand {
+ switch model.routeDefaultCommand(draft, spec: dc) {
+ case .sent: draft = ""; return
+ case .kept: return // bare verb or down socket — draft stays put
+ case .notHandled: break
+ }
+ }
// Mid-upload, the send queues instead of blocking (web PR #269): the
// button spins and fireQueuedSend releases it when the batch resolves.
guard !uploadsInFlight else {