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>
10 files changed,
+516
−33
ios/LChat/Views/NativeChatScreen.swift+56 −8ios/LChatCore/Sources/LChatCore/AppChatProjection.swift+1 −1ios/LChatCore/Sources/LChatCore/AppDefaultCommand.swift+37 −7ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift+64 −6ios/LChatCore/Sources/LChatCore/LChatAPI.swift+36 −0ios/LChatCore/Sources/LChatCore/ReminderParse.swift+133 −0ios/LChatCore/Tests/LChatCoreTests/AppDefaultCommandTests.swift+28 −2ios/LChatCore/Tests/LChatCoreTests/ComposerTypeaheadTests.swift+2 −1ios/LChatCore/Tests/LChatCoreTests/ReminderParseTests.swift+98 −0ios/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. |