From 43e429f2045a0a0699fd666c360a82a1fb875472 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= <35082514+alezmad@users.noreply.github.com> Date: Sun, 3 May 2026 02:35:57 +0100 Subject: [PATCH] feat(workspace): claudemesh me notifications + dashboard parity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ships v0.4.0 phase 3. api: GET /v1/me/notifications aggregates the mesh.notification table across every joined mesh in a 7-day window (?since=iso overrides, ?include=all surfaces already-read). returns sender + topic + mesh context plus a 240-char snippet for v1 plaintext messages or raw ciphertext for v2 (the dashboard topic-key cache decrypts client-side). cli (1.12.0): claudemesh me notifications — terse unread feed with @ dot, --all to include read, --since for custom window. web: /dashboard/notifications mirrors the cli view in card form, adds a notifications entry to the dashboard sidebar between topics and invites. each card links straight to the topic chat. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/cli/package.json | 2 +- apps/cli/src/commands/me.ts | 94 ++++++++ apps/cli/src/entrypoints/cli.ts | 19 +- .../app/[locale]/dashboard/(user)/layout.tsx | 5 + .../dashboard/(user)/notifications/page.tsx | 228 ++++++++++++++++++ apps/web/src/config/paths.ts | 1 + docs/roadmap.md | 19 +- packages/api/src/modules/mesh/v1-router.ts | 129 +++++++++- 8 files changed, 487 insertions(+), 10 deletions(-) create mode 100644 apps/web/src/app/[locale]/dashboard/(user)/notifications/page.tsx diff --git a/apps/cli/package.json b/apps/cli/package.json index 2c3ce6f..91c0f66 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "claudemesh-cli", - "version": "1.11.0", + "version": "1.12.0", "description": "Peer mesh for Claude Code sessions — CLI + MCP server.", "keywords": [ "claude-code", diff --git a/apps/cli/src/commands/me.ts b/apps/cli/src/commands/me.ts index a1a590f..97bb5b8 100644 --- a/apps/cli/src/commands/me.ts +++ b/apps/cli/src/commands/me.ts @@ -200,6 +200,100 @@ export async function runMeTopics(flags: MeTopicsFlags): Promise { ); } +interface WorkspaceNotification { + notificationId: string; + messageId: string; + topicId: string; + topicName: string; + meshId: string; + meshSlug: string; + meshName: string; + senderName: string | null; + snippet: string | null; + ciphertext: string | null; + bodyVersion: number; + read: boolean; + readAt: string | null; + createdAt: string; +} + +interface WorkspaceNotificationsResponse { + notifications: WorkspaceNotification[]; + totals: { unread: number; total: number }; +} + +export interface MeNotificationsFlags extends MeFlags { + all?: boolean; + since?: string; +} + +export async function runMeNotifications( + flags: MeNotificationsFlags, +): Promise { + return withRestKey( + { + meshSlug: flags.mesh ?? null, + purpose: "workspace-notifications", + capabilities: ["read"], + }, + async ({ secret }) => { + const params = new URLSearchParams(); + if (flags.all) params.set("include", "all"); + if (flags.since) params.set("since", flags.since); + const path = + "/api/v1/me/notifications" + + (params.toString() ? `?${params.toString()}` : ""); + const ws = await request({ + path, + token: secret, + }); + + if (flags.json) { + console.log(JSON.stringify(ws, null, 2)); + return EXIT.SUCCESS; + } + + const headerLabel = flags.all ? "@-mentions (all)" : "@-mentions (unread)"; + render.section( + `${clay(headerLabel)} — ${ws.totals.total} ${dim( + ws.totals.unread > 0 ? `· ${ws.totals.unread} unread` : "· nothing pending", + )}`, + ); + + if (ws.notifications.length === 0) { + process.stdout.write( + dim( + flags.all + ? " no @-mentions in window\n" + : " inbox zero — nothing waiting\n", + ), + ); + return EXIT.SUCCESS; + } + + const slugWidth = Math.max( + ...ws.notifications.map((n) => n.meshSlug.length), + 6, + ); + + for (const n of ws.notifications) { + const slug = dim(n.meshSlug.padEnd(slugWidth)); + const topic = cyan(`#${n.topicName}`); + const sender = n.senderName ? `from ${n.senderName}` : "from ?"; + const ago = formatRelativeTime(n.createdAt); + const dot = n.read ? dim("·") : yellow("●"); + const snippet = + n.snippet ?? (n.ciphertext ? dim("[encrypted]") : dim("[empty]")); + process.stdout.write( + ` ${dot} ${slug} ${topic} ${dim(sender)} ${dim(ago)}\n` + + ` ${snippet.length > 200 ? snippet.slice(0, 200) + "…" : snippet}\n`, + ); + } + return EXIT.SUCCESS; + }, + ); +} + function formatRelativeTime(iso: string): string { const then = new Date(iso).getTime(); const now = Date.now(); diff --git a/apps/cli/src/entrypoints/cli.ts b/apps/cli/src/entrypoints/cli.ts index 9d78638..0fddeb0 100644 --- a/apps/cli/src/entrypoints/cli.ts +++ b/apps/cli/src/entrypoints/cli.ts @@ -125,6 +125,7 @@ Topic (conversation scope, v0.2.0) claudemesh send "#topic" "msg" send to a topic (WS path, v1 plaintext) claudemesh me cross-mesh workspace overview (v0.4.0) claudemesh me topics cross-mesh topic list [--unread] + claudemesh me notifications cross-mesh @-mentions [--all] [--since=ISO] claudemesh member list mesh roster with online state [--online] claudemesh notification list recent @-mentions of you [--since ] @@ -687,11 +688,23 @@ async function main(): Promise { } else if (sub === "topics") { const { runMeTopics } = await import("~/commands/me.js"); process.exit(await runMeTopics({ ...f, unread: !!flags.unread })); + } else if (sub === "notifications" || sub === "notifs") { + const { runMeNotifications } = await import("~/commands/me.js"); + process.exit( + await runMeNotifications({ + ...f, + all: !!flags.all, + since: flags.since as string | undefined, + }), + ); } else { console.error( - "Usage: claudemesh me (cross-mesh overview)\n" + - " claudemesh me topics (cross-mesh topic list)\n" + - " claudemesh me topics --unread (only unread topics)", + "Usage: claudemesh me (cross-mesh overview)\n" + + " claudemesh me topics (cross-mesh topic list)\n" + + " claudemesh me topics --unread (only unread topics)\n" + + " claudemesh me notifications (unread @-mentions, last 7d)\n" + + " claudemesh me notifications --all (include already-read)\n" + + " claudemesh me notifications --since=ISO (custom window)", ); process.exit(EXIT.INVALID_ARGS); } diff --git a/apps/web/src/app/[locale]/dashboard/(user)/layout.tsx b/apps/web/src/app/[locale]/dashboard/(user)/layout.tsx index dd4138e..4d7f52f 100644 --- a/apps/web/src/app/[locale]/dashboard/(user)/layout.tsx +++ b/apps/web/src/app/[locale]/dashboard/(user)/layout.tsx @@ -30,6 +30,11 @@ const menu = [ href: pathsConfig.dashboard.user.topics, icon: Icons.MessageSquare, }, + { + title: "notifications", + href: pathsConfig.dashboard.user.notifications, + icon: Icons.Bell, + }, { title: "invites", href: pathsConfig.dashboard.user.invites, diff --git a/apps/web/src/app/[locale]/dashboard/(user)/notifications/page.tsx b/apps/web/src/app/[locale]/dashboard/(user)/notifications/page.tsx new file mode 100644 index 0000000..4972c2e --- /dev/null +++ b/apps/web/src/app/[locale]/dashboard/(user)/notifications/page.tsx @@ -0,0 +1,228 @@ +import Link from "next/link"; + +import { db } from "@turbostarter/db/server"; +import { + mesh, + meshMember, + meshNotification, + meshTopic, + meshTopicMessage, +} from "@turbostarter/db/schema/mesh"; +import { aliasedTable, and, desc, eq, gt, inArray, isNull } from "drizzle-orm"; + +import { pathsConfig } from "~/config/paths"; +import { getSession } from "~/lib/auth/server"; +import { getMetadata } from "~/lib/metadata"; +import { Reveal } from "~/modules/dashboard/universe/reveal"; + +export const generateMetadata = getMetadata({ + title: "Notifications", + description: "@-mentions across every mesh, last 7 days.", +}); + +const formatRelative = (iso: string) => { + const sec = Math.max(0, Math.floor((Date.now() - new Date(iso).getTime()) / 1000)); + if (sec < 60) return `${sec}s ago`; + if (sec < 3600) return `${Math.floor(sec / 60)}m ago`; + if (sec < 86_400) return `${Math.floor(sec / 3600)}h ago`; + if (sec < 86_400 * 30) return `${Math.floor(sec / 86_400)}d ago`; + if (sec < 86_400 * 365) return `${Math.floor(sec / (86_400 * 30))}mo ago`; + return `${Math.floor(sec / (86_400 * 365))}y ago`; +}; + +const decode = (b64: string) => { + try { + return Buffer.from(b64, "base64").toString("utf-8"); + } catch { + return ""; + } +}; + +interface PageProps { + searchParams: Promise<{ all?: string }>; +} + +export default async function WorkspaceNotificationsPage({ + searchParams, +}: PageProps) { + const { user } = await getSession(); + if (!user) return null; + + const params = await searchParams; + const includeAll = params.all === "1"; + + const memberships = await db + .select({ memberId: meshMember.id }) + .from(meshMember) + .innerJoin(mesh, eq(mesh.id, meshMember.meshId)) + .where( + and( + eq(meshMember.userId, user.id), + isNull(meshMember.revokedAt), + isNull(mesh.archivedAt), + ), + ); + + const myMemberIds = memberships.map((m) => m.memberId); + const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); + + const senderMember = aliasedTable(meshMember, "sender_member"); + const rows = myMemberIds.length + ? await db + .select({ + id: meshNotification.id, + messageId: meshTopicMessage.id, + topicName: meshTopic.name, + meshId: meshTopic.meshId, + meshSlug: mesh.slug, + senderName: senderMember.displayName, + ciphertext: meshTopicMessage.ciphertext, + bodyVersion: meshTopicMessage.bodyVersion, + readAt: meshNotification.readAt, + createdAt: meshTopicMessage.createdAt, + }) + .from(meshNotification) + .innerJoin( + meshTopicMessage, + eq(meshTopicMessage.id, meshNotification.messageId), + ) + .innerJoin(meshTopic, eq(meshTopic.id, meshNotification.topicId)) + .innerJoin(mesh, eq(mesh.id, meshTopic.meshId)) + .innerJoin( + senderMember, + eq(senderMember.id, meshNotification.senderMemberId), + ) + .where( + and( + inArray(meshNotification.recipientMemberId, myMemberIds), + isNull(meshTopic.archivedAt), + gt(meshTopicMessage.createdAt, since), + ...(includeAll ? [] : [isNull(meshNotification.readAt)]), + ), + ) + .orderBy(desc(meshTopicMessage.createdAt)) + .limit(100) + : []; + + const items = rows.map((r) => ({ + id: r.id, + messageId: r.messageId, + topicName: r.topicName, + meshId: r.meshId, + meshSlug: r.meshSlug, + senderName: r.senderName ?? "?", + snippet: r.bodyVersion === 1 ? decode(r.ciphertext).slice(0, 240) : null, + encrypted: r.bodyVersion === 2, + read: !!r.readAt, + createdAt: r.createdAt.toISOString(), + })); + + const unreadCount = items.filter((i) => !i.read).length; + + return ( +
+
+
+
+ +

+ Mentions,{" "} + on you. +

+
+ + +
+ + 0 ? "text-[var(--cm-clay)]" : "text-[var(--cm-fg)]"}`} + > + {unreadCount} + + unread + + + {items.length} + {includeAll ? "shown" : "in window"} + + + {includeAll ? "unread only" : "show all"} + +
+
+
+ + {items.length === 0 ? ( +

+ {includeAll + ? "No mentions in the last 7 days." + : "Inbox zero. Nothing waiting on you."} +

+ ) : ( +
    + {items.map((n, i) => ( + +
  • + +
    + {!n.read ? ( + + ) : null} + + {n.meshSlug} + + #{n.topicName} + + from {n.senderName} + + + {formatRelative(n.createdAt)} + +
    +

    + {n.encrypted + ? (encrypted — open the topic to decrypt) + : n.snippet || (empty)} +

    + +
  • +
    + ))} +
+ )} +
+
+ ); +} diff --git a/apps/web/src/config/paths.ts b/apps/web/src/config/paths.ts index f6c5c2f..7f67a9c 100644 --- a/apps/web/src/config/paths.ts +++ b/apps/web/src/config/paths.ts @@ -104,6 +104,7 @@ const pathsConfig = { `${DASHBOARD_PREFIX}/meshes/${id}/topics/${encodeURIComponent(name)}`, }, topics: `${DASHBOARD_PREFIX}/topics`, + notifications: `${DASHBOARD_PREFIX}/notifications`, invites: `${DASHBOARD_PREFIX}/invites`, settings: { index: `${DASHBOARD_PREFIX}/settings`, diff --git a/docs/roadmap.md b/docs/roadmap.md index 8bebdf3..70760f8 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -255,11 +255,20 @@ level, or wire claudemesh to messaging surfaces beyond Claude Code. matching `/dashboard/topics` page (SSR, direct DB) with a Topics entry in the sidebar between Meshes and Invites. *Shipped 2026-05-03 in CLI v1.11.0.* -- **v0.4.0 phase 3+ — `me notifications`, `me activity`, `me - search`** — additional aggregating verbs over `/v1/me/*` - mirroring the existing per-mesh reads. Default aggregation rule - for existing read verbs (`notification list`, `task list`, `state - list`, `memory recall`) when no `--mesh` is passed. +- **v0.4.0 phase 3 — `claudemesh me notifications` + dashboard + parity** — `GET /v1/me/notifications` aggregates @-mention rows + across every joined mesh in a 7-day window (`?since=ISO` + override, `?include=all` to surface already-read). CLI verb + prints unread feed with sender + topic + snippet (or + `[encrypted]` for v2 ciphertext). Web dashboard adds + `/dashboard/notifications` with a "show all" toggle, matching + the universe page's mention card aesthetic. *Shipped 2026-05-03 + in CLI v1.12.0.* +- **v0.4.0 phase 4+ — `me activity`, `me search`** — remaining + aggregating verbs over `/v1/me/*` mirroring the existing + per-mesh reads. Default aggregation rule for existing read verbs + (`task list`, `state list`, `memory recall`) when no `--mesh` is + passed. - **v0.3.2 — multi-session DM routing + broadcast self-loopback** — fixes two production bugs: (1) replies via `claudemesh send ` rejected with "no connected peer" when the sender's diff --git a/packages/api/src/modules/mesh/v1-router.ts b/packages/api/src/modules/mesh/v1-router.ts index 3f925c2..e7a41c9 100644 --- a/packages/api/src/modules/mesh/v1-router.ts +++ b/packages/api/src/modules/mesh/v1-router.ts @@ -39,7 +39,7 @@ import { messageQueue, presence, } from "@turbostarter/db/schema/mesh"; -import { and, asc, count, desc, eq, gt, inArray, isNull, lt, notInArray, sql } from "drizzle-orm"; +import { aliasedTable, and, asc, count, desc, eq, gt, inArray, isNull, lt, notInArray, sql } from "drizzle-orm"; import { validate } from "../../middleware"; import { @@ -496,6 +496,133 @@ export const v1Router = new Hono() }); }) + // GET /v1/me/notifications — cross-mesh @-mention feed. + // + // Returns recent unread notifications (default) or all notifications + // (?include=all) targeting the caller's member rows across every + // joined mesh. Each row carries mesh + topic + sender context plus a + // 240-char ciphertext-base64 snippet (clients decrypt under the + // topic key they already cached). 7-day window keeps the response + // bounded; use ?since= to override. + .get("/me/notifications", async (c) => { + const key = c.var.apiKey; + requireCapability(key, "read"); + if (!key.issuedByMemberId) { + return c.json({ error: "api_key_has_no_issuer" }, 400); + } + const [issuer] = await db + .select({ userId: meshMember.userId }) + .from(meshMember) + .where(eq(meshMember.id, key.issuedByMemberId)); + if (!issuer?.userId) { + return c.json({ error: "issuer_member_has_no_user" }, 400); + } + + const memberships = await db + .select({ memberId: meshMember.id }) + .from(meshMember) + .innerJoin(mesh, eq(mesh.id, meshMember.meshId)) + .where( + and( + eq(meshMember.userId, issuer.userId), + isNull(meshMember.revokedAt), + isNull(mesh.archivedAt), + ), + ); + + if (memberships.length === 0) { + return c.json({ + notifications: [], + totals: { unread: 0, total: 0 }, + }); + } + + const myMemberIds = memberships.map((m) => m.memberId); + const includeAll = c.req.query("include") === "all"; + const sinceParam = c.req.query("since"); + const sinceDate = sinceParam + ? new Date(sinceParam) + : new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); + + const senderMember = aliasedTable(meshMember, "sender_member"); + const where = and( + inArray(meshNotification.recipientMemberId, myMemberIds), + isNull(meshTopic.archivedAt), + gt(meshTopicMessage.createdAt, sinceDate), + ...(includeAll ? [] : [isNull(meshNotification.readAt)]), + ); + + const rows = await db + .select({ + notificationId: meshNotification.id, + messageId: meshTopicMessage.id, + topicId: meshTopicMessage.topicId, + topicName: meshTopic.name, + meshId: meshTopic.meshId, + meshSlug: mesh.slug, + meshName: mesh.name, + senderName: senderMember.displayName, + senderMemberId: senderMember.id, + ciphertext: meshTopicMessage.ciphertext, + bodyVersion: meshTopicMessage.bodyVersion, + readAt: meshNotification.readAt, + createdAt: meshTopicMessage.createdAt, + }) + .from(meshNotification) + .innerJoin( + meshTopicMessage, + eq(meshTopicMessage.id, meshNotification.messageId), + ) + .innerJoin(meshTopic, eq(meshTopic.id, meshNotification.topicId)) + .innerJoin(mesh, eq(mesh.id, meshTopic.meshId)) + .innerJoin( + senderMember, + eq(senderMember.id, meshNotification.senderMemberId), + ) + .where(where) + .orderBy(desc(meshTopicMessage.createdAt)) + .limit(100); + + const decode = (b64: string) => { + try { + return Buffer.from(b64, "base64").toString("utf-8"); + } catch { + return ""; + } + }; + + const notifications = rows.map((r) => ({ + notificationId: r.notificationId, + messageId: r.messageId, + topicId: r.topicId, + topicName: r.topicName, + meshId: r.meshId, + meshSlug: r.meshSlug, + meshName: r.meshName, + senderName: r.senderName, + // For v1 (plaintext-base64) messages, surface a decoded snippet so + // CLI/dashboard can render it without doing crypto. v2 messages + // ship ciphertext only — the client decrypts with the topic key. + snippet: + r.bodyVersion === 1 ? decode(r.ciphertext).slice(0, 240) : null, + ciphertext: r.bodyVersion === 2 ? r.ciphertext : null, + bodyVersion: r.bodyVersion, + read: !!r.readAt, + readAt: r.readAt ? r.readAt.toISOString() : null, + createdAt: r.createdAt.toISOString(), + })); + + const unreadCount = notifications.filter((n) => !n.read).length; + + return c.json({ + notifications, + totals: { + unread: unreadCount, + total: notifications.length, + }, + }); + }) + // GET /v1/me/topics — cross-mesh topic list for the caller's user. // // For each topic across every mesh the user belongs to, returns