eleven / messenger Public

Native "/" typeahead shows app-local verbs (/add in To-do) (#387)

* mac,ios,core: "/" typeahead shows app-local verbs — /add in To-do

The native typeahead's command catalog was BOT_COMMANDS only, gated on a
bot being present — so in a botless app-thread "/" showed nothing at
all. typeaheadItems now takes the thread's app-local commands (from
AppDefaultCommand.localCommands, the native slice of the web's
commandCatalog(threadId)): they lead the list and show bot or no bot,
while bot commands stay bot-gated. Wired on Mac (NativeFeedView) and
iOS (NativeChatScreen).

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

* ios: app-threads use ChatView (webview) — wire ITS composer chrome too

On iOS a mini-app thread never opens NativeChatScreen (ThreadListView
routes t.isApp to the webview surface), so the To-do typeahead/
placeholder/attach wiring was unreachable there. ChatView's NATIVE
composer now gets the same treatment: /add in the typeahead strip
(appCommands), "Add a to-do…" placeholder, + button gone in a
default-command thread. The actual routing already worked — sendText
submits through the web composer pipeline inside the webview.

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

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 583a7eea876751664955c63190966bc6ec5f4bef parent 1ca0c3d
6 files changed, +76 −19
  • ios/LChat/Views/ChatView.swift +26 −10
  • ios/LChat/Views/NativeChatScreen.swift +4 −1
  • ios/LChatCore/Sources/LChatCore/AppDefaultCommand.swift +12 −1
  • ios/LChatCore/Sources/LChatCore/ComposerTypeahead.swift +9 −6
  • ios/LChatCore/Tests/LChatCoreTests/ComposerTypeaheadTests.swift +19 −0
  • ios/LChatMac/NativeFeedView.swift +6 −1
diff --git a/ios/LChat/Views/ChatView.swift b/ios/LChat/Views/ChatView.swift
index c32b415..cbf9805 100644
--- a/ios/LChat/Views/ChatView.swift
+++ b/ios/LChat/Views/ChatView.swift
@@ -296,8 +296,11 @@ struct ChatView: View {
}
let members = Set(thread.members)
let hasBot = store.roster.contains { members.contains($0.user_id) && $0.isAgent }
+ // App-local verbs (/add in To-do) lead the strip, bot or no bot — the
+ // web's commandCatalog(threadId). The webview runs whatever's picked.
let items = typeaheadItems(token, roster: store.roster,
- myUserId: store.myUserId, hasBot: hasBot)
+ myUserId: store.myUserId, hasBot: hasBot,
+ appCommands: AppDefaultCommand.localCommands(appID: thread.appId))
nativeTypeahead = items.isEmpty ? nil : (token, items)
}
@@ -406,24 +409,37 @@ struct ChatView: View {
.padding(.top, 8)
}
+ // The thread's default command (To-do: plain text adds a to-do). The web
+ // pipeline inside the webview does the actual routing (sendText → the
+ // composer submit path) — this only shapes the NATIVE chrome around it:
+ // the placeholder advertises the routing and the attach button goes
+ // (text-only surface), mirroring NativeChatScreen and the Mac.
+ private var defaultCommand: AppDefaultCommand.Spec? {
+ let members = Set(thread.members)
+ let hasBot = store.roster.contains { members.contains($0.user_id) && $0.isAgent }
+ return AppDefaultCommand.spec(appID: thread.appId, hasBot: hasBot)
+ }
+
private var composerRow: some View {
HStack(alignment: .bottom, spacing: 8) {
- Menu {
- Button { presentAfterMenu { showPhotos = true } } label: { Label("Photo Library", systemImage: "photo") }
- } label: {
- Image(systemName: "plus")
- .font(.system(size: 18, weight: .medium))
- .frame(width: 36, height: 36)
- .floatingGlass(in: Circle())
+ if defaultCommand == nil {
+ Menu {
+ Button { presentAfterMenu { showPhotos = true } } label: { Label("Photo Library", systemImage: "photo") }
+ } label: {
+ Image(systemName: "plus")
+ .font(.system(size: 18, weight: .medium))
+ .frame(width: 36, height: 36)
+ .floatingGlass(in: Circle())
+ }
+ .buttonStyle(.plain)
}
- .buttonStyle(.plain)
HStack(alignment: .bottom, spacing: 6) {
ComposerField(
text: $text,
focused: $composerFocused,
caret: $caret,
- placeholder: "Message",
+ placeholder: defaultCommand?.placeholder ?? "Message",
sendOnReturn: isMac,
onSend: { if canSend { send() } }
)
diff --git a/ios/LChat/Views/NativeChatScreen.swift b/ios/LChat/Views/NativeChatScreen.swift
index 168e3f8..18f1364 100644
--- a/ios/LChat/Views/NativeChatScreen.swift
+++ b/ios/LChat/Views/NativeChatScreen.swift
@@ -838,8 +838,11 @@ struct NativeChatScreen: View {
nativeTypeahead = nil
return
}
+ // App-local verbs (/add in To-do) lead the strip, bot or no bot —
+ // the web's commandCatalog(threadId).
let items = typeaheadItems(token, roster: store.roster,
- myUserId: store.myUserId, hasBot: hasBot)
+ myUserId: store.myUserId, hasBot: hasBot,
+ appCommands: AppDefaultCommand.localCommands(appID: thread.appId))
nativeTypeahead = items.isEmpty ? nil : (token, items)
}
diff --git a/ios/LChatCore/Sources/LChatCore/AppDefaultCommand.swift b/ios/LChatCore/Sources/LChatCore/AppDefaultCommand.swift
index c3e40bf..0bb2fa4 100644
--- a/ios/LChatCore/Sources/LChatCore/AppDefaultCommand.swift
+++ b/ios/LChatCore/Sources/LChatCore/AppDefaultCommand.swift
@@ -15,12 +15,14 @@ public enum AppDefaultCommand {
// 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
+ public let desc: String // the local verb's typeahead one-liner ("Add a to-do")
}
// 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", verbs: ["/add", "/todo"], placeholder: "Add a to-do…"),
+ "todo": Spec(eventType: "todo.add", verbs: ["/add", "/todo"],
+ placeholder: "Add a to-do…", desc: "Add a to-do"),
]
/// The default-command spec for a thread, or nil when the rule is off:
@@ -30,6 +32,15 @@ public enum AppDefaultCommand {
return specs[appID]
}
+ /// The app-local commands the "/" typeahead lists inside `appID`'s own
+ /// thread — the native slice of the web's commandCatalog(threadId), where
+ /// local verbs lead and the app's global alias drops out as redundant.
+ /// Unlike spec(), NOT bot-gated: the verb works in the thread either way.
+ public static func localCommands(appID: String?) -> [(name: String, desc: String)] {
+ guard let appID, let spec = specs[appID], let verb = spec.verbs.first else { return [] }
+ return [(name: verb, desc: spec.desc)]
+ }
+
/// 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
diff --git a/ios/LChatCore/Sources/LChatCore/ComposerTypeahead.swift b/ios/LChatCore/Sources/LChatCore/ComposerTypeahead.swift
index 9682c8e..1d6e462 100644
--- a/ios/LChatCore/Sources/LChatCore/ComposerTypeahead.swift
+++ b/ios/LChatCore/Sources/LChatCore/ComposerTypeahead.swift
@@ -77,16 +77,19 @@ public enum TypeaheadItem: Equatable, Identifiable {
/// shell.js typeaheadItems: candidates for a token, ranked identically to the
/// web (top 8; commands prefix-matched and bot-gated; mentions substring-matched
-/// against the roster minus yourself). The native catalog is BOT_COMMANDS only —
-/// the bot answers those server-side, so they work without a webview; the
-/// client-owned mini-app commands (/todo, /remind) come with the app layer.
+/// against the roster minus yourself). The command catalog is `appCommands` —
+/// the thread's app-local verbs (web commandCatalog(threadId): they lead the
+/// list, bot or no bot; see AppDefaultCommand.localCommands) — then
+/// BOT_COMMANDS, which need a bot in the thread to answer them. The rest of the
+/// web's client-owned commands (/todo, /remind, /help…) still come with the app
+/// layer.
public func typeaheadItems(_ token: ComposerToken, roster: [RosterEntry],
- myUserId: String, hasBot: Bool) -> [TypeaheadItem] {
+ myUserId: String, hasBot: Bool,
+ appCommands: [(name: String, desc: String)] = []) -> [TypeaheadItem] {
switch token.kind {
case .command:
- guard hasBot else { return [] }
let q = token.query.lowercased()
- return botCommands
+ return (appCommands + (hasBot ? botCommands : []))
.filter { $0.name.dropFirst().lowercased().hasPrefix(q) }
.prefix(8)
.map { .command(name: $0.name, desc: $0.desc) }
diff --git a/ios/LChatCore/Tests/LChatCoreTests/ComposerTypeaheadTests.swift b/ios/LChatCore/Tests/LChatCoreTests/ComposerTypeaheadTests.swift
index 9c765f7..44c762a 100644
--- a/ios/LChatCore/Tests/LChatCoreTests/ComposerTypeaheadTests.swift
+++ b/ios/LChatCore/Tests/LChatCoreTests/ComposerTypeaheadTests.swift
@@ -117,4 +117,23 @@ final class ComposerTypeaheadTests: XCTestCase {
["/theme ", "/themes "])
XCTAssertTrue(typeaheadItems(tok, roster: roster, myUserId: "u2", hasBot: false).isEmpty)
}
+
+ // App-local verbs (web commandCatalog(threadId)): they show in the owning
+ // thread with or without a bot, lead the list, and prefix-filter like the rest.
+ func testAppCommandsListedWithoutBotAndLeadWithOne() {
+ let add = AppDefaultCommand.localCommands(appID: "todo")
+ let bare = ComposerToken(kind: .command, query: "", start: 0)
+ XCTAssertEqual(typeaheadItems(bare, roster: roster, myUserId: "u2", hasBot: false,
+ appCommands: add).map(\.insert),
+ ["/add "], "no bot → the local verb still shows, alone")
+ XCTAssertEqual(typeaheadItems(bare, roster: roster, myUserId: "u2", hasBot: true,
+ appCommands: add).map(\.insert).first,
+ "/add ", "with a bot the local verb leads the catalog")
+ let miss = ComposerToken(kind: .command, query: "the", start: 0)
+ XCTAssertTrue(typeaheadItems(miss, roster: roster, myUserId: "u2", hasBot: false,
+ appCommands: add).isEmpty,
+ "prefix filtering applies to app commands too")
+ XCTAssertEqual(AppDefaultCommand.localCommands(appID: nil).count, 0)
+ XCTAssertEqual(AppDefaultCommand.localCommands(appID: "remind").count, 0)
+ }
}
diff --git a/ios/LChatMac/NativeFeedView.swift b/ios/LChatMac/NativeFeedView.swift
index 1f9b39f..e024404 100644
--- a/ios/LChatMac/NativeFeedView.swift
+++ b/ios/LChatMac/NativeFeedView.swift
@@ -60,10 +60,14 @@ struct NativeFeedView: View {
// 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)
+ // The thread's app-local verbs for the "/" typeahead (/add in To-do) —
+ // shown bot or no bot, like the web's commandCatalog(threadId).
+ self.appCommands = AppDefaultCommand.localCommands(appID: thread.appId)
self.threadID = thread.id
}
private let defaultCommand: AppDefaultCommand.Spec?
+ private let appCommands: [(name: String, desc: String)]
private let threadID: String
@@ -607,7 +611,8 @@ struct NativeFeedView: View {
// ComposerField pattern) — container work.
taToken = composerToken(text)
taItems = taToken.map {
- typeaheadItems($0, roster: roster, myUserId: myUserId, hasBot: hasBot)
+ typeaheadItems($0, roster: roster, myUserId: myUserId, hasBot: hasBot,
+ appCommands: appCommands)
} ?? []
taIndex = 0
// The typing beats: alive while there's a draft, an immediate