web+ios: Eleven's welcome mentions open groups — existence only, Browse chip (#714)
The finish step gains a conditional line + 'Browse open groups' chip when the directory holds something this member could actually join (joined rows don't count — default-join groups already sit in the sidebar; locked-level names stay sealed, so the mention is existence-only by design). Web: maybeStartWelcome asks /api/threads/open and hands startWelcome openGroups + an openDiscover hook (showDiscover). Native: the model computes the same flag from api.openThreads() for the welcome flow and the new openDiscover verb presents the shared DiscoverGroupsView (iOS sheet off ElevenChatScreen; Mac sheet off ElevenConversationPane, mirroring NativeChatView's). Flow twin regenerated; flow test added (18/18).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
9 files changed,
+98
−0
changelog/2026-07-11-welcome-open-groups.md+1 −0ios/LChat/Views/ElevenChatScreen.swift+12 −0ios/LChatCore/Sources/LChatCore/ElevenConversationModel.swift+15 −0ios/LChatCore/Sources/LChatCore/Flows/welcome.json+12 −0ios/LChatMac/ElevenConversationPane.swift+21 −0test/flow.test.mjs+15 −0web/static/apps/welcome.flow.js+7 −0web/static/apps/welcome.js+4 −0web/static/shell.js+11 −0
diff --git a/changelog/2026-07-11-welcome-open-groups.md b/changelog/2026-07-11-welcome-open-groups.md| new file mode 100644 |
| index 0000000..6f0fba7 |
| --- /dev/null |
| +++ b/changelog/2026-07-11-welcome-open-groups.md |
| @@ -0,0 +1 @@ |
| +🤖 Eleven now points new members at your open groups. If your space has open groups a newcomer could join, the welcome conversation ends with a mention and a "Browse open groups" button — straight into the directory. |
diff --git a/ios/LChat/Views/ElevenChatScreen.swift b/ios/LChat/Views/ElevenChatScreen.swift| index 07dd55e..5ed3935 100644 |
| --- a/ios/LChat/Views/ElevenChatScreen.swift |
| +++ b/ios/LChat/Views/ElevenChatScreen.swift |
| @@ -11,6 +11,7 @@ struct ElevenChatScreen: View { |
| @ObservedObject var store: ThreadStore |
| @Binding var path: [Route] |
| @State private var model: ElevenConversationModel? |
| + @State private var showDiscover = false |
| @ScaledMetric(relativeTo: .body) private var chatFontSize: CGFloat = 17 |
| @AppStorage("nativeTheme") private var themeName = "classic" |
| @@ -25,6 +26,16 @@ struct ElevenChatScreen: View { |
| } |
| .navigationTitle(thread.title) |
| .navigationBarTitleDisplayMode(.inline) |
| + .sheet(isPresented: $showDiscover) { |
| + // The community directory (shared view; web: showDiscover) — the |
| + // welcome finish's "Browse open groups" chip lands here. |
| + NavigationStack { |
| + DiscoverGroupsView(store: store) { id in |
| + showDiscover = false |
| + path.append(.thread(id)) |
| + } |
| + } |
| + } |
| .onAppear { |
| guard model == nil else { return } |
| let m = ElevenConversationCache.shared.model(for: instance, thread: thread, |
| @@ -37,6 +48,7 @@ struct ElevenChatScreen: View { |
| } |
| } |
| } |
| + m?.openDiscover = { showDiscover = true } |
| // Outgoing payloads stamp the SavedInstance's name — persist a |
| // flow rename, the way ServerSettingsSheet persists a manual one. |
| m?.onNameSet = { name in |
diff --git a/ios/LChatCore/Sources/LChatCore/ElevenConversationModel.swift b/ios/LChatCore/Sources/LChatCore/ElevenConversationModel.swift| index 3e9617f..334b7b8 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/ElevenConversationModel.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/ElevenConversationModel.swift |
| @@ -55,6 +55,9 @@ public final class ElevenConversationModel: ObservableObject { |
| /// The finish chip's Admin hop: open (creating if needed) the Admin app |
| /// thread — the host owns thread creation and navigation. |
| public var openAdmin: (() -> Void)? |
| + /// The welcome finish's "Browse open groups" chip — the host presents the |
| + /// Discover surface (the twin of the web's showDiscover). |
| + public var openDiscover: (() -> Void)? |
| /// The welcome flow's name step just published a label — the host must |
| /// also persist the name into the saved instance (outgoing payloads stamp |
| /// SavedInstance.screenName; without this a flow rename fixes the roster |
| @@ -114,6 +117,15 @@ public final class ElevenConversationModel: ObservableObject { |
| } |
| env.flags["inviterName"] = .string(inviter) |
| env.flags["hasPendingThread"] = .bool(pendingThreadId != nil) |
| + // Open groups (welcome only): existence, never names — a locked |
| + // level's name stays sealed by design. Joined rows don't count: |
| + // default-join groups already sit in the sidebar. |
| + if appId == "welcome" { |
| + let open = await api.openThreads() |
| + env.flags["openGroups"] = .bool(open.contains { row in |
| + !(row.joined ?? false) && ((row.open_tier ?? "").isEmpty || (row.eligible ?? false)) |
| + }) |
| + } |
| let name = await api.groupName() |
| env.space = ["name": .string(name), "nameSet": .bool(!name.isEmpty)] |
| env.live["bridge.myName"] = { [instance] in .string(instance.screenName) } |
| @@ -331,6 +343,9 @@ extension ElevenConversationModel: FlowIO { |
| case "openAdmin": |
| openAdmin?() |
| return .null |
| + case "openDiscover": |
| + openDiscover?() |
| + return .null |
| default: |
| return .null // unknown/web-only verbs are quiet no-ops natively |
| } |
diff --git a/ios/LChatCore/Sources/LChatCore/Flows/welcome.json b/ios/LChatCore/Sources/LChatCore/Flows/welcome.json| index 1381ec7..1cc8562 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/Flows/welcome.json |
| +++ b/ios/LChatCore/Sources/LChatCore/Flows/welcome.json |
| @@ -318,6 +318,10 @@ |
| "text": "No problem — you can add one anytime from your profile.\n\n" |
| }, |
| "That's it — you're all set. 🎉 Enjoy {space.name|this space}!", |
| + { |
| + "if": "openGroups", |
| + "text": "\n\nThis space also has open groups anyone here can join — have a look around whenever you like." |
| + }, |
| { |
| "if": "!accessBackedUp", |
| "text": "\n\nOne more reminder before you go: you haven't signed in yet, so your access to this space lives only in this browser — if it's cleared or lost, there's no way back in. Sign in any time and you're safe." |
| @@ -364,6 +368,14 @@ |
| "verb": "openPendingThread" |
| } |
| }, |
| + { |
| + "if": "openGroups", |
| + "label": "Browse open groups", |
| + "advancing": false, |
| + "effect": { |
| + "verb": "openDiscover" |
| + } |
| + }, |
| { |
| "if": "!native", |
| "label": "Take the tour", |
diff --git a/ios/LChatMac/ElevenConversationPane.swift b/ios/LChatMac/ElevenConversationPane.swift| index b58e0ac..6b3b90e 100644 |
| --- a/ios/LChatMac/ElevenConversationPane.swift |
| +++ b/ios/LChatMac/ElevenConversationPane.swift |
| @@ -11,6 +11,7 @@ struct ElevenConversationPane: View { |
| @Binding var selectedID: String? |
| @EnvironmentObject private var app: MacAppState |
| @State private var model: ElevenConversationModel? |
| + @State private var showDiscover = false |
| @AppStorage(ChatTextSize.key) private var chatTextSize = ChatTextSize.defaultSize |
| @AppStorage("nativeTheme") private var themeName = "classic" |
| @@ -23,6 +24,25 @@ struct ElevenConversationPane: View { |
| ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) |
| } |
| } |
| + .sheet(isPresented: $showDiscover) { |
| + // The community directory (shared view; web: showDiscover) — the |
| + // welcome finish's "Browse open groups" chip lands here. Mirrors |
| + // NativeChatView's Discover sheet. |
| + VStack(spacing: 0) { |
| + DiscoverGroupsView(store: store) { threadID in |
| + showDiscover = false |
| + selectedID = threadID |
| + } |
| + Divider() |
| + HStack { |
| + Spacer() |
| + Button("Done") { showDiscover = false } |
| + .keyboardShortcut(.cancelAction) |
| + } |
| + .padding(12) |
| + } |
| + .frame(minWidth: 420, minHeight: 420) |
| + } |
| .onAppear { |
| guard model == nil else { return } |
| let m = ElevenConversationCache.shared.model(for: instance, thread: thread, |
| @@ -35,6 +55,7 @@ struct ElevenConversationPane: View { |
| } |
| } |
| } |
| + m?.openDiscover = { showDiscover = true } |
| // Outgoing payloads stamp the SavedInstance's name — persist a |
| // flow rename, the way MacSettingsView persists a manual one. |
| m?.onNameSet = { name in |
diff --git a/test/flow.test.mjs b/test/flow.test.mjs| index 80dcc57..d1cec94 100644 |
| --- a/test/flow.test.mjs |
| +++ b/test/flow.test.mjs |
| @@ -446,6 +446,21 @@ await test("welcome: skip everything — nudge acks, photo skip disarms, nag on |
| assert.match(fin.text, /^No problem — you can add one anytime from your profile\./); |
| assert.match(fin.text, /One more reminder before you go/); |
| assert.deepEqual(h.lastChips.map((c) => c.label), ["Sign in", "Take the tour"], "the parting sign-in chip rides the finish"); |
| + // No open groups in this env — the mention and its chip stay hidden. |
| + assert.equal(/open groups/.test(fin.text), false); |
| +}); |
| + |
| +await test("welcome: open groups get a finish mention + a Browse chip", async () => { |
| + const h = harness({ enrolled: true, envExtra: welcomeEnv({ openGroups: true }) }); |
| + h.env.accessBackedUp = () => true; |
| + runFlow(WELCOME_FLOW, h.env, h.io); |
| + await tick(); |
| + await h.answer("Paul"); |
| + await h.tap("Skip for now"); // photo |
| + const fin = h.transcript.find((t) => t.extra.key === "q-finish"); |
| + assert.match(fin.text, /This space also has open groups anyone here can join/); |
| + await h.tap("Browse open groups"); |
| + assert.ok(h.verbLog.find((v) => v[0] === "openDiscover"), "the chip hands over to the directory"); |
| }); |
| await test("welcome: native embed — passkey step skipped, access secured by the sign-in, finish records completion", async () => { |
diff --git a/web/static/apps/welcome.flow.js b/web/static/apps/welcome.flow.js| index 88ea08d..558539c 100644 |
| --- a/web/static/apps/welcome.flow.js |
| +++ b/web/static/apps/welcome.flow.js |
| @@ -236,6 +236,12 @@ export default { |
| text: [ |
| { if: "vars.skippedPhoto", text: "No problem — you can add one anytime from your profile.\n\n" }, |
| "That's it — you're all set. 🎉 Enjoy {space.name|this space}!", |
| + // Open groups: existence only, never names — a locked level's name |
| + // stays sealed by design, and the directory is one tap away. |
| + { |
| + if: "openGroups", |
| + text: "\n\nThis space also has open groups anyone here can join — have a look around whenever you like.", |
| + }, |
| // The one honest parting warning for someone who chose "Not now": |
| // plain consequences, no mechanism talk, and the way out right there. |
| { |
| @@ -264,6 +270,7 @@ export default { |
| advancing: false, |
| effect: { verb: "openPendingThread" }, |
| }, |
| + { if: "openGroups", label: "Browse open groups", advancing: false, effect: { verb: "openDiscover" } }, |
| // The tour is the web's guided overlay — web-only for now. |
| { if: "!native", label: "Take the tour", advancing: false, effect: { verb: "startTour" } }, |
| ], |
diff --git a/web/static/apps/welcome.js b/web/static/apps/welcome.js| index 8dded43..aece623 100644 |
| --- a/web/static/apps/welcome.js |
| +++ b/web/static/apps/welcome.js |
| @@ -72,6 +72,10 @@ function makeVerb(env) { |
| return bridge.markWelcomeDone && bridge.markWelcomeDone(); |
| case "openPendingThread": |
| return env.openPendingThread && env.openPendingThread(); |
| + case "openDiscover": |
| + // "Browse open groups" on the finish step — the host supplies the |
| + // directory surface (web: showDiscover; native: the Discover sheet). |
| + return env.openDiscover && env.openDiscover(); |
| case "startTour": |
| // Reached by "Take the tour" at the end of the welcome — the user has |
| // just been talking to Eleven, so skip the tour's own "fancy a tour?" |
diff --git a/web/static/shell.js b/web/static/shell.js| index e1794e1..ac43f17 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -4517,6 +4517,15 @@ async function maybeStartWelcome() { |
| }; |
| } |
| const adminEntry = roster.find((e) => !e.is_agent && e.role === "admin"); |
| + // Open groups: Eleven's finish mentions the directory when there's |
| + // something this member could actually join — existence only, never names |
| + // (a locked level's name stays sealed by design). Joined rows don't count: |
| + // default-join groups are already in the sidebar. |
| + let openGroups = false; |
| + try { |
| + const r = await (await fetch(`/api/threads/open?token=${encodeURIComponent(session.token)}`)).json(); |
| + openGroups = (r.threads || []).some((t) => !t.joined && (!t.open_tier || t.eligible)); |
| + } catch {} |
| startWelcome({ |
| threadId: id, |
| live, |
| @@ -4524,6 +4533,8 @@ async function maybeStartWelcome() { |
| inviterName, |
| adminName: (adminEntry && adminEntry.label) || "", |
| openPendingThread, |
| + openGroups, |
| + openDiscover: () => showDiscover(), |
| typing: (on) => { |
| setNoticeTyping(id, "Eleven", on); |
| if (on) pinFeed(); |