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>
6 files changed,
+76
−19
ios/LChat/Views/ChatView.swift+26 −10ios/LChat/Views/NativeChatScreen.swift+4 −1ios/LChatCore/Sources/LChatCore/AppDefaultCommand.swift+12 −1ios/LChatCore/Sources/LChatCore/ComposerTypeahead.swift+9 −6ios/LChatCore/Tests/LChatCoreTests/ComposerTypeaheadTests.swift+19 −0ios/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 |