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>
4 files changed,
+121
−46
ios/LChat/Views/NativeChatScreen.swift+10 −8ios/LChatCore/Sources/LChatCore/AppDefaultCommand.swift+51 −14ios/LChatCore/Tests/LChatCoreTests/AppDefaultCommandTests.swift+25 −14ios/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 { |