Admin joins the new-chat list (web + native) (#710)
* web: Admin joins the new-chat list + palette for admins
The Admin app is system-scope (never in personalApps), so /admin was
effectively its only way in. Admins now get an Admin row in the New chat
sheet's Apps section (including the empty-roster state) and an "Open
admin" action in the palette — same resolveAppThread("admin") open as
every personal app row. Client-side role gate is a UI nicety only; the
app re-checks the role through its bridge.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* ios: Admin row in the iOS new-chat sheet and Mac popover
Plumb the roster role natively (RosterEntry.role, optional so older
servers still decode; ThreadStore.isAdmin reads my own entry) and give
admins an Admin row: iOS in NewChatView's top section beside Discover
groups, Mac in the popover's APPS section reusing appRow with the
shared 🛠️ glyph. Both open store.openOrCreateAppThread("admin") and
land in the thread the way picking a person does. No changelog entry
yet — native notes wait for the release that ships them.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
6 files changed,
+84
−5
changelog/2026-07-11-admin-in-new-chat.md+3 −0ios/LChat/Views/NewChatView.swift+35 −1ios/LChatCore/Sources/LChatCore/Models.swift+4 −0ios/LChatCore/Sources/LChatCore/ThreadStore.swift+8 −0ios/LChatMac/NewChatPopover.swift+9 −0web/static/shell.js+25 −4
diff --git a/changelog/2026-07-11-admin-in-new-chat.md b/changelog/2026-07-11-admin-in-new-chat.md| new file mode 100644 |
| index 0000000..c5e08cd |
| --- /dev/null |
| +++ b/changelog/2026-07-11-admin-in-new-chat.md |
| @@ -0,0 +1,3 @@ |
| +🤖 **Admins: the Admin panel now sits in your New chat list.** Open New chat |
| +and you'll find Admin under Apps (and in the ⌘K palette as "Open admin") — |
| +no need to remember the /admin command. Only admins see it. |
diff --git a/ios/LChat/Views/NewChatView.swift b/ios/LChat/Views/NewChatView.swift| index c04a2b6..401bd07 100644 |
| --- a/ios/LChat/Views/NewChatView.swift |
| +++ b/ios/LChat/Views/NewChatView.swift |
| @@ -11,7 +11,7 @@ struct NewChatView: View { |
| @Environment(\.dismiss) private var dismiss |
| let onCreated: (String) -> Void |
| - @State private var busyWith: String? // user id of the DM being created |
| + @State private var busyWith: String? // user id of the DM being created ("admin" = the Admin row) |
| @State private var error: String? |
| private var others: [RosterEntry] { store.roster.filter { $0.user_id != store.myUserId } } |
| @@ -55,6 +55,28 @@ struct NewChatView: View { |
| .foregroundStyle(Color.accentColor) |
| } |
| } |
| + // The Admin app, admins only (the roster carries our role; the |
| + // server re-checks it) — same open-the-app-thread action as the |
| + // web picker's Apps row, so /admin isn't the only way in. |
| + if store.isAdmin { |
| + Button { openAdmin() } label: { |
| + HStack { |
| + Label { |
| + Text("Admin").font(.body.weight(.medium)) |
| + } icon: { |
| + Image(systemName: "gearshape") |
| + .foregroundStyle(Color.accentColor) |
| + } |
| + Spacer() |
| + if busyWith == "admin" { ProgressView() } |
| + } |
| + .contentShape(Rectangle()) |
| + } |
| + .buttonStyle(.plain) |
| + .disabled(busyWith != nil) |
| + .accessibilityLabel("Admin") |
| + .accessibilityHint("Opens this space's admin panel") |
| + } |
| } |
| if others.isEmpty { |
| @@ -112,6 +134,18 @@ struct NewChatView: View { |
| } |
| } |
| + // Open (creating on first use) the Admin app-thread, then dismiss into it — |
| + // the same land-in-the-thread shape as picking a person. |
| + private func openAdmin() { |
| + busyWith = "admin" // an app id can't collide with a user id here |
| + error = nil |
| + Task { |
| + let id = await store.openOrCreateAppThread(app: "admin", title: "Admin") |
| + busyWith = nil |
| + if let id { finish(id) } else { error = store.loadError ?? "Couldn't open Admin." } |
| + } |
| + } |
| + |
| private func finish(_ id: String) { |
| onCreated(id) |
| dismiss() |
diff --git a/ios/LChatCore/Sources/LChatCore/Models.swift b/ios/LChatCore/Sources/LChatCore/Models.swift| index 81e8218..013cbc3 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/Models.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/Models.swift |
| @@ -99,6 +99,10 @@ public struct RosterEntry: Codable, Equatable { |
| // "online" | "away" | "offline" — the effective state the server stamps on |
| // the roster (presence.go); kept live between refreshes by "presence" frames. |
| public var presence: String? = nil |
| + // "admin" | "moderator" | "member" — who runs the space (db.go RosterEntry). |
| + // Optional so payloads from older servers still decode; gates UI only (the |
| + // server re-checks the role on every admin API call). |
| + public var role: String? = nil |
| public var isAgent: Bool { is_agent ?? false } |
| public var isAI: Bool { kind == "ai" } |
diff --git a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift| index 7817046..fa2b674 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift |
| @@ -47,6 +47,14 @@ public final class ThreadStore: ObservableObject { |
| /// member profile photos. nil until loaded / if we haven't been granted it. |
| public func instanceKey() -> String? { instanceKeyB64 } |
| + /// Whether this member holds the admin role — my own roster entry carries it. |
| + /// Gates admin-only UI (the Admin app's picker rows) as a nicety only: the |
| + /// server re-checks the role on every admin API call. False until the roster |
| + /// loads, so the rows simply appear with it. |
| + public var isAdmin: Bool { |
| + roster.first { $0.user_id == myUserId }?.role == "admin" |
| + } |
| + |
| /// The photo source for a roster member — their profile photo (instance-key |
| /// encrypted), or nil if they have none / we lack the key or address. For the |
| /// new-chat picker. Mirrors avatarFor in web/static/chat.js. |
diff --git a/ios/LChatMac/NewChatPopover.swift b/ios/LChatMac/NewChatPopover.swift| index 5567fd3..65b4007 100644 |
| --- a/ios/LChatMac/NewChatPopover.swift |
| +++ b/ios/LChatMac/NewChatPopover.swift |
| @@ -105,6 +105,15 @@ struct NewChatPopover: View { |
| ForEach(ThreadStore.personalApps, id: \.id) { appEntry in |
| appRow(appEntry) |
| } |
| + // The Admin app is system-scope (never in personalApps); |
| + // admins get its row here all the same — the discoverable |
| + // way in beyond /admin. Same open action, popover closes |
| + // into the thread via onCreated like every app row. |
| + if store.isAdmin { |
| + appRow(("admin", "Admin", "🛠️")) |
| + .accessibilityLabel("Admin") |
| + .accessibilityHint("Opens this space's admin panel") |
| + } |
| } else { |
| Text("SHARED APPS") |
| .font(.system(size: 10, weight: .semibold)) |
diff --git a/web/static/shell.js b/web/static/shell.js| index 2c69830..152b69f 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -1769,6 +1769,14 @@ function inviteAllowed() { |
| return !!(me && me.role === "admin"); |
| } |
| +// Whether THIS member holds the admin role — gates the Admin app's entry |
| +// points in the pickers. A UI nicety, not security: the Admin app re-checks |
| +// the role through its host bridge (/api/me) before doing anything. |
| +function isAdmin() { |
| + const me = roster.find((e) => e.user_id === myUserId); |
| + return !!(me && me.role === "admin"); |
| +} |
| + |
| // True when a composer holds unsent text — reload paths check this so picking up |
| // an update never eats someone's draft. Covers every mounted chat-app composer |
| // (Split view shows two) plus the legacy #text box. |
| @@ -1906,6 +1914,7 @@ window.lchatPaletteItems = () => { |
| actions.push({ id: "discover", label: "Discover groups" }); |
| if (inviteAllowed()) actions.push({ id: "invite", label: "Invite someone" }); |
| if (personalApps().length) actions.push({ id: "apps", label: "Open apps" }); |
| + if (isAdmin()) actions.push({ id: "admin", label: "Open admin" }); |
| actions.push( |
| { id: "add-device", label: "Add a device" }, |
| { id: "theme", label: "Theme" }, |
| @@ -1922,6 +1931,10 @@ window.lchatPaletteRun = (kind, id) => { |
| discover: showDiscover, |
| invite: () => showNewChat("invite"), |
| apps: showApps, |
| + admin: async () => { |
| + const id = await resolveAppThread("admin"); |
| + if (id) openThread(id); |
| + }, |
| "add-device": addDevice, |
| theme: showThemePicker, |
| "edit-profile": editProfile, |
| @@ -7343,6 +7356,11 @@ function showNewChat(startMode) { |
| (a.title || a.name).toLowerCase().includes(q) && |
| (!a.requiresService || (connectedServices && connectedServices.has(a.requiresService))), |
| ); |
| + // The Admin app is system-scope (deliberately absent from personalApps), but |
| + // admins get its row in the Apps section all the same — the discoverable way |
| + // in, beyond the /admin command. Null for everyone else. |
| + const adminApp = isAdmin() ? { name: "admin", title: "Admin", glyph: "🛠️" } : null; |
| + const adminMatches = (q) => !!adminApp && adminApp.title.toLowerCase().includes(q); |
| const addAppRow = (listEl, app) => { |
| const b = document.createElement("button"); |
| b.className = "list-row"; |
| @@ -7476,11 +7494,12 @@ const disc = document.createElement("button"); |
| card.appendChild(p); |
| } |
| const apps = matchingApps(""); |
| - if (apps.length) { |
| + if (apps.length || adminApp) { |
| const list = document.createElement("div"); |
| list.className = "pick-list"; |
| list.appendChild(pickerSection("Apps")); |
| for (const app of apps) addAppRow(list, app); |
| + if (adminApp) addAppRow(list, adminApp); |
| card.appendChild(list); |
| } |
| return; |
| @@ -7626,7 +7645,8 @@ const disc = document.createElement("button"); |
| const people = others.filter((e) => !e.is_agent && rosterMatches(e, q)); |
| const helpers = others.filter((e) => e.is_agent && rosterMatches(e, q)); |
| const apps = mode === "people" ? matchingApps(q) : []; |
| - if (!people.length && !helpers.length && !apps.length) { |
| + const showAdmin = mode === "people" && adminMatches(q); |
| + if (!people.length && !helpers.length && !apps.length && !showAdmin) { |
| const p = document.createElement("p"); |
| p.className = "sub"; |
| p.style.padding = "12px 28px"; |
| @@ -7634,12 +7654,13 @@ const disc = document.createElement("button"); |
| list.appendChild(p); |
| return; |
| } |
| - if (people.length && (helpers.length || apps.length)) list.appendChild(pickerSection("People")); |
| + if (people.length && (helpers.length || apps.length || showAdmin)) list.appendChild(pickerSection("People")); |
| for (const e of people) addRow(e); |
| if (helpers.length) list.appendChild(pickerSection("Helpers")); |
| for (const e of helpers) addRow(e); |
| - if (apps.length) list.appendChild(pickerSection("Apps")); |
| + if (apps.length || showAdmin) list.appendChild(pickerSection("Apps")); |
| for (const app of apps) addAppRow(list, app); |
| + if (showAdmin) addAppRow(list, adminApp); |
| }; |
| filter.addEventListener("input", renderList); |
| renderList(); |