eleven / ocho Public

mac: ⌘K jumps to chats and runs actions, not just servers (#273)

* mac: ⌘K jumps to chats and runs actions, not just servers

The command palette now asks the web layer what it can offer — the current
server's chats (pinned-then-recent, same order as the sidebar) and the
actions available right now (new chat, new group, invite someone, open
apps, add a device, theme, edit profile, leave) — over a new
lchatPaletteItems / lchatPaletteRun bridge in shell.js, so labels and
behaviour keep one source of truth in the web layer and native only draws
rows. Other servers are phrased as "Switch to <name>" (the current one is
represented by its chats), the placeholder is now "Jump to…", and the old
"New Chat…" row is "Connect to a new space…".

web: showNewChat takes an optional start mode so the palette lands straight
in the group/invite views, and a small Apps sheet (the New-chat sheet's
Apps section on its own, connected-service gate included) backs "Open
apps".

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

* mac: keep the ⌘K palette scrolled to and owning the keyboard

Two palette fixes: (1) walk-off-screen — scroll the highlighted row into
view with an explicit directional anchor (nil-anchor scrollTo is
unreliable on macOS): down pins to the bottom edge, up to the top, and
the ScrollView clamps so nothing moves until the highlight reaches an
edge. (2) focus loss — switching away and back re-focused the web
composer (ChatFeedWebView.focusComposer makes the webview first
responder), knocking the palette's field out. focusComposer now no-ops
while the palette is up, the palette re-asserts focus on app
re-activation, and takes first responder straight back if anything else
grabs it while visible.

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

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell a1c784477587a38936d057e7eec72fc4fa81f21d parent c118307
4 files changed, +364 −40
  • changelog/2026-07-06-mac-cmdk-palette.md +5 −0
  • ios/LChatMac/ChatFeedWebView.swift +6 −1
  • ios/LChatMac/RootView.swift +243 −37
  • web/static/shell.js +110 −2
diff --git a/changelog/2026-07-06-mac-cmdk-palette.md b/changelog/2026-07-06-mac-cmdk-palette.md
new file mode 100644
index 0000000..d21c4fe
--- /dev/null
+++ b/changelog/2026-07-06-mac-cmdk-palette.md
@@ -0,0 +1,5 @@
+🤖 On the Mac app, ⌘K now takes you anywhere: jump straight to any chat on the
+server you're on, or run a quick action — new chat, new group, invite someone,
+open apps, add a device, theme, edit profile — without reaching for the mouse.
+Other servers show as "Switch to …", and connecting a whole new space is still
+one row away.
diff --git a/ios/LChatMac/ChatFeedWebView.swift b/ios/LChatMac/ChatFeedWebView.swift
index 78e708a..d50a6ea 100644
--- a/ios/LChatMac/ChatFeedWebView.swift
+++ b/ios/LChatMac/ChatFeedWebView.swift
@@ -189,7 +189,12 @@ struct ChatFeedWebView: NSViewRepresentable {
// no composer is present.
func focusComposer() {
guard let web = app.activeWebView ?? webView else { return }
- DispatchQueue.main.async {
+ DispatchQueue.main.async { [app] in
+ // Never steal the keyboard from the ⌘K palette: re-activating the
+ // app (or finishing a file pick) while it's open must leave the
+ // palette driving — making the webview first responder here is
+ // exactly what knocked its field out.
+ guard !app.showPalette else { return }
web.window?.makeFirstResponder(web)
web.evaluateJavaScript(
"window.lchatFocusComposer && window.lchatFocusComposer()",
diff --git a/ios/LChatMac/RootView.swift b/ios/LChatMac/RootView.swift
index a000afe..2ceb306 100644
--- a/ios/LChatMac/RootView.swift
+++ b/ios/LChatMac/RootView.swift
@@ -117,28 +117,70 @@ struct LeftArrow: Shape {
}
}
-// ⌘K command palette (Tier 1): fuzzy-jump between servers, plus New Chat. Servers
-// are native state, so this needs no webview bridge — chats/threads live in the web
-// layer and are a later tier. Drawn as an overlay (like the rail tooltip) so it sits
-// above the WKWebView feed; type to filter, ↑/↓ to move, ↩ to open, esc to dismiss.
+// What the web layer offers the palette (window.lchatPaletteItems in shell.js):
+// the current server's chats plus the actions available to this member right now.
+private struct PaletteChat: Decodable, Identifiable {
+ let id: String
+ let name: String
+ let glyph: String? // set for an app-thread (To-do, …); nil = person/group
+ let active: Bool
+}
+
+private struct PaletteAction: Decodable, Identifiable {
+ let id: String
+ let label: String
+ var danger: Bool?
+}
+
+private struct PaletteWebItems: Decodable {
+ var chats: [PaletteChat]
+ var actions: [PaletteAction]
+}
+
+// One flat, keyboard-navigable list mixing all four row kinds.
+private enum PaletteRow: Identifiable {
+ case chat(PaletteChat)
+ case server(SavedChat)
+ case action(PaletteAction)
+ case connect
+ var id: String {
+ switch self {
+ case .chat(let c): return "chat:" + c.id
+ case .server(let s): return "server:" + s.id
+ case .action(let a): return "action:" + a.id
+ case .connect: return "connect"
+ }
+ }
+}
+
+// ⌘K command palette: jump to any chat on the current server, run an action
+// (new chat, invite someone, theme, …), or switch to another server. Servers are
+// native state; the chats and actions come from the web layer over the
+// lchatPaletteItems / lchatPaletteRun bridge (shell.js) — labels and behaviour
+// keep one source of truth there, native only draws rows and posts the pick
+// back. Drawn as an overlay (like the rail tooltip) so it sits above the
+// WKWebView feed; type to filter, ↑/↓ to move, ↩ to open, esc to dismiss.
struct CommandPalette: View {
@EnvironmentObject var app: MacAppState
@State private var query = ""
@State private var index = 0
+ @State private var web = PaletteWebItems(chats: [], actions: [])
@FocusState private var focused: Bool
- // Recency rank for a chat (0 = most recently opened); unopened sort last.
+ // Recency rank for a server (0 = most recently opened); unopened sort last.
private func recencyRank(_ chat: SavedChat) -> Int {
app.recent.firstIndex(of: chat.id) ?? Int.max
}
+ // Other servers, phrased as a switch ("Switch to Paul's team") — the one
+ // you're on is represented by its chats above, so it isn't offered again.
// With no query: most-recently-opened first, then any never-opened in saved
- // order. With a query: fuzzy subsequence match on the friendly name or host,
- // best score first, ties broken by recency — so "cfc" finds "Campbell family
- // chat", and among comparable matches the one used most recently wins.
- private var matches: [SavedChat] {
- let q = query.filter { !$0.isWhitespace }
- let ranked = app.chats.enumerated().map {
+ // order. With a query: fuzzy subsequence match on the row label or host, best
+ // score first, ties broken by recency — so "cfc" (or "switch") finds
+ // "Switch to Campbell family chat", and among comparable matches the one
+ // used most recently wins.
+ private func serverMatches(_ q: String) -> [SavedChat] {
+ let ranked = app.chats.filter { $0.id != app.selection }.enumerated().map {
(chat: $0.element, order: $0.offset, recency: recencyRank($0.element))
}
if q.isEmpty {
@@ -148,7 +190,7 @@ struct CommandPalette: View {
}
return ranked
.compactMap { r -> (chat: SavedChat, score: Int, order: Int, recency: Int)? in
- let scores = [fuzzyScore(q, app.serverName(r.chat)),
+ let scores = [fuzzyScore(q, "Switch to " + app.serverName(r.chat)),
fuzzyScore(q, r.chat.origin?.host ?? "")].compactMap { $0 }
guard let best = scores.max() else { return nil }
return (r.chat, best, r.order, r.recency)
@@ -161,6 +203,33 @@ struct CommandPalette: View {
.map(\.chat)
}
+ // The full list, in section order: chats on this server (the web layer already
+ // sorted them pinned-then-recent, capped when idle so servers and actions stay
+ // in reach), other servers, actions, and "Connect to a new space…" pinned last.
+ private var rows: [PaletteRow] {
+ let q = query.filter { !$0.isWhitespace }
+ var out: [PaletteRow] = []
+ if q.isEmpty {
+ out += web.chats.prefix(6).map { .chat($0) }
+ } else {
+ out += web.chats
+ .compactMap { c in fuzzyScore(q, c.name).map { (chat: c, score: $0) } }
+ .sorted { $0.score > $1.score }
+ .map { .chat($0.chat) }
+ }
+ out += serverMatches(q).map { .server($0) }
+ if q.isEmpty {
+ out += web.actions.map { .action($0) }
+ } else {
+ out += web.actions
+ .compactMap { a in fuzzyScore(q, a.label).map { (action: a, score: $0) } }
+ .sorted { $0.score > $1.score }
+ .map { .action($0.action) }
+ }
+ out.append(.connect)
+ return out
+ }
+
// Query chars must appear in `text` in order (case-insensitive). Word-boundary
// and contiguous matches score higher; nil when it isn't a subsequence at all.
private func fuzzyScore(_ query: String, _ text: String) -> Int? {
@@ -180,10 +249,10 @@ struct CommandPalette: View {
}
return qi == q.count ? score : nil
}
- private var rowCount: Int { matches.count + 1 } // servers + "New Chat…"
var body: some View {
let inits = app.initials()
+ let rows = self.rows
return ZStack(alignment: .top) {
Color.black.opacity(0.2).ignoresSafeArea()
.onTapGesture { close() }
@@ -193,7 +262,7 @@ struct CommandPalette: View {
Image(systemName: "magnifyingglass")
.font(.system(size: 18, weight: .medium))
.foregroundStyle(.secondary)
- TextField("Jump to a chat…", text: $query)
+ TextField("Jump to…", text: $query)
.textFieldStyle(.plain)
.font(.system(size: 21))
.focused($focused)
@@ -202,20 +271,28 @@ struct CommandPalette: View {
.padding(.horizontal, 20)
.padding(.vertical, 17)
Divider().opacity(0.5)
- ScrollView {
- VStack(spacing: 4) {
- ForEach(Array(matches.enumerated()), id: \.element.id) { i, chat in
- row(chat: chat, initial: inits[chat.id] ?? "?", highlighted: i == index) {
- app.selection = chat.id; close()
+ ScrollViewReader { proxy in
+ ScrollView {
+ VStack(spacing: 4) {
+ ForEach(Array(rows.enumerated()), id: \.element.id) { i, row in
+ paletteRow(row, inits: inits, highlighted: i == index)
+ .id(row.id)
}
}
- newChatRow(highlighted: index == matches.count) {
- close(); app.showAdd = true
+ .padding(8)
+ }
+ .frame(maxHeight: 340)
+ // Keep the keyboard highlight in view as ↑/↓ walk the list. An
+ // explicit directional anchor (not nil, which macOS sometimes
+ // ignores): moving down pins the row to the bottom edge, moving
+ // up to the top — the ScrollView clamps, so nothing moves until
+ // the highlight actually reaches an edge.
+ .onChange(of: index) { old, new in
+ if new < rows.count {
+ proxy.scrollTo(rows[new].id, anchor: new > old ? .bottom : .top)
}
}
- .padding(8)
}
- .frame(maxHeight: 340)
Divider().opacity(0.5)
// Footer hint bar, à la TinyStart's status line.
HStack(spacing: 16) {
@@ -233,18 +310,74 @@ struct CommandPalette: View {
.shadow(color: .black.opacity(0.35), radius: 30, y: 12)
.padding(.top, 80)
}
- .onAppear { index = 0; DispatchQueue.main.async { focused = true } }
+ .onAppear {
+ index = 0
+ loadWebItems()
+ DispatchQueue.main.async { focused = true }
+ }
+ // While the palette is up it owns the keyboard. If anything else grabs
+ // first responder (switching away and back re-focuses the web composer;
+ // see ChatFeedWebView.focusComposer, which also guards on showPalette),
+ // take it straight back — and re-assert on app re-activation too, since
+ // a focus lost to another app comes back to whoever asks.
+ .onChange(of: focused) { _, isFocused in
+ if !isFocused && app.showPalette {
+ DispatchQueue.main.async { focused = true }
+ }
+ }
+ .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
+ DispatchQueue.main.async { focused = true }
+ }
.onChange(of: query) { _, _ in index = 0 }
- .onKeyPress(.downArrow) { index = min(index + 1, rowCount - 1); return .handled }
+ .onKeyPress(.downArrow) { index = min(index + 1, rows.count - 1); return .handled }
.onKeyPress(.upArrow) { index = max(index - 1, 0); return .handled }
.onExitCommand { close() }
}
+ // Ask the selected instance's web layer what it can offer. Fails soft: with
+ // no webview (or a pre-join page, which answers null) the palette still
+ // switches servers and connects new ones — just no chat/action rows.
+ private func loadWebItems() {
+ guard let webView = app.activeWebView else { return }
+ webView.evaluateJavaScript(
+ "JSON.stringify(window.lchatPaletteItems ? window.lchatPaletteItems() : null)"
+ ) { result, _ in
+ guard let json = result as? String,
+ let data = json.data(using: .utf8),
+ let items = try? JSONDecoder().decode(PaletteWebItems.self, from: data) else { return }
+ web = items
+ }
+ }
+
+ // Post an activation back to the web layer, which runs the real handler.
+ private func runWeb(_ kind: String, _ id: String) {
+ guard let webView = app.activeWebView else { return }
+ let safe = id
+ .replacingOccurrences(of: "\\", with: "\\\\")
+ .replacingOccurrences(of: "'", with: "\\'")
+ webView.evaluateJavaScript(
+ "window.lchatPaletteRun && window.lchatPaletteRun('\(kind)', '\(safe)')",
+ completionHandler: nil)
+ }
+
private func activate() {
- if index < matches.count {
- app.selection = matches[index].id
+ let rows = self.rows
+ guard index < rows.count else { return }
+ pick(rows[index])
+ }
+
+ private func pick(_ row: PaletteRow) {
+ switch row {
+ case .chat(let c):
+ runWeb("chat", c.id)
close()
- } else {
+ case .server(let s):
+ app.selection = s.id
+ close()
+ case .action(let a):
+ runWeb("action", a.id)
+ close()
+ case .connect:
close()
app.showAdd = true
}
@@ -252,12 +385,45 @@ struct CommandPalette: View {
private func close() { app.showPalette = false; query = "" }
- // A server row, TinyStart-style: leading status dot, colour badge with the
- // instance initial, name + host, a highlight and a ↩ on the active row.
@ViewBuilder
- private func row(chat: SavedChat, initial: String, highlighted: Bool, action: @escaping () -> Void) -> some View {
- rowShell(highlighted: highlighted, action: action) {
- statusDot(current: app.selection == chat.id)
+ private func paletteRow(_ row: PaletteRow, inits: [String: String], highlighted: Bool) -> some View {
+ switch row {
+ case .chat(let c):
+ chatRow(c, highlighted: highlighted)
+ case .server(let s):
+ serverRow(s, initial: inits[s.id] ?? "?", highlighted: highlighted)
+ case .action(let a):
+ actionRow(a, highlighted: highlighted)
+ case .connect:
+ connectRow(highlighted: highlighted)
+ }
+ }
+
+ // A chat on the current server: initials badge coloured by thread id (or the
+ // app's glyph for an app-thread), accent dot on the one that's open.
+ @ViewBuilder
+ private func chatRow(_ c: PaletteChat, highlighted: Bool) -> some View {
+ rowShell(highlighted: highlighted, action: { pick(.chat(c)) }) {
+ statusDot(current: c.active)
+ Text(c.glyph ?? chatInitials(c.name))
+ .font(.system(size: 16, weight: .semibold))
+ .lineLimit(1).minimumScaleFactor(0.6)
+ .foregroundStyle(c.glyph == nil ? .white : .primary)
+ .frame(width: 38, height: 38)
+ .background(RoundedRectangle(cornerRadius: 10, style: .continuous)
+ .fill(c.glyph == nil
+ ? Color(hue: app.hue(for: c.id), saturation: 0.55, brightness: 0.78)
+ : Color.secondary.opacity(0.18)))
+ Text(c.name).font(.system(size: 16, weight: .semibold))
+ }
+ }
+
+ // Another server, TinyStart-style: colour badge with the instance initial,
+ // "Switch to <name>" + host.
+ @ViewBuilder
+ private func serverRow(_ chat: SavedChat, initial: String, highlighted: Bool) -> some View {
+ rowShell(highlighted: highlighted, action: { pick(.server(chat)) }) {
+ statusDot(current: false)
Text(initial)
.font(.system(size: 16, weight: .semibold))
.lineLimit(1).minimumScaleFactor(0.6)
@@ -266,7 +432,7 @@ struct CommandPalette: View {
.background(RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(Color(hue: app.hue(for: chat.id), saturation: 0.55, brightness: 0.78)))
VStack(alignment: .leading, spacing: 1) {
- Text(app.serverName(chat)).font(.system(size: 16, weight: .semibold))
+ Text("Switch to \(app.serverName(chat))").font(.system(size: 16, weight: .semibold))
if let host = chat.origin?.host, host != app.serverName(chat) {
Text(host).font(.system(size: 12)).foregroundStyle(.secondary)
}
@@ -275,15 +441,55 @@ struct CommandPalette: View {
}
@ViewBuilder
- private func newChatRow(highlighted: Bool, action: @escaping () -> Void) -> some View {
- rowShell(highlighted: highlighted, action: action) {
+ private func actionRow(_ a: PaletteAction, highlighted: Bool) -> some View {
+ rowShell(highlighted: highlighted, action: { pick(.action(a)) }) {
+ statusDot(current: false)
+ ZStack {
+ RoundedRectangle(cornerRadius: 10, style: .continuous).fill(.secondary.opacity(0.18))
+ Image(systemName: actionSymbol(a.id))
+ .font(.system(size: 15, weight: .semibold))
+ .foregroundStyle(a.danger == true ? AnyShapeStyle(.red) : AnyShapeStyle(.secondary))
+ }
+ .frame(width: 38, height: 38)
+ Text(a.label)
+ .font(.system(size: 16, weight: .semibold))
+ .foregroundStyle(a.danger == true ? AnyShapeStyle(.red) : AnyShapeStyle(.primary))
+ }
+ }
+
+ @ViewBuilder
+ private func connectRow(highlighted: Bool) -> some View {
+ rowShell(highlighted: highlighted, action: { pick(.connect) }) {
statusDot(current: false)
ZStack {
RoundedRectangle(cornerRadius: 10, style: .continuous).fill(.secondary.opacity(0.18))
Image(systemName: "plus").font(.system(size: 16, weight: .semibold)).foregroundStyle(.secondary)
}
.frame(width: 38, height: 38)
- Text("New Chat…").font(.system(size: 16, weight: .semibold)).foregroundStyle(.secondary)
+ Text("Connect to a new space…").font(.system(size: 16, weight: .semibold)).foregroundStyle(.secondary)
+ }
+ }
+
+ // Chat-name initials, mirroring MacAppState.initials(): one letter for a
+ // single-word name, first letters of the first two words otherwise.
+ private func chatInitials(_ name: String) -> String {
+ let ws = name.split { !($0.isLetter || $0.isNumber) }
+ func head(_ w: Substring?) -> String? { w?.first.map { String($0).uppercased() } }
+ guard let first = head(ws.first) else { return "?" }
+ return ws.count >= 2 ? first + (head(ws[1]) ?? "") : first
+ }
+
+ private func actionSymbol(_ id: String) -> String {
+ switch id {
+ case "new-chat": return "square.and.pencil"
+ case "new-group": return "person.2"
+ case "invite": return "person.badge.plus"
+ case "apps": return "square.grid.2x2"
+ case "add-device": return "laptopcomputer.and.iphone"
+ case "theme": return "paintpalette"
+ case "edit-profile": return "person.crop.circle"
+ case "leave": return "rectangle.portrait.and.arrow.right"
+ default: return "bolt"
}
}
diff --git a/web/static/shell.js b/web/static/shell.js
index 6e59f40..052538c 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -1058,6 +1058,58 @@ function focusChatComposer() {
}
window.lchatFocusComposer = focusChatComposer;
+// ⌘K palette bridge (Mac app). The native command palette asks the web layer
+// what it can offer — the chats on this server plus the actions available to
+// this member — and calls back with a pick. Labels and behaviour live HERE
+// (one source of truth, like the native-menu bridge); native only draws rows.
+// Returns null until the app has booted, so a join/onboarding page offers
+// nothing. Kept as data-out + id-back so no web internals leak across.
+window.lchatPaletteItems = () => {
+ if (!myUserId) return null;
+ // Same set + order as the sidebar: closed chats dropped, pinned first, then
+ // most-recent-first — so the palette's top hits mirror the list you know.
+ const recencyOf = (t) => t.last_at || t.created_at || 0;
+ const shown = pinnedFirst(
+ threads.filter((t) => !hidden[t.id]),
+ (t) => pinned[t.id],
+ recencyOf,
+ );
+ const chats = shown.map((t) => ({
+ id: t.id,
+ name: threadName[t.id] || "Chat",
+ glyph: threadApp[t.id] ? (appByName(threadApp[t.id]) || {}).glyph || "▦" : null,
+ active: t.id === activeId,
+ }));
+ // Only actions that would actually work right now: a group needs someone to
+ // add, invites need the admin's say-so, apps need at least one registered.
+ const others = roster.some((e) => e.user_id !== myUserId && e.public_key);
+ const actions = [{ id: "new-chat", label: "New chat" }];
+ if (others) actions.push({ id: "new-group", label: "New group" });
+ if (USER_INVITES) actions.push({ id: "invite", label: "Invite someone" });
+ if (personalApps().length) actions.push({ id: "apps", label: "Open apps" });
+ actions.push(
+ { id: "add-device", label: "Add a device" },
+ { id: "theme", label: "Theme" },
+ { id: "edit-profile", label: "Edit profile" },
+ { id: "leave", label: "Leave", danger: true },
+ );
+ return { chats, actions };
+};
+window.lchatPaletteRun = (kind, id) => {
+ if (kind === "chat") return void openThreadById(id);
+ const run = {
+ "new-chat": () => showNewChat(),
+ "new-group": () => showNewChat("group"),
+ invite: () => showNewChat("invite"),
+ apps: showApps,
+ "add-device": addDevice,
+ theme: showThemePicker,
+ "edit-profile": editProfile,
+ leave,
+ }[id];
+ if (run) run();
+};
+
// A one-line snippet for the chat list: "Name: text" in groups, just text in DMs.
function previewText(t, data) {
let body = (data.text || "").replace(/\s+/g, " ").trim();
@@ -4528,7 +4580,10 @@ function submitOnEnter(input, action) {
});
}
-function showNewChat() {
+// startMode ("group" | "invite") opens the sheet straight in that view — the ⌘K
+// palette's "New group" / "Invite someone" land there directly. Anything else
+// (including the click event the + button passes) starts at "people".
+function showNewChat(startMode) {
// Only joined connections can be picked for a new DM/group. Keyless entries are
// pending invitees who haven't joined yet — they already have their own chat.
const others = roster.filter((e) => e.user_id !== myUserId && e.public_key);
@@ -4783,7 +4838,60 @@ function showNewChat() {
card.appendChild(foot);
}
};
- render("people");
+ // Honour a requested start view only when it's usable, else fall back.
+ let start = "people";
+ if (startMode === "group" && others.length) start = "group";
+ if (startMode === "invite" && USER_INVITES) start = "invite";
+ render(start);
+}
+
+// Apps directory (⌘K → "Open apps"): the personal apps as a plain pick list —
+// tap one to open (or create on first use) its app-thread. Same rows the
+// New-chat sheet shows under "Apps", including the connected-service gate: a
+// `requiresService` app only lists once its service is linked, and stays hidden
+// until /api/connected answers (no flash of a not-yet-usable app).
+function showApps() {
+ const backdrop = modal();
+ const card = backdrop.querySelector(".sheet");
+ const head = document.createElement("div");
+ head.className = "sheet-head";
+ const h = document.createElement("h2");
+ h.textContent = "Apps";
+ head.appendChild(h);
+ card.appendChild(head);
+ const list = document.createElement("div");
+ list.className = "pick-list";
+ card.appendChild(list);
+ let connectedServices = null; // null = unknown yet; a Set once loaded
+ const paint = () => {
+ list.innerHTML = "";
+ for (const app of personalApps()) {
+ if (app.requiresService && !(connectedServices && connectedServices.has(app.requiresService)))
+ continue;
+ const b = document.createElement("button");
+ b.className = "list-row";
+ const g = document.createElement("span");
+ g.className = "avatar app-avatar";
+ g.textContent = app.glyph || "▦";
+ const nm = document.createElement("span");
+ nm.textContent = app.title || app.name;
+ b.append(g, nm);
+ b.onclick = async () => {
+ backdrop.remove();
+ const id = await resolveAppThread(app.name);
+ if (id) openThread(id);
+ };
+ list.appendChild(b);
+ }
+ };
+ fetch(`/api/connected?token=${encodeURIComponent(session.token)}`)
+ .then((r) => (r.ok ? r.json() : { services: [] }))
+ .then((j) => {
+ connectedServices = new Set(j.services || []);
+ paint();
+ })
+ .catch(() => {});
+ paint();
}
// createThread generates a fresh key, wraps it to every member (me + picked),