From ff3d11d42dcee0cdabf4049fc6b3d8258eb6a961 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 04:35:52 +0100 Subject: [PATCH] feat(workspace): claudemesh me activity + dashboard parity ships v0.4.0 phase 4. final aggregating verb after this is me search (phase 5). api: GET /v1/me/activity returns topic messages across every mesh the user belongs to in a 24h default window (?since=iso override), excluding messages the caller authored themselves. "what is happening that i missed", capped at 200. cli (1.13.0): claudemesh me activity prints a condensed feed with mesh + topic + sender + relative timestamp + snippet (or [encrypted] for v2 ciphertext). web: /dashboard/activity clusters consecutive messages from the same topic into thread blocks for readability. sidebar gains an activity entry between notifications and invites. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/cli/package.json | 2 +- apps/cli/src/commands/me.ts | 80 +++++++ apps/cli/src/entrypoints/cli.ts | 13 +- .../dashboard/(user)/activity/page.tsx | 203 ++++++++++++++++++ .../app/[locale]/dashboard/(user)/layout.tsx | 5 + apps/web/src/config/paths.ts | 1 + docs/roadmap.md | 20 +- packages/api/src/modules/mesh/v1-router.ts | 108 ++++++++++ 8 files changed, 425 insertions(+), 7 deletions(-) create mode 100644 apps/web/src/app/[locale]/dashboard/(user)/activity/page.tsx diff --git a/apps/cli/package.json b/apps/cli/package.json index 91c0f66..99af540 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "claudemesh-cli", - "version": "1.12.0", + "version": "1.13.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 97bb5b8..cca3f6f 100644 --- a/apps/cli/src/commands/me.ts +++ b/apps/cli/src/commands/me.ts @@ -294,6 +294,86 @@ export async function runMeNotifications( ); } +interface WorkspaceActivity { + messageId: string; + topicId: string; + topicName: string; + meshId: string; + meshSlug: string; + meshName: string; + senderName: string; + senderMemberId: string; + snippet: string | null; + ciphertext: string | null; + bodyVersion: number; + createdAt: string; +} + +interface WorkspaceActivityResponse { + activity: WorkspaceActivity[]; + totals: { events: number }; +} + +export interface MeActivityFlags extends MeFlags { + since?: string; +} + +export async function runMeActivity(flags: MeActivityFlags): Promise { + return withRestKey( + { + meshSlug: flags.mesh ?? null, + purpose: "workspace-activity", + capabilities: ["read"], + }, + async ({ secret }) => { + const params = new URLSearchParams(); + if (flags.since) params.set("since", flags.since); + const path = + "/api/v1/me/activity" + + (params.toString() ? `?${params.toString()}` : ""); + const ws = await request({ + path, + token: secret, + }); + + if (flags.json) { + console.log(JSON.stringify(ws, null, 2)); + return EXIT.SUCCESS; + } + + render.section( + `${clay("activity")} — ${ws.totals.events} ${dim( + flags.since ? `since ${flags.since}` : "in the last 24h", + )}`, + ); + + if (ws.activity.length === 0) { + process.stdout.write(dim(" quiet — no activity in window\n")); + return EXIT.SUCCESS; + } + + const slugWidth = Math.max( + ...ws.activity.map((a) => a.meshSlug.length), + 6, + ); + + for (const a of ws.activity) { + const slug = dim(a.meshSlug.padEnd(slugWidth)); + const topic = cyan(`#${a.topicName}`); + const sender = a.senderName ?? "?"; + const ago = formatRelativeTime(a.createdAt); + const snippet = + a.snippet ?? (a.ciphertext ? dim("[encrypted]") : dim("[empty]")); + process.stdout.write( + ` ${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 0fddeb0..48f4f57 100644 --- a/apps/cli/src/entrypoints/cli.ts +++ b/apps/cli/src/entrypoints/cli.ts @@ -126,6 +126,7 @@ Topic (conversation scope, v0.2.0) 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 me activity cross-mesh recent messages [--since=ISO] claudemesh member list mesh roster with online state [--online] claudemesh notification list recent @-mentions of you [--since ] @@ -697,6 +698,14 @@ async function main(): Promise { since: flags.since as string | undefined, }), ); + } else if (sub === "activity") { + const { runMeActivity } = await import("~/commands/me.js"); + process.exit( + await runMeActivity({ + ...f, + since: flags.since as string | undefined, + }), + ); } else { console.error( "Usage: claudemesh me (cross-mesh overview)\n" + @@ -704,7 +713,9 @@ async function main(): Promise { " 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)", + " claudemesh me notifications --since=ISO (custom window)\n" + + " claudemesh me activity (recent messages, last 24h)\n" + + " claudemesh me activity --since=ISO (custom window)", ); process.exit(EXIT.INVALID_ARGS); } diff --git a/apps/web/src/app/[locale]/dashboard/(user)/activity/page.tsx b/apps/web/src/app/[locale]/dashboard/(user)/activity/page.tsx new file mode 100644 index 0000000..6bc18e1 --- /dev/null +++ b/apps/web/src/app/[locale]/dashboard/(user)/activity/page.tsx @@ -0,0 +1,203 @@ +import Link from "next/link"; + +import { db } from "@turbostarter/db/server"; +import { + mesh, + meshMember, + meshTopic, + meshTopicMessage, +} from "@turbostarter/db/schema/mesh"; +import { aliasedTable, and, desc, eq, gt, inArray, isNull, notInArray } 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: "Activity", + description: "Recent messages across every mesh, last 24 hours.", +}); + +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 ""; + } +}; + +export default async function WorkspaceActivityPage() { + const { user } = await getSession(); + if (!user) return null; + + const memberships = await db + .select({ memberId: meshMember.id, meshId: meshMember.meshId }) + .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 meshIds = memberships.map((m) => m.meshId); + const since = new Date(Date.now() - 24 * 60 * 60 * 1000); + + const senderMember = aliasedTable(meshMember, "sender_member"); + const rows = meshIds.length && myMemberIds.length + ? await db + .select({ + messageId: meshTopicMessage.id, + topicName: meshTopic.name, + meshId: meshTopic.meshId, + meshSlug: mesh.slug, + senderName: senderMember.displayName, + ciphertext: meshTopicMessage.ciphertext, + bodyVersion: meshTopicMessage.bodyVersion, + createdAt: meshTopicMessage.createdAt, + }) + .from(meshTopicMessage) + .innerJoin(meshTopic, eq(meshTopic.id, meshTopicMessage.topicId)) + .innerJoin(mesh, eq(mesh.id, meshTopic.meshId)) + .leftJoin( + senderMember, + eq(senderMember.id, meshTopicMessage.senderMemberId), + ) + .where( + and( + inArray(meshTopic.meshId, meshIds), + isNull(meshTopic.archivedAt), + gt(meshTopicMessage.createdAt, since), + notInArray(meshTopicMessage.senderMemberId, myMemberIds), + ), + ) + .orderBy(desc(meshTopicMessage.createdAt)) + .limit(200) + : []; + + const items = rows.map((r) => ({ + 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, + createdAt: r.createdAt.toISOString(), + })); + + // Group consecutive entries by mesh+topic so a chatty thread reads + // as a cluster rather than 20 identical headers. + const clusters: Array<{ meshId: string; meshSlug: string; topicName: string; messages: typeof items }> = []; + for (const m of items) { + const last = clusters[clusters.length - 1]; + if (last && last.meshId === m.meshId && last.topicName === m.topicName) { + last.messages.push(m); + } else { + clusters.push({ + meshId: m.meshId, + meshSlug: m.meshSlug, + topicName: m.topicName, + messages: [m], + }); + } + } + + return ( +
+
+
+
+ +

+ What you{" "} + missed. +

+
+ + +
+ + {items.length} + events + + + {clusters.length} + threads + + + 24h + window + +
+
+
+ + {clusters.length === 0 ? ( +

+ Quiet on every front. Nothing posted in the last 24 hours. +

+ ) : ( +
    + {clusters.map((c, ci) => ( + +
  • + + {c.meshSlug} + #{c.topicName} + {c.messages.length} msg{c.messages.length === 1 ? "" : "s"} + +
      + {c.messages.map((m) => ( +
    1. +
      + {m.senderName} + {formatRelative(m.createdAt)} +
      +

      + {m.encrypted + ? (encrypted) + : m.snippet || (empty)} +

      +
    2. + ))} +
    +
  • +
    + ))} +
+ )} +
+
+ ); +} diff --git a/apps/web/src/app/[locale]/dashboard/(user)/layout.tsx b/apps/web/src/app/[locale]/dashboard/(user)/layout.tsx index 4d7f52f..606d825 100644 --- a/apps/web/src/app/[locale]/dashboard/(user)/layout.tsx +++ b/apps/web/src/app/[locale]/dashboard/(user)/layout.tsx @@ -35,6 +35,11 @@ const menu = [ href: pathsConfig.dashboard.user.notifications, icon: Icons.Bell, }, + { + title: "activity", + href: pathsConfig.dashboard.user.activity, + icon: Icons.Activity, + }, { title: "invites", href: pathsConfig.dashboard.user.invites, diff --git a/apps/web/src/config/paths.ts b/apps/web/src/config/paths.ts index 7f67a9c..d211299 100644 --- a/apps/web/src/config/paths.ts +++ b/apps/web/src/config/paths.ts @@ -105,6 +105,7 @@ const pathsConfig = { }, topics: `${DASHBOARD_PREFIX}/topics`, notifications: `${DASHBOARD_PREFIX}/notifications`, + activity: `${DASHBOARD_PREFIX}/activity`, invites: `${DASHBOARD_PREFIX}/invites`, settings: { index: `${DASHBOARD_PREFIX}/settings`, diff --git a/docs/roadmap.md b/docs/roadmap.md index 70760f8..7a55d9a 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -264,11 +264,21 @@ level, or wire claudemesh to messaging surfaces beyond Claude Code. `/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.4.0 phase 4 — `claudemesh me activity` + dashboard + parity** — `GET /v1/me/activity` returns recent topic messages + across every joined mesh in a 24h default window + (`?since=ISO`), excluding messages the caller authored + themselves ("what's happening that I missed"). CLI verb prints + a condensed feed; web `/dashboard/activity` clusters + consecutive messages from the same topic into thread blocks + with sender + relative timestamp. *Shipped 2026-05-03 in CLI + v1.13.0.* +- **v0.4.0 phase 5 — `me search`** — final aggregating verb. + Cross-mesh full-text search across decrypted (v1) snippets + + topic names + sender names + memory entries. Default + aggregation rule for existing read verbs (`task list`, `state + list`, `memory recall`) when no `--mesh` is passed lands here + too. - **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 e7a41c9..0edfdbd 100644 --- a/packages/api/src/modules/mesh/v1-router.ts +++ b/packages/api/src/modules/mesh/v1-router.ts @@ -623,6 +623,114 @@ export const v1Router = new Hono() }); }) + // GET /v1/me/activity — cross-mesh recent message stream. + // + // Topic messages from any mesh the user belongs to in a 24-hour + // default window (?since=ISO override). Excludes messages the + // caller authored themselves — this is "what's happening that I + // missed", not a self-audit log. Returns sender + topic + mesh + // context plus a snippet (v1) or ciphertext (v2). Sorted desc by + // createdAt, capped at 200 rows. + .get("/me/activity", 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, meshId: meshMember.meshId }) + .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({ activity: [], totals: { events: 0 } }); + } + + const myMemberIds = memberships.map((m) => m.memberId); + const meshIds = memberships.map((m) => m.meshId); + const sinceParam = c.req.query("since"); + const sinceDate = sinceParam + ? new Date(sinceParam) + : new Date(Date.now() - 24 * 60 * 60 * 1000); + + const senderMember = aliasedTable(meshMember, "sender_member"); + const rows = await db + .select({ + messageId: meshTopicMessage.id, + topicId: meshTopicMessage.topicId, + topicName: meshTopic.name, + meshId: meshTopic.meshId, + meshSlug: mesh.slug, + meshName: mesh.name, + senderName: senderMember.displayName, + senderMemberId: meshTopicMessage.senderMemberId, + ciphertext: meshTopicMessage.ciphertext, + bodyVersion: meshTopicMessage.bodyVersion, + createdAt: meshTopicMessage.createdAt, + }) + .from(meshTopicMessage) + .innerJoin(meshTopic, eq(meshTopic.id, meshTopicMessage.topicId)) + .innerJoin(mesh, eq(mesh.id, meshTopic.meshId)) + .leftJoin( + senderMember, + eq(senderMember.id, meshTopicMessage.senderMemberId), + ) + .where( + and( + inArray(meshTopic.meshId, meshIds), + isNull(meshTopic.archivedAt), + gt(meshTopicMessage.createdAt, sinceDate), + notInArray(meshTopicMessage.senderMemberId, myMemberIds), + ), + ) + .orderBy(desc(meshTopicMessage.createdAt)) + .limit(200); + + const decode = (b64: string) => { + try { + return Buffer.from(b64, "base64").toString("utf-8"); + } catch { + return ""; + } + }; + + const activity = rows.map((r) => ({ + messageId: r.messageId, + topicId: r.topicId, + topicName: r.topicName, + meshId: r.meshId, + meshSlug: r.meshSlug, + meshName: r.meshName, + senderName: r.senderName ?? "?", + senderMemberId: r.senderMemberId, + snippet: + r.bodyVersion === 1 ? decode(r.ciphertext).slice(0, 240) : null, + ciphertext: r.bodyVersion === 2 ? r.ciphertext : null, + bodyVersion: r.bodyVersion, + createdAt: r.createdAt.toISOString(), + })); + + return c.json({ + activity, + totals: { events: activity.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