eleven / ocho Public

mac,core: companion apps' chat view goes native — events project as chat (#289)

The Both/Chat panes of a mini-app thread showed an EMPTY native feed:
app events (remind.add, remind.fire, todo.add) aren't type:"chat", so
the fold skipped them all. AppChatProjection is the native twin of the
web apps' chatMessage(): remind.fire renders as the bot's bubble
("You asked me to remind you: …"), remind.add/todo.add as the action
lines you took (with remind.js's absTime format), and unknown events
stay hidden exactly like chatMessage's nulls. Both fold paths route
through the projection, so the store and search index carry the
projected lines too. The app's chatIntro renders above the earliest
message, like the web's start-of-history intro.

The other half of the fix — the right pane showing the app INTERFACE
instead of web chat — is the shell.js embed-view change already on
main; it takes effect when the server deploys.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 8ae3e15b63ead06976d7ed9c375637b799eb1649 parent b57506b
3 files changed, +93 −2
  • ios/LChatCore/Sources/LChatCore/AppChatProjection.swift +56 −0
  • ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift +22 −2
  • ios/LChatMac/NativeFeedView.swift +15 −0
diff --git a/ios/LChatCore/Sources/LChatCore/AppChatProjection.swift b/ios/LChatCore/Sources/LChatCore/AppChatProjection.swift
new file mode 100644
index 0000000..98d0ecd
--- /dev/null
+++ b/ios/LChatCore/Sources/LChatCore/AppChatProjection.swift
@@ -0,0 +1,56 @@
+import Foundation
+
+// 🤖 The native side of the companion apps' Chat view: each mini-app event
+// projects into a chat line exactly the way the app's own chatMessage() does
+// on the web (apps/remind.js, apps/todo.js) — a bot bubble for something the
+// app tells YOU, an action line for something you did. Only the companion
+// apps (the ones implementing chatMessage) appear here; takeover apps have no
+// chat view to project into.
+public enum AppChatProjection {
+ /// The event payload fields the projections read (decoded from the same
+ /// decrypted blob as MessagePayload — apps emit richer objects).
+ public struct AppEvent: Codable {
+ public var type: String?
+ public var text: String?
+ public var when: Double? // ms since epoch (remind.add)
+ public var notice: Notice?
+ public struct Notice: Codable { public var body: String? }
+ }
+
+ /// The start-of-history intro each companion app shows above its chat —
+ /// apps/<id>.js chatIntro, verbatim.
+ public static let intros: [String: String] = [
+ "todo": "This is the start of your chat history with To-do. Type /todo Something to add a to-do — e.g. /todo 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.",
+ ]
+
+ // Inline (not ThreadStore.personalApps): projections run off-main in the
+ // detached history fold, and that catalog is main-actor state.
+ public static func title(_ appID: String) -> String {
+ ["todo": "To-do", "remind": "Reminders"][appID] ?? appID
+ }
+
+ /// Project one event into a chat line; nil = this event doesn't render
+ /// (state-only events like todo.toggle, mirroring chatMessage's nulls).
+ public static func project(_ e: AppEvent) -> (text: String, action: Bool, appID: String)? {
+ switch e.type {
+ case "remind.fire":
+ return ("You asked me to remind you: “\(e.notice?.body ?? "")”", false, "remind")
+ case "remind.add":
+ let at = e.when.map(absTime) ?? ""
+ return ("set a reminder: “\(e.text ?? "")”" + (at.isEmpty ? "" : " — " + at),
+ true, "remind")
+ case "todo.add":
+ return ("added “\(e.text ?? "")”", true, "todo")
+ default:
+ return nil
+ }
+ }
+
+ // remind.js absTime: "Sat 4 Jul at 23:16".
+ static func absTime(_ ms: Double) -> String {
+ let f = DateFormatter()
+ f.dateFormat = "EEE d MMM 'at' HH:mm"
+ return f.string(from: Date(timeIntervalSince1970: ms / 1000))
+ }
+}
diff --git a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift
index a153a04..e17cba7 100644
--- a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift
+++ b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift
@@ -513,7 +513,7 @@ public final class ChatFeedModel: ObservableObject {
var out: [Message] = []
var toStore: [MessageStore.StoredMessage] = []
for row in rows {
- guard let p = try? key.decryptJSON(MessagePayload.self, blob: row.blob) else { continue }
+ guard let p = Self.renderable(row.blob, key: key) else { continue }
toStore.append(.init(id: row.id, at: row.created_at,
edited: (row.edited_at ?? 0) > 0,
reactions: row.reactions ?? [], payload: p))
@@ -711,7 +711,7 @@ public final class ChatFeedModel: ObservableObject {
private func fold(id: Int64, at: Int64, blob: String,
edited: Bool = false, reactions: [Reaction] = []) {
guard let key else { return }
- let payload = try? key.decryptJSON(MessagePayload.self, blob: blob)
+ let payload = Self.renderable(blob, key: key)
var list = messages
fold(id: id, at: at, payload: payload,
edited: edited, reactions: reactions, into: &list)
@@ -736,6 +736,26 @@ public final class ChatFeedModel: ObservableObject {
// Fold one already-decrypted payload into a working list (upsert by id —
// history refetch and the live echo of an already-seen message must not
// duplicate). Batch callers fold a whole page before publishing once.
+
+ // App-thread events (todo.add, remind.fire, …) aren't type:"chat" — the
+ // companion apps' Chat view projects them into chat lines on the web, and
+ // this is that projection natively: decode the richer event and synthesize
+ // a renderable payload (bot bubble or action line).
+ nonisolated static func renderable(_ blob: String, key: GroupKey) -> MessagePayload? {
+ guard let p = try? key.decryptJSON(MessagePayload.self, blob: blob) else { return nil }
+ if p.isRenderableChat { return p }
+ guard let ev = try? key.decryptJSON(AppChatProjection.AppEvent.self, blob: blob),
+ let proj = AppChatProjection.project(ev) else { return nil }
+ return MessagePayload(type: "chat",
+ uid: proj.action ? p.uid : nil,
+ name: proj.action ? p.name : AppChatProjection.title(proj.appID),
+ text: proj.text,
+ ts: p.ts,
+ action: proj.action,
+ attachments: nil,
+ replyTo: nil)
+ }
+
private func fold(id: Int64, at: Int64, payload: MessagePayload?,
edited: Bool, reactions: [Reaction], into list: inout [Message]) {
guard let p = payload, p.isRenderableChat else { return }
diff --git a/ios/LChatMac/NativeFeedView.swift b/ios/LChatMac/NativeFeedView.swift
index 327017e..0544c4c 100644
--- a/ios/LChatMac/NativeFeedView.swift
+++ b/ios/LChatMac/NativeFeedView.swift
@@ -56,8 +56,13 @@ struct NativeFeedView: View {
self.mentionAvatar = mentionAvatar
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 companion app's start-of-history intro (chatIntro on the web),
+ // shown above the earliest message once all history is loaded.
+ private let appIntro: String?
+
// The lightbox lives on MacAppState (RootView overlays it window-wide).
@EnvironmentObject private var app: MacAppState
// Transient status-bar line while a video/file fetches ("nothing happened"
@@ -129,6 +134,16 @@ struct NativeFeedView: View {
// children can overdraw their row freely.
ScrollView {
LazyVStack(spacing: 0) {
+ if let appIntro, model.ready, model.exhausted || model.messages.isEmpty {
+ Text(appIntro)
+ .font(.system(size: 13))
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ .frame(maxWidth: 440)
+ .padding(.vertical, 28)
+ .padding(.horizontal, 16)
+ .frame(maxWidth: .infinity)
+ }
ForEach(Array(model.messages.enumerated()), id: \.element.id) { i, m in
row(m, previous: i > 0 ? model.messages[i - 1] : nil, proxy: proxy)
.id(m.id)