eleven / ocho Public

web,mac,ios: app rows blurb before first use; Connected services → Integrations (#333)

An app thread with no activity yet showed a bare, vertically-centred title.
Now it shows a one-line blurb ("Create a to-do list", "Connect third-party
services", …) as its sidebar subtitle, so it reads like every other row and
tells you what it's for. The blurb lives on each app's definition
(web/static/apps/*.js) and is mirrored in ThreadStore.appDescriptions for the
native list; the row layout already top-aligns the moment there's a subtitle.

Also standardises app identity across surfaces:
- Renames the `connect` app "Connected services" → "Integrations" (title, its
  render header, the /connect reply + button labels). Built-in titles now come
  from the catalog on native too — matching the web, where displayName() already
  reads the catalog title — so the rename reaches EXISTING threads, not just new
  ones.
- Fills in the native glyph table for `connect` (🔌) and `dnsimple` (🌐), which
  were falling back to a generic grid (▦) and mismatching the web faces.

web: shell.js sidebar + apps/*.js blurbs. native: ThreadStore catalog +
description fallback (shared, so Mac and iOS both get it).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Paul Campbell 99c5c88faa5e494175aadcc8ec1e956ed81dac22 parent 692bd38
10 files changed, +44 −9
  • changelog/2026-07-07-integrations-and-app-blurbs.md +1 −0
  • ios/LChatCore/Sources/LChatCore/ThreadStore.swift +24 −2
  • web/static/apps/board.js +1 −0
  • web/static/apps/bookmarks.js +1 −0
  • web/static/apps/connect.js +7 −4
  • web/static/apps/dnsimple.js +1 −1
  • web/static/apps/poll.js +1 −0
  • web/static/apps/remind.js +1 −0
  • web/static/apps/todo.js +1 −0
  • web/static/shell.js +6 −2
diff --git a/changelog/2026-07-07-integrations-and-app-blurbs.md b/changelog/2026-07-07-integrations-and-app-blurbs.md
new file mode 100644
index 0000000..acb4a68
--- /dev/null
+++ b/changelog/2026-07-07-integrations-and-app-blurbs.md
@@ -0,0 +1 @@
+🤖 Apps you haven't opened yet now introduce themselves — the chat list shows a one-line hint like "Create a to-do list" instead of just a bare name. And the **Connected services** app is now called **Integrations**.
diff --git a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift
index f4d122a..0ae2c88 100644
--- a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift
+++ b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift
@@ -144,6 +144,12 @@ public final class ThreadStore: ObservableObject {
lastMine = n == rosterLabels[myUserId]
}
}
+ // An app thread with no activity yet shows its blurb instead of a
+ // blank, vertically-centred title (the row layout top-aligns the
+ // moment there's a subtitle) — same intent as the web sidebar.
+ if preview.isEmpty, let app = appId(t, key: key), let blurb = Self.appDescriptions[app] {
+ preview = blurb
+ }
let lastAt = t.last_at ?? 0
// Park the key + decrypted title for the notification extension: a
// push's ciphertext is decrypted on-device against this store.
@@ -282,13 +288,25 @@ public final class ThreadStore: ObservableObject {
private static let appTitles: [String: String] = [
"todo": "To-do", "remind": "Reminders", "poll": "Poll",
"bookmarks": "Bookmarks", "board": "Whiteboard", "chat": "Chat",
+ "connect": "Integrations", "dnsimple": "DNSimple", "admin": "Admin",
]
// Each app's emoji face — the sidebar/list avatar, mirroring the `glyph`
- // in web/static/apps/*.js (keep in sync like appTitles).
+ // in web/static/apps/*.js (keep in sync like appTitles). The web and native
+ // faces must match, so a service looks the same on every surface.
private static let appGlyphs: [String: String] = [
"todo": "✓", "remind": "⏰", "poll": "📊",
"bookmarks": "🔖", "board": "🎨", "chat": "💬", "admin": "🛠️",
+ "connect": "🔌", "dnsimple": "🌐",
+ ]
+
+ // A one-line "what you'd do here" blurb, shown as the sidebar subtitle for an
+ // app thread with no activity yet — instead of a lonely, vertically-centred
+ // title. Mirrors each app's `blurb` in web/static/apps/*.js (keep in sync).
+ private static let appDescriptions: [String: String] = [
+ "todo": "Create a to-do list", "remind": "Set a reminder",
+ "poll": "Start a poll", "bookmarks": "Save a bookmark",
+ "board": "Sketch on a whiteboard", "connect": "Connect third-party services",
]
/// The personal apps a picker can offer (New chat's APPS section) —
@@ -422,7 +440,11 @@ public final class ThreadStore: ObservableObject {
if let nb = t.name_blob, !nb.isEmpty, let key,
let p = try? key.decryptJSON(AppThreadPayload.self, blob: nb),
let app = p.app, !app.isEmpty {
- return p.title ?? Self.appTitles[app] ?? app.capitalized
+ // Built-in apps take their name from the catalog, so renaming an
+ // app (e.g. Connected services → Integrations) reaches existing
+ // threads too — matching the web, where displayName() also reads
+ // the catalog title. Unknown/custom apps keep the stored title.
+ return Self.appTitles[app] ?? p.title ?? app.capitalized
}
return "Notes"
case "dm":
diff --git a/web/static/apps/board.js b/web/static/apps/board.js
index 0e48547..3b932ba 100644
--- a/web/static/apps/board.js
+++ b/web/static/apps/board.js
@@ -45,6 +45,7 @@ export function sanitizePoints(points) {
export const app = defineApp({
name: "board",
title: "Whiteboard",
+ blurb: "Sketch on a whiteboard", // sidebar subtitle before first use (sync w/ native)
glyph: "🎨",
scope: "shared",
initialState: () => ({ open: false, strokes: [] }),
diff --git a/web/static/apps/bookmarks.js b/web/static/apps/bookmarks.js
index 0882a44..321f8b8 100644
--- a/web/static/apps/bookmarks.js
+++ b/web/static/apps/bookmarks.js
@@ -45,6 +45,7 @@ function linkText(url) {
export const app = defineApp({
name: "bookmarks",
title: "Bookmarks",
+ blurb: "Save a bookmark", // sidebar subtitle before first use (sync w/ native)
glyph: "🔖",
scope: "personal",
initialState: () => ({ items: [] }),
diff --git a/web/static/apps/connect.js b/web/static/apps/connect.js
index 18ecd28..5e166ac 100644
--- a/web/static/apps/connect.js
+++ b/web/static/apps/connect.js
@@ -1,4 +1,4 @@
-// Connected services (prototype) — docs/connected-services.md.
+// Integrations — the `connect` app (prototype) — docs/connected-services.md.
//
// A small surface to link a third-party account so a bot can act for you. You
// authorize on the provider's own site (OAuth PKCE) — no key is pasted, no
@@ -15,7 +15,10 @@ const unavailable = (ctx) => ctx.reply({ text: "Connecting services isn't availa
export const app = defineApp({
name: "connect",
- title: "Connected services",
+ title: "Integrations",
+ // A one-line blurb shown as the sidebar subtitle before the thread has any
+ // activity (mirrored natively in ThreadStore.appDescriptions — keep in sync).
+ blurb: "Connect third-party services",
glyph: "🔌",
scope: "personal",
initialState: () => ({}),
@@ -26,7 +29,7 @@ export const app = defineApp({
args: "",
run: (_args, ctx) => {
if (!ctx.host || !ctx.host.connected) return unavailable(ctx);
- ctx.reply({ text: "Open Connected services to link an account — you'll authorize on the provider's site, no key to paste.", actions: [{ label: "Open Connected services", run: ctx.open }] });
+ ctx.reply({ text: "Open Integrations to link an account — you'll authorize on the provider's site, no key to paste.", actions: [{ label: "Open Integrations", run: ctx.open }] });
},
},
},
@@ -38,7 +41,7 @@ export const app = defineApp({
const h = document.createElement("h2");
h.className = "connect-h";
- h.textContent = "Connected services";
+ h.textContent = "Integrations";
wrap.appendChild(h);
const sub = document.createElement("p");
diff --git a/web/static/apps/dnsimple.js b/web/static/apps/dnsimple.js
index 73fcbd6..1fd15bd 100644
--- a/web/static/apps/dnsimple.js
+++ b/web/static/apps/dnsimple.js
@@ -1,5 +1,5 @@
// The DNSimple companion app (docs/connected-services.md § companion apps). Once
-// you've linked DNSimple (the "Connected services" surface), this app "installs":
+// you've linked DNSimple (via the Integrations surface), this app "installs":
// it appears in New chat and opens as its own thread, where the Interface view
// lists your domains and the Chat view reads back as a conversation with it.
//
diff --git a/web/static/apps/poll.js b/web/static/apps/poll.js
index 459f1d9..24e0a07 100644
--- a/web/static/apps/poll.js
+++ b/web/static/apps/poll.js
@@ -42,6 +42,7 @@ export function pollSummary(poll) {
export const app = defineApp({
name: "poll",
title: "Poll",
+ blurb: "Start a poll", // sidebar subtitle before first use (sync w/ native)
glyph: "📊",
scope: "shared",
initialState: () => ({ polls: [] }),
diff --git a/web/static/apps/remind.js b/web/static/apps/remind.js
index 94fb26f..8864f23 100644
--- a/web/static/apps/remind.js
+++ b/web/static/apps/remind.js
@@ -174,6 +174,7 @@ function dismiss(ctx, item) {
export const app = defineApp({
name: "remind",
title: "Reminders",
+ blurb: "Set a reminder", // sidebar subtitle before first use (sync w/ native)
glyph: "⏰",
scope: "personal",
initialState: () => ({ items: [] }),
diff --git a/web/static/apps/todo.js b/web/static/apps/todo.js
index df81d61..024ca35 100644
--- a/web/static/apps/todo.js
+++ b/web/static/apps/todo.js
@@ -26,6 +26,7 @@ const TUCK_AFTER_MS = 25; // beat between the ring finishing and the fold-up sta
export const app = defineApp({
name: "todo",
title: "To-do",
+ blurb: "Create a to-do list", // sidebar subtitle before first use (sync w/ native)
glyph: "✓",
scope: "personal",
initialState: () => ({ items: [] }),
diff --git a/web/static/shell.js b/web/static/shell.js
index 8ef41d7..83252f0 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -1686,10 +1686,14 @@ function renderThreadList() {
name.appendChild(badge);
}
}
- if (threadPreview[t.id]) {
+ // An app thread with no activity yet falls back to its one-line blurb, so
+ // the name doesn't sit alone, vertically centred (matches the native rows).
+ const blurb = appName ? (appByName(appName) || {}).blurb : "";
+ const previewLine = threadPreview[t.id] || blurb;
+ if (previewLine) {
const prev = document.createElement("span");
prev.className = "thread-row-preview";
- prev.textContent = threadPreview[t.id];
+ prev.textContent = previewLine;
body.appendChild(prev);
}
row.appendChild(body);