eleven / messenger Public

Native Reminders: typing sets a real reminder (Mac + iOS) (#389)

* mac,ios,core: native Reminders — typing (or /remind) sets a real reminder

Ports the web's reminder pipeline to LChatCore so the Mac's native feed
(and iOS's native chrome) can run Reminders' default command for real:

- ReminderParse: parseReminder ported verbatim (three shapes — "| date",
  relative in-<n><unit> leading or trailing, at-HH:MM — same rejections;
  parity suite mirrors the web's test vectors).
- LChatAPI.schedule: POST /api/schedule (ciphertext + thread + time),
  the web's ctx.schedule wire.
- ChatFeedModel.addReminder: schedule the encrypted remind.fire FIRST,
  then emit remind.add carrying when + sid ("" offline fallback) —
  remind.js addReminder's exact order and shape.
- routeDefaultCommand goes async and gains .rejected("when should I
  remind you?"): timeless text pushes back instead of doing nothing.
  Mac shows the prompt on its status line; iOS's native screen gets an
  inline notice line. The sync routing() pre-check keeps the
  passthrough decision synchronous at both call sites.
- AppDefaultCommand.specs gains the remind row, so placeholder, hidden
  attach, and the "/" typeahead light up on all three composers; drift
  gates pin defaultCommand/placeholder/chatIntro/prompt to remind.js.

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

* mac,ios: clear only the routed draft — typing mid-schedule survives

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

* mac,ios,core: review fixes — crash-proof parser, no double/orphaned reminders

High-effort review (26 verified findings, 13 distinct) on the native
reminder port, fixed at the root:

- ReminderParse: [0-9] not \d (ICU \d matches Unicode digits that
  Swift's numeric inits reject — Arabic-digit input crashed the app);
  all force-unwraps gone; years < 100 rejected (JS Date parity);
  interior trims strip newlines (JS .trim parity); non-finite when
  rejected. Four hardening tests pin each.
- addReminder: Int64(Double) can no longer trap on absurd relative
  times (trap-safety guard only — the server's scheduleHorizon stays
  the range authority, as on web); an emit-failure after a successful
  schedule now CANCELS the fresh schedule (new LChatAPI.unschedule)
  so a retry can't stack an uncancellable duplicate alarm.
- Both composers: dcRouting in-flight guard — a second Enter during
  the schedule round-trip can't double-create; bare verbs now get the
  usage line (web parity) via .rejected instead of silence.
- Push-back notice lifecycle: dedicated routeNotice slot on the Mac
  (busyText's 3s upload timers wiped it) and generation-tokened clears
  on both platforms (a re-shown identical prompt survives the earlier
  showing's timer; success retires a stale prompt immediately).

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

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell c3fdb516a7c1a5b0b7fa315130f44a4ccf1bc86e parent 42a754d
10 files changed, +516 −33
  • ios/LChat/Views/NativeChatScreen.swift +56 −8
  • ios/LChatCore/Sources/LChatCore/AppChatProjection.swift +1 −1
  • ios/LChatCore/Sources/LChatCore/AppDefaultCommand.swift +37 −7
  • ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift +64 −6
  • ios/LChatCore/Sources/LChatCore/LChatAPI.swift +36 −0
  • ios/LChatCore/Sources/LChatCore/ReminderParse.swift +133 −0
  • ios/LChatCore/Tests/LChatCoreTests/AppDefaultCommandTests.swift +28 −2
  • ios/LChatCore/Tests/LChatCoreTests/ComposerTypeaheadTests.swift +2 −1
  • ios/LChatCore/Tests/LChatCoreTests/ReminderParseTests.swift +98 −0
  • ios/LChatMac/NativeFeedView.swift +61 −8
diff --git a/ios/LChat/Views/NativeChatScreen.swift b/ios/LChat/Views/NativeChatScreen.swift
index 18f1364..99674c0 100644
--- a/ios/LChat/Views/NativeChatScreen.swift
+++ b/ios/LChat/Views/NativeChatScreen.swift
@@ -61,6 +61,12 @@ struct NativeChatScreen: View {
@State private var caret = 0
@State private var composerFocused = false // ComposerField's own focus binding (UIKit-backed)
@State private var nativeTypeahead: (token: ComposerToken, items: [TypeaheadItem])?
+ // A default command's push-back line, generation-tokened so a re-shown
+ // identical prompt is never wiped by an earlier showing's clear timer;
+ // dcRouting keeps one async routing in flight (no double-sent reminders).
+ @State private var routeNotice: String?
+ @State private var routeNoticeGen = 0
+ @State private var dcRouting = false
@State private var replyTo: ReplyRef?
@State private var editingID: Int64?
@State private var pickerItem: PhotosPickerItem?
@@ -645,6 +651,15 @@ struct NativeChatScreen: View {
if let ta = nativeTypeahead {
typeaheadStrip(ta.items)
}
+ // A default command pushing back ("when should I remind you?") —
+ // inline above the composer, occupying layout, never floating.
+ if let routeNotice {
+ Text(routeNotice)
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.horizontal, 16)
+ }
if let replyTo {
banner("Replying to \(replyTo.name ?? "")", detail: replyTo.snippet) { self.replyTo = nil }
}
@@ -738,6 +753,19 @@ struct NativeChatScreen: View {
return !uploading && (hasText || staged.contains { $0.attachment != nil })
}
+ // Show (or clear, with nil) the default-command push-back. Every showing
+ // bumps the generation, so an in-flight clear timer from an EARLIER
+ // showing — even of the identical string — can never wipe this one early.
+ private func setRouteNotice(_ text: String?) {
+ routeNoticeGen += 1
+ routeNotice = text
+ guard text != nil else { return }
+ let gen = routeNoticeGen
+ DispatchQueue.main.asyncAfter(deadline: .now() + 8) {
+ if routeNoticeGen == gen { routeNotice = nil }
+ }
+ }
+
private func sendDraft() {
if let id = editingID {
if model.edit(id, text: draft) { cancelEdit() }
@@ -764,16 +792,36 @@ struct NativeChatScreen: View {
break
}
}
- // 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.
+ // Default-command thread (To-do, Reminders): plain text — or one of the
+ // app's own verbs — becomes the app event instead of a chat message
+ // nobody reads; a bare verb is consumed, never posted. Async because
+ // Reminders schedules server-side first; the sync routing() pre-check
+ // decides whether the draft is ours at all (passthrough → normal send).
// 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 staged.isEmpty, replyTo == nil, let dc = defaultCommand,
+ AppDefaultCommand.routing(draft, spec: dc) != .passthrough {
+ // One routing in flight at a time: the draft deliberately stays
+ // visible during the schedule round-trip, so a second Enter must
+ // not double-create the reminder.
+ guard !dcRouting else { return }
+ dcRouting = true
+ let text = draft
+ Task { @MainActor in
+ defer { dcRouting = false }
+ switch await model.routeDefaultCommand(text, spec: dc) {
+ case .sent:
+ // Clear only what we sent — typing during the schedule
+ // round-trip must never be wiped by a stale completion.
+ if draft == text { draft = "" }
+ setRouteNotice(nil) // a success retires any stale push-back
+ case .rejected(let prompt):
+ // The draft stays put to fix; this line is the instruction.
+ setRouteNotice(prompt)
+ case .kept, .notHandled:
+ break // draft stays put (down socket)
+ }
}
+ return
}
if model.send(draft, attachments: staged.compactMap(\.attachment), replyTo: replyTo) {
draft = ""
diff --git a/ios/LChatCore/Sources/LChatCore/AppChatProjection.swift b/ios/LChatCore/Sources/LChatCore/AppChatProjection.swift
index 5a7a107..8014d98 100644
--- a/ios/LChatCore/Sources/LChatCore/AppChatProjection.swift
+++ b/ios/LChatCore/Sources/LChatCore/AppChatProjection.swift
@@ -21,7 +21,7 @@ public enum AppChatProjection {
/// apps/<id>.js chatIntro, verbatim.
public static let intros: [String: String] = [
"todo": "This is the start of your chat history with To-do. Just type something to add it as a to-do — e.g. buy milk. You can see, add, and check off to-dos in the Interface view.",
- "remind": "This is the start of your chat history with Reminders. Type /remind me in 10 minutes to Something — e.g. /remind me in 10 minutes to call mum. You can see and manage your reminders in the Interface view.",
+ "remind": "This is the start of your chat history with Reminders. Just type what and when — e.g. call mum in 10 minutes, or standup at 9:00. You can see and manage your reminders in the Interface view.",
]
// Inline (not ThreadStore.personalApps): projections run off-main in the
diff --git a/ios/LChatCore/Sources/LChatCore/AppDefaultCommand.swift b/ios/LChatCore/Sources/LChatCore/AppDefaultCommand.swift
index 0bb2fa4..acc347e 100644
--- a/ios/LChatCore/Sources/LChatCore/AppDefaultCommand.swift
+++ b/ios/LChatCore/Sources/LChatCore/AppDefaultCommand.swift
@@ -16,15 +16,30 @@ public enum AppDefaultCommand {
public let verbs: [String]
public let placeholder: String // the composer's advertisement of the routing
public let desc: String // the local verb's typeahead one-liner ("Add a to-do")
+ // The bare-verb push-back, shown inline like the web's in-thread usage
+ // reply — a bare "/add" or "/remind" must get feedback, never silence.
+ public let usage: String
}
// One entry per app that declares a defaultCommand on the web — keep in
// sync with web/static/apps/*.js (like AppChatProjection's tables).
+ // Reminders' verb list is just its global /remind: the web made the global
+ // the default (its "what + when" IS the in-thread grammar), so there's no
+ // local twin to lead with.
static let specs: [String: Spec] = [
"todo": Spec(eventType: "todo.add", verbs: ["/add", "/todo"],
- placeholder: "Add a to-do…", desc: "Add a to-do"),
+ placeholder: "Add a to-do…", desc: "Add a to-do",
+ usage: "Usage: /add <task> — e.g. /add buy milk"),
+ "remind": Spec(eventType: "remind.add", verbs: ["/remind"],
+ placeholder: "Remind me… e.g. call mum in 2h", desc: "Set a reminder",
+ usage: remindWhenPrompt),
]
+ // remind.js's "when?" push-back, shown when routed text has no parseable
+ // time — the same wording as the web's usage reply.
+ public static let remindWhenPrompt =
+ "When should I remind you? Try “call mum in 10 minutes”, “standup at 9:00”, or “renew passport | 2026-08-01”."
+
/// The default-command spec for a thread, or nil when the rule is off:
/// not an app-thread, no default declared, or a bot member present.
public static func spec(appID: String?, hasBot: Bool) -> Spec? {
@@ -76,17 +91,32 @@ extension ChatFeedModel {
/// 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 enum DefaultCommandOutcome: Equatable {
+ case sent // event emitted — clear the draft
+ case kept // consumed, nothing sent (bare verb, or a down socket) — keep the draft
+ case rejected(String) // consumed with a push-back to SHOW ("when should I remind you?") — keep the draft
+ case notHandled // not ours — continue to the normal send path
}
- public func routeDefaultCommand(_ draft: String, spec: AppDefaultCommand.Spec) -> DefaultCommandOutcome {
+ /// Async because Reminders schedules its fire on the server before
+ /// emitting (remind.js addReminder's order); To-do resolves immediately.
+ public func routeDefaultCommand(_ draft: String, spec: AppDefaultCommand.Spec) async -> DefaultCommandOutcome {
switch AppDefaultCommand.routing(draft, spec: spec) {
case .passthrough: return .notHandled
- case .swallow: return .kept
+ case .swallow:
+ // A bare verb gets the usage line, like the web's in-thread reply
+ // — silence would leave the platforms answering the same keystroke
+ // differently.
+ return .rejected(spec.usage)
case .event(let text):
+ if spec.eventType == "remind.add" {
+ // Timeless text is the app pushing back, not an error path —
+ // the composer shows the prompt and keeps the draft for fixing.
+ guard let p = ReminderParse.parse(text) else {
+ return .rejected(AppDefaultCommand.remindWhenPrompt)
+ }
+ return await addReminder(text: p.text, when: p.when) ? .sent : .kept
+ }
return sendAppEvent(type: spec.eventType, text: text) ? .sent : .kept
}
}
diff --git a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift
index a420900..06eb81a 100644
--- a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift
+++ b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift
@@ -332,9 +332,11 @@ public final class ChatFeedModel: ObservableObject {
var replyTo: ReplyRef?
}
- // A typed mini-app event (todo.add, …) — the web's ctx.emit envelope: the
- // event fields plus the transport-stamped name/ts/uid. The id is the app
- // entity's stable identity (test/event-vectors/todo.json pins the shape).
+ // A typed mini-app event (todo.add, remind.add, …) — the web's ctx.emit
+ // envelope: the event fields plus the transport-stamped name/ts/uid. The id
+ // is the app entity's stable identity; `when`/`sid` are remind.add's extras
+ // (nil optionals are omitted, so a todo.add carries neither — the event
+ // vectors pin both shapes).
private struct AppEventOut: Encodable {
let type: String
let id: String
@@ -342,19 +344,25 @@ public final class ChatFeedModel: ObservableObject {
let name: String
let ts: Int64
var uid: String?
+ var when: Double? // remind.add: fire time, epoch ms
+ var sid: String? // remind.add: server schedule id ("" = offline fallback)
}
/// Emit a mini-app event into this thread (the default-command path: plain
/// text typed into To-do becomes todo.add). Encrypted + sent like any
/// message; the echo folds back through the app projection, so the action
- /// line appears exactly as it would from the web.
+ /// line appears exactly as it would from the web. `id` defaults to a fresh
+ /// entity id; remind.add passes its own so the scheduled fire matches.
@discardableResult
- public func sendAppEvent(type: String, text: String) -> Bool {
+ public func sendAppEvent(type: String, text: String, id: String = UUID().uuidString,
+ when: Double? = nil, sid: String? = nil) -> Bool {
guard !text.isEmpty, let key, let socket else { return false }
- var payload = AppEventOut(type: type, id: UUID().uuidString, text: text,
+ var payload = AppEventOut(type: type, id: id, text: text,
name: instance.screenName,
ts: Int64(Date().timeIntervalSince1970 * 1000))
if !myUserId.isEmpty { payload.uid = myUserId }
+ payload.when = when
+ payload.sid = sid
guard let blob = try? key.encryptJSON(payload),
let frame = try? JSONEncoder().encode(OutFrame(thread_id: threadID, blob: blob)),
let json = String(data: frame, encoding: .utf8) else { return false }
@@ -363,6 +371,56 @@ public final class ChatFeedModel: ObservableObject {
return sent
}
+ // The scheduled remind.fire payload — encrypted NOW and held by the server
+ // until the due moment (remind.js addReminder's shape: the reminder text
+ // rides ONLY inside the encrypted notice).
+ private struct RemindFireOut: Encodable {
+ let type = "remind.fire"
+ let id: String
+ let notice: Notice
+ let name: String
+ let ts: Int64
+ var uid: String?
+ struct Notice: Encodable {
+ let title: String
+ let body: String
+ }
+ }
+
+ /// The full native reminder write path, mirroring remind.js addReminder:
+ /// schedule the future remind.fire FIRST (its encrypted notice is what
+ /// alerts every device), then record the reminder via remind.add carrying
+ /// the schedule id — or "" when scheduling failed (offline), which the
+ /// render ticker fires best-effort, exactly like the web. If the emit then
+ /// fails (socket down), the fresh schedule is CANCELLED before returning —
+ /// otherwise no remind.add would ever record its sid, and the user's retry
+ /// would stack a second, uncancellable alarm on top of the orphan.
+ public func addReminder(text: String, when: Double) async -> Bool {
+ guard let key else { return false }
+ let id = UUID().uuidString
+ var sid: String? = nil
+ var fire = RemindFireOut(id: id, notice: .init(title: "⏰ Reminder", body: text),
+ name: instance.screenName,
+ ts: Int64(Date().timeIntervalSince1970 * 1000))
+ if !myUserId.isEmpty { fire.uid = myUserId }
+ // The seconds conversion must never trap: Int64(Double) crashes on
+ // out-of-range/infinite input, and the parser accepts arbitrarily big
+ // relative times exactly like the web — whose server just answers 400
+ // ("deliver_at out of range", the scheduleHorizon check) and the add
+ // falls back to sid "". So the guard here is ONLY trap-safety; the
+ // server stays the one range authority for both platforms.
+ let sec = when / 1000
+ if let api, sec.isFinite, sec > 0, sec < 9e18, // Int64-conversion safety, not policy
+ let blob = try? key.encryptJSON(fire) {
+ sid = await api.schedule(thread: threadID, deliverAt: Int64(sec), blob: blob)
+ }
+ let sent = sendAppEvent(type: "remind.add", text: text, id: id, when: when, sid: sid ?? "")
+ if !sent, let sid, let api {
+ await api.unschedule(sid) // never orphan a fire no remind.add points at
+ }
+ return sent
+ }
+
/// Broadcast a /me-style action line ("Paul waves") — the composer builds
/// the full text; here it rides with action:true, like the web's ctx.emit.
@discardableResult
diff --git a/ios/LChatCore/Sources/LChatCore/LChatAPI.swift b/ios/LChatCore/Sources/LChatCore/LChatAPI.swift
index 0444cf1..f6f9c9c 100644
--- a/ios/LChatCore/Sources/LChatCore/LChatAPI.swift
+++ b/ios/LChatCore/Sources/LChatCore/LChatAPI.swift
@@ -80,6 +80,42 @@ public struct LChatAPI {
return r.thread_id
}
+ /// Schedule an encrypted event for future delivery into a thread (the web's
+ /// ctx.schedule wire: POST /api/schedule). The server holds only ciphertext
+ /// plus (thread, time) — the accepted timing trade-off of real alerts — and
+ /// delivers the blob as a normal message at the due moment. `deliverAt` is
+ /// unix SECONDS (the wire's unit; callers hold epoch ms). Returns the
+ /// schedule id (for cancel), or nil — a failed schedule is the offline
+ /// fallback path, not an error.
+ public func schedule(thread: String, deliverAt: Int64, blob: String) async -> String? {
+ struct Resp: Codable { let id: String? }
+ let body: [String: Any] = ["token": token, "thread_id": thread, "deliver_at": deliverAt, "blob": blob]
+ guard let url = URL(string: "/api/schedule", relativeTo: baseURL),
+ let data = try? JSONSerialization.data(withJSONObject: body) else { return nil }
+ var req = URLRequest(url: url)
+ req.httpMethod = "POST"
+ req.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ req.httpBody = data
+ guard let (respData, resp) = try? await session.data(for: req),
+ (resp as? HTTPURLResponse)?.statusCode == 200,
+ let r = try? JSONDecoder().decode(Resp.self, from: respData) else { return nil }
+ return r.id
+ }
+
+ /// Cancel a pending schedule by the id schedule() returned (the web's
+ /// ctx.unschedule wire: DELETE /api/schedule/{id}).
+ @discardableResult
+ public func unschedule(_ sid: String) async -> Bool {
+ var comps = URLComponents(url: baseURL.appending(path: "/api/schedule/\(sid)"),
+ resolvingAgainstBaseURL: false)!
+ comps.queryItems = [URLQueryItem(name: "token", value: token)]
+ guard let url = comps.url else { return false }
+ var req = URLRequest(url: url)
+ req.httpMethod = "DELETE"
+ guard let (_, resp) = try? await session.data(for: req) else { return false }
+ return (resp as? HTTPURLResponse)?.statusCode == 200
+ }
+
/// Pin/unpin a chat for this user — a per-user marker the server syncs across
/// the user's devices and floats to the top of their list.
public func setPinned(thread: String, pinned: Bool) async {
diff --git a/ios/LChatCore/Sources/LChatCore/ReminderParse.swift b/ios/LChatCore/Sources/LChatCore/ReminderParse.swift
new file mode 100644
index 0000000..6bc6cd5
--- /dev/null
+++ b/ios/LChatCore/Sources/LChatCore/ReminderParse.swift
@@ -0,0 +1,133 @@
+import Foundation
+
+// 🤖 The native port of remind.js's parseReminder — the pure "<text> <when>"
+// splitter behind /remind and the Reminders default command. Same three shapes,
+// same order, same rejections (ReminderParseTests pins parity against the web
+// test vectors):
+// "… | 2026-08-01[ 14:00]" explicit date/time after a bar
+// "… in 2h" / "in 45m …" relative from now (time may lead or trail)
+// "… at 15:30" / "at 9:00 …" next occurrence of that clock time
+// Times are epoch MILLISECONDS (the web's currency); `now` is injectable so
+// tests are deterministic. Clock/date shapes resolve in LOCAL time, like the
+// web ("at 9:00" means the user's 9am).
+//
+// Port gotchas this file deliberately handles (each caught by review):
+// - Digits are [0-9], NOT \d: ICU \d matches every Unicode decimal digit
+// ("٥"), which Swift's numeric inits then reject — JS \d is ASCII-only, and
+// a parser must never be able to crash the app, so no force-unwraps either.
+// - Years below 100 are rejected: JS new Date(26,…) means 1926 and fails the
+// round-trip check, so "0026-08-01" must not become 26 AD here.
+// - Interior trims strip newlines too (JS .trim() does): a multi-line draft
+// must split identically on both platforms.
+public enum ReminderParse {
+ public struct Parsed: Equatable {
+ public let text: String
+ public let when: Double // epoch ms
+ }
+
+ // remind.js REMIND_UNIT, verbatim.
+ static let unitPattern = "seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d|weeks?|w"
+
+ static func unitMs(_ u: String) -> Double {
+ let l = u.lowercased()
+ if l.range(of: "^(seconds?|secs?|s)$", options: .regularExpression) != nil { return 1000 }
+ if l.range(of: "^(minutes?|mins?|m)$", options: .regularExpression) != nil { return 60000 }
+ if l.range(of: "^(hours?|hrs?|h)$", options: .regularExpression) != nil { return 3_600_000 }
+ if l.range(of: "^(days?|d)$", options: .regularExpression) != nil { return 86_400_000 }
+ if l.range(of: "^(weeks?|w)$", options: .regularExpression) != nil { return 604_800_000 }
+ return 0
+ }
+
+ // The next wall-clock occurrence of h:m — today if still ahead, else tomorrow.
+ static func nextClockTime(h: Int, m: Int, now: Double) -> Double? {
+ guard h <= 23, m <= 59 else { return nil }
+ let cal = Calendar.current
+ let base = Date(timeIntervalSince1970: now / 1000)
+ guard let at = cal.date(bySettingHour: h, minute: m, second: 0, of: base) else { return nil }
+ var t = at.timeIntervalSince1970 * 1000
+ if t <= now { t += 86_400_000 }
+ return t
+ }
+
+ // "YYYY-MM-DD" or "YYYY-MM-DD HH:MM" (T or space), local time; bare dates
+ // default to 09:00. Out-of-range parts are REJECTED, never rolled over
+ // (2026-06-31 must not become July 1) — verified by round-tripping the
+ // components, the same rigor as the web's Date checks.
+ static func parseAbsolute(_ str: String) -> Double? {
+ guard let m = firstMatch(#"^([0-9]{4})-([0-9]{2})-([0-9]{2})(?:[ T]([0-9]{1,2}):([0-9]{2}))?$"#, in: str),
+ let y = Int(m[1]), let mo = Int(m[2]), let d = Int(m[3]) else { return nil }
+ let h = m[4].isEmpty ? 9 : (Int(m[4]) ?? -1)
+ let min = m[5].isEmpty ? 0 : (Int(m[5]) ?? -1)
+ // y >= 100: JS's new Date(y,…) maps 0–99 to 1900+y and then fails its
+ // round-trip check — a sub-100 year is a typo to reject, not 26 AD.
+ guard y >= 100, (1...12).contains(mo), (1...31).contains(d),
+ (0...23).contains(h), (0...59).contains(min) else { return nil }
+ var comps = DateComponents()
+ comps.year = y; comps.month = mo; comps.day = d; comps.hour = h; comps.minute = min
+ let cal = Calendar.current
+ guard let dt = cal.date(from: comps) else { return nil }
+ let back = cal.dateComponents([.year, .month, .day], from: dt)
+ guard back.year == y, back.month == mo, back.day == d else { return nil }
+ return dt.timeIntervalSince1970 * 1000
+ }
+
+ public static func parse(_ raw: String, now: Double = Date().timeIntervalSince1970 * 1000) -> Parsed? {
+ var s = raw.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !s.isEmpty else { return nil }
+ // Slack phrasing is "/remind me …" — drop a leading standalone "me"
+ // (whole word: "meeting" keeps its me-).
+ s = s.replacingOccurrences(of: #"^me\b\s*"#, with: "", options: [.regularExpression, .caseInsensitive])
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !s.isEmpty else { return nil }
+
+ // Explicit "<text> | <absolute>" form.
+ if let bar = s.firstIndex(of: "|") {
+ let text = String(s[..<bar]).trimmingCharacters(in: .whitespacesAndNewlines)
+ let when = parseAbsolute(String(s[s.index(after: bar)...]).trimmingCharacters(in: .whitespacesAndNewlines))
+ guard !text.isEmpty, let when else { return nil }
+ return Parsed(text: text, when: when)
+ }
+
+ // The "when" can lead ("in 10s to do X") or trail ("do X in 10s").
+ var when: Double?
+ var rest: String?
+ if let m = firstMatch("^in\\s+([0-9]+)\\s*(\(unitPattern))\\b\\s*(.*)$", in: s), let n = Double(m[1]) {
+ let ms = unitMs(m[2])
+ guard ms > 0 else { return nil }
+ when = now + n * ms
+ rest = m[3]
+ } else if let m = firstMatch("^(.*?)\\s+in\\s+([0-9]+)\\s*(\(unitPattern))\\s*$", in: s), let n = Double(m[2]) {
+ let ms = unitMs(m[3])
+ guard ms > 0 else { return nil }
+ when = now + n * ms
+ rest = m[1]
+ } else if let m = firstMatch(#"^at\s+([0-9]{1,2}):([0-9]{2})\b\s*(.*)$"#, in: s),
+ let h = Int(m[1]), let min = Int(m[2]) {
+ when = nextClockTime(h: h, m: min, now: now)
+ rest = m[3]
+ } else if let m = firstMatch(#"^(.*?)\s+at\s+([0-9]{1,2}):([0-9]{2})\s*$"#, in: s),
+ let h = Int(m[2]), let min = Int(m[3]) {
+ when = nextClockTime(h: h, m: min, now: now)
+ rest = m[1]
+ }
+
+ // isFinite: an absurd digit string overflows Double to +inf, which the
+ // web ends up dropping in the fold (JSON null) — natively we reject it
+ // here so the composer asks for a real time instead of no-opping.
+ guard let when, when.isFinite, var text = rest else { return nil }
+ text = text.replacingOccurrences(of: #"^to\s+"#, with: "", options: [.regularExpression, .caseInsensitive])
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ return text.isEmpty ? nil : Parsed(text: text, when: when)
+ }
+
+ // Case-insensitive regex helper: group strings by index ("" for no-match groups).
+ private static func firstMatch(_ pattern: String, in s: String) -> [String]? {
+ guard let re = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else { return nil }
+ let ns = s as NSString
+ guard let m = re.firstMatch(in: s, range: NSRange(location: 0, length: ns.length)) else { return nil }
+ return (0..<m.numberOfRanges).map { i in
+ let r = m.range(at: i)
+ return r.location == NSNotFound ? "" : ns.substring(with: r)
+ }
+ }
+}
diff --git a/ios/LChatCore/Tests/LChatCoreTests/AppDefaultCommandTests.swift b/ios/LChatCore/Tests/LChatCoreTests/AppDefaultCommandTests.swift
index 7b00c3a..a5f1e50 100644
--- a/ios/LChatCore/Tests/LChatCoreTests/AppDefaultCommandTests.swift
+++ b/ios/LChatCore/Tests/LChatCoreTests/AppDefaultCommandTests.swift
@@ -29,11 +29,37 @@ final class AppDefaultCommandTests: XCTestCase {
XCTAssertNotNil(AppDefaultCommand.spec(appID: "todo", hasBot: false))
XCTAssertNil(AppDefaultCommand.spec(appID: "todo", hasBot: true),
"an LLM member makes plain text a real message again")
- XCTAssertNil(AppDefaultCommand.spec(appID: "remind", hasBot: false),
- "no default declared on the web yet")
+ XCTAssertNotNil(AppDefaultCommand.spec(appID: "remind", hasBot: false))
+ XCTAssertNil(AppDefaultCommand.spec(appID: "remind", hasBot: true))
XCTAssertNil(AppDefaultCommand.spec(appID: nil, hasBot: false))
}
+ // Reminders' verb is its GLOBAL /remind (the web made it the default — no
+ // local twin), so it both routes and leads the thread's typeahead.
+ func testRemindSpecRoutesItsGlobalVerb() throws {
+ let dc = try XCTUnwrap(AppDefaultCommand.spec(appID: "remind", hasBot: false))
+ XCTAssertEqual(AppDefaultCommand.routing("call mum in 2h", spec: dc), .event("call mum in 2h"))
+ XCTAssertEqual(AppDefaultCommand.routing("/remind call mum in 2h", spec: dc), .event("call mum in 2h"))
+ XCTAssertEqual(AppDefaultCommand.routing("/remind", spec: dc), .swallow)
+ XCTAssertEqual(AppDefaultCommand.routing("/theme lagoon", spec: dc), .passthrough)
+ XCTAssertEqual(AppDefaultCommand.localCommands(appID: "remind").map(\.name), ["/remind"])
+ }
+
+ func testRemindSpecMatchesRemindJS() throws {
+ let js = try String(contentsOf: repoRoot.appending(path: "web/static/apps/remind.js"),
+ encoding: .utf8)
+ let dc = try XCTUnwrap(AppDefaultCommand.specs["remind"])
+ XCTAssertEqual(jsString(js, key: "defaultCommand"), dc.verbs.first,
+ "Swift default command has drifted from remind.js")
+ XCTAssertEqual(jsString(js, key: "composerPlaceholder"), dc.placeholder,
+ "Swift placeholder has drifted from remind.js")
+ XCTAssertEqual(jsString(js, key: "chatIntro"), AppChatProjection.intros["remind"],
+ "native Reminders intro has drifted from remind.js chatIntro")
+ // The "when?" push-back mirrors the web's usage reply wording.
+ XCTAssertTrue(js.contains(AppDefaultCommand.remindWhenPrompt),
+ "remindWhenPrompt has drifted from remind.js's usage reply")
+ }
+
// ---- routing (chat.js composer) ----------------------------------------
func testPlainTextRoutes() throws {
diff --git a/ios/LChatCore/Tests/LChatCoreTests/ComposerTypeaheadTests.swift b/ios/LChatCore/Tests/LChatCoreTests/ComposerTypeaheadTests.swift
index 44c762a..3a80a3f 100644
--- a/ios/LChatCore/Tests/LChatCoreTests/ComposerTypeaheadTests.swift
+++ b/ios/LChatCore/Tests/LChatCoreTests/ComposerTypeaheadTests.swift
@@ -134,6 +134,7 @@ final class ComposerTypeaheadTests: XCTestCase {
appCommands: add).isEmpty,
"prefix filtering applies to app commands too")
XCTAssertEqual(AppDefaultCommand.localCommands(appID: nil).count, 0)
- XCTAssertEqual(AppDefaultCommand.localCommands(appID: "remind").count, 0)
+ XCTAssertEqual(AppDefaultCommand.localCommands(appID: "poll").count, 0,
+ "apps with no default-command spec offer no in-thread verbs")
}
}
diff --git a/ios/LChatCore/Tests/LChatCoreTests/ReminderParseTests.swift b/ios/LChatCore/Tests/LChatCoreTests/ReminderParseTests.swift
new file mode 100644
index 0000000..7ddab02
--- /dev/null
+++ b/ios/LChatCore/Tests/LChatCoreTests/ReminderParseTests.swift
@@ -0,0 +1,98 @@
+import XCTest
+@testable import LChatCore
+
+// 🤖 Parity tests for the native parseReminder port — the SAME cases the web
+// suite pins (test/apps.test.mjs "remind: parseReminder …"), so the two parsers
+// accept and reject identically. `now` is injected, so everything relative is
+// exact; clock/date shapes assert on local-time components, like the web tests.
+final class ReminderParseTests: XCTestCase {
+ func testRelativeTimes() {
+ XCTAssertEqual(ReminderParse.parse("Call mom in 2h", now: 0),
+ .init(text: "Call mom", when: 2 * 3_600_000))
+ XCTAssertEqual(ReminderParse.parse("stretch in 45m", now: 1000),
+ .init(text: "stretch", when: 1000 + 45 * 60000))
+ }
+
+ func testClockTimeIsNextOccurrenceWithinADay() throws {
+ let now = Date().timeIntervalSince1970 * 1000
+ let at = try XCTUnwrap(ReminderParse.parse("Standup at 9:00", now: now))
+ XCTAssertEqual(at.text, "Standup")
+ XCTAssertGreaterThan(at.when, now)
+ XCTAssertLessThanOrEqual(at.when, now + 24 * 3_600_000)
+ let comps = Calendar.current.dateComponents([.hour, .minute],
+ from: Date(timeIntervalSince1970: at.when / 1000))
+ XCTAssertEqual(comps.hour, 9)
+ XCTAssertEqual(comps.minute, 0)
+ }
+
+ func testExplicitDateAfterBar() throws {
+ let abs = try XCTUnwrap(ReminderParse.parse("Renew passport | 2026-08-01", now: 0))
+ XCTAssertEqual(abs.text, "Renew passport")
+ let d = Calendar.current.dateComponents([.year, .month, .day, .hour],
+ from: Date(timeIntervalSince1970: abs.when / 1000))
+ XCTAssertEqual(d.year, 2026)
+ XCTAssertEqual(d.month, 8)
+ XCTAssertEqual(d.day, 1)
+ XCTAssertEqual(d.hour, 9, "a bare date defaults to 09:00")
+ }
+
+ func testSlackPhrasing() {
+ // "me in 10 seconds to do a thing" — leading "me" dropped, time leads.
+ XCTAssertEqual(ReminderParse.parse("me in 10 seconds to do a thing", now: 0),
+ .init(text: "do a thing", when: 10000))
+ XCTAssertEqual(ReminderParse.parse("in 5 minutes to stretch", now: 1000),
+ .init(text: "stretch", when: 1000 + 5 * 60000))
+ XCTAssertEqual(ReminderParse.parse("me to water plants in 1 h", now: 0),
+ .init(text: "water plants", when: 3_600_000))
+ // "meeting" must NOT be read as the "me" keyword.
+ XCTAssertEqual(ReminderParse.parse("meeting in 10m", now: 0),
+ .init(text: "meeting", when: 10 * 60000))
+ }
+
+ // Port-hardening edges from review: each of these crashed or silently
+ // diverged from the web before being pinned here.
+ func testNonASCIIDigitsAreRejectedNotCrashed() {
+ // ICU \d matches Unicode digits ("٥") that Swift's numeric inits then
+ // reject — the patterns must be [0-9] and the outcome a calm nil.
+ XCTAssertNil(ReminderParse.parse("pray in ٥ minutes", now: 0))
+ XCTAssertNil(ReminderParse.parse("bad | ٢٠٢٦-08-01", now: 0))
+ XCTAssertNil(ReminderParse.parse("standup at ٩:00", now: 0))
+ }
+
+ func testSubCenturyYearsAreRejected() {
+ // JS's new Date(26,…) means 1926 and fails its round-trip; 0026 must
+ // not become 26 AD natively (it would file a permanently-due reminder).
+ XCTAssertNil(ReminderParse.parse("renew passport | 0026-08-01", now: 0))
+ XCTAssertNil(ReminderParse.parse("renew passport | 0099-01-01", now: 0))
+ }
+
+ func testNewlinesTrimLikeTheWeb() {
+ // JS .trim() strips newlines; multi-line drafts must split identically.
+ XCTAssertEqual(ReminderParse.parse("call mum \n| 2026-08-01", now: 0)?.text, "call mum")
+ XCTAssertEqual(ReminderParse.parse("me \n in 10 s \n to stretch", now: 0)?.text, "stretch")
+ }
+
+ func testAbsurdRelativeAmountsDoNotProduceInfinity() {
+ // A digit string big enough to overflow Double must reject (the web's
+ // equivalent add is dropped in the fold), never propagate ±inf into
+ // scheduling arithmetic.
+ let huge = String(repeating: "9", count: 400)
+ XCTAssertNil(ReminderParse.parse("call mum in \(huge) weeks", now: 0))
+ // A large-but-finite amount still parses — the SERVER is the range
+ // authority (postSchedule's horizon check), same as the web.
+ XCTAssertNotNil(ReminderParse.parse("call mum in 999999999 weeks", now: 0))
+ }
+
+ func testRejections() {
+ XCTAssertNil(ReminderParse.parse("just a note", now: 0))
+ XCTAssertNil(ReminderParse.parse("", now: 0))
+ XCTAssertNil(ReminderParse.parse("do it in 3x", now: 0), "unknown unit")
+ XCTAssertNil(ReminderParse.parse("in 2h", now: 0), "no text before the time")
+ // Explicit dates/times are rejected, never rolled over, when out of range.
+ XCTAssertNil(ReminderParse.parse("bad | 2026-06-31", now: 0), "June has 30 days")
+ XCTAssertNil(ReminderParse.parse("bad | 2026-02-29", now: 0), "2026 isn't a leap year")
+ XCTAssertNil(ReminderParse.parse("bad | 2026-13-01", now: 0), "month > 12")
+ XCTAssertNil(ReminderParse.parse("bad | 2026-08-01 24:00", now: 0), "hour > 23")
+ XCTAssertNil(ReminderParse.parse("bad | 2026-08-01 12:60", now: 0), "minute > 59")
+ }
+}
diff --git a/ios/LChatMac/NativeFeedView.swift b/ios/LChatMac/NativeFeedView.swift
index e024404..98a1ab6 100644
--- a/ios/LChatMac/NativeFeedView.swift
+++ b/ios/LChatMac/NativeFeedView.swift
@@ -80,6 +80,15 @@ struct NativeFeedView: View {
// Transient status-bar line while a video/file fetches ("nothing happened"
// is worse than a quiet caption; the web shows a radial ring here).
@State private var busyText: String?
+ // A default command's push-back line ("when should I remind you?") — its
+ // OWN slot, not busyText: the upload paths clear busyText on their own 3s
+ // timers and would wipe an unrelated instruction. The generation counter
+ // makes every clear timer cancel-safe (a re-shown identical prompt must
+ // not be wiped by the previous showing's timer), and dcRouting keeps one
+ // async routing in flight at a time (no double-created reminders).
+ @State private var routeNotice: String?
+ @State private var routeNoticeGen = 0
+ @State private var dcRouting = false
// The native composer's draft — cleared only when a send actually reaches
// the server (model.send returns false on a down socket, keeping the text).
@State private var draft = ""
@@ -587,6 +596,16 @@ struct NativeFeedView: View {
composerBanner("Editing message", detail: nil) { cancelEdit() }
}
VStack(spacing: 0) {
+ // A default command's push-back ("when should I remind you?")
+ // — inline above the composer, occupying layout, never floating.
+ if let routeNotice {
+ Text(routeNotice)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.horizontal, 12)
+ .padding(.top, 6)
+ }
composerRow
// Transient feedback only (downloads, upload errors) — no
// permanent status line.
@@ -856,6 +875,19 @@ struct NativeFeedView: View {
staged.contains { $0.attachment == nil }
}
+ // Show (or clear, with nil) the default-command push-back. Every showing
+ // bumps the generation, so an in-flight clear timer from an EARLIER
+ // showing — even of the identical string — can never wipe this one early.
+ private func setRouteNotice(_ text: String?) {
+ routeNoticeGen += 1
+ routeNotice = text
+ guard text != nil else { return }
+ let gen = routeNoticeGen
+ DispatchQueue.main.asyncAfter(deadline: .now() + 8) {
+ if routeNoticeGen == gen { routeNotice = nil }
+ }
+ }
+
private func sendDraft() {
// An edit replaces the message in place — no attachments/reply staging.
if let id = editingID {
@@ -887,16 +919,37 @@ 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.
+ // Default-command thread (To-do, Reminders): plain text — or one of the
+ // app's own verbs — becomes the app event instead of a chat message
+ // nobody reads; a bare verb is consumed, never posted. Async because
+ // Reminders schedules server-side first; the sync routing() pre-check
+ // decides whether the draft is ours at all (passthrough → normal send).
// 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
+ if staged.isEmpty, replyTo == nil, let dc = defaultCommand,
+ AppDefaultCommand.routing(draft, spec: dc) != .passthrough {
+ // One routing in flight at a time: the draft deliberately stays
+ // visible during the schedule round-trip, so a second Enter must
+ // not double-create the reminder.
+ guard !dcRouting else { return }
+ dcRouting = true
+ let text = draft
+ Task { @MainActor in
+ defer { dcRouting = false }
+ switch await model.routeDefaultCommand(text, spec: dc) {
+ case .sent:
+ // Clear only what we sent — typing during the schedule
+ // round-trip must never be wiped by a stale completion.
+ if draft == text { draft = "" }
+ setRouteNotice(nil) // a success retires any stale push-back
+ case .rejected(let prompt):
+ // The app pushing back ("when should I remind you?") — the
+ // draft stays put to fix; this line is the instruction.
+ setRouteNotice(prompt)
+ case .kept, .notHandled:
+ break // draft stays put (down socket)
+ }
}
+ return
}
// Mid-upload, the send queues instead of blocking (web PR #269): the
// button spins and fireQueuedSend releases it when the batch resolves.