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>
10 files changed,
+44
−9
changelog/2026-07-07-integrations-and-app-blurbs.md+1 −0ios/LChatCore/Sources/LChatCore/ThreadStore.swift+24 −2web/static/apps/board.js+1 −0web/static/apps/bookmarks.js+1 −0web/static/apps/connect.js+7 −4web/static/apps/dnsimple.js+1 −1web/static/apps/poll.js+1 −0web/static/apps/remind.js+1 −0web/static/apps/todo.js+1 −0web/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); |