diff --git a/apps/cli/package.json b/apps/cli/package.json index 510caef..2c3ce6f 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "claudemesh-cli", - "version": "1.10.0", + "version": "1.11.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 a8359fe..a1a590f 100644 --- a/apps/cli/src/commands/me.ts +++ b/apps/cli/src/commands/me.ts @@ -109,3 +109,106 @@ export async function runMe(flags: MeFlags): Promise { }, ); } + +interface WorkspaceTopic { + topicId: string; + name: string; + description: string | null; + visibility: string; + createdAt: string; + meshId: string; + meshSlug: string; + meshName: string; + memberId: string; + unread: number; + lastMessageAt: string | null; +} + +interface WorkspaceTopicsResponse { + topics: WorkspaceTopic[]; + totals: { topics: number; unread: number }; +} + +export interface MeTopicsFlags extends MeFlags { + unread?: boolean; +} + +export async function runMeTopics(flags: MeTopicsFlags): Promise { + return withRestKey( + { + meshSlug: flags.mesh ?? null, + purpose: "workspace-topics", + capabilities: ["read"], + }, + async ({ secret }) => { + const ws = await request({ + path: "/api/v1/me/topics", + token: secret, + }); + + const visible = flags.unread + ? ws.topics.filter((t) => t.unread > 0) + : ws.topics; + + if (flags.json) { + console.log( + JSON.stringify( + { topics: visible, totals: ws.totals }, + null, + 2, + ), + ); + return EXIT.SUCCESS; + } + + render.section( + `${clay("topics")} — ${ws.totals.topics} across all meshes ${dim( + ws.totals.unread > 0 + ? `· ${ws.totals.unread} unread` + : "· all read", + )}`, + ); + + if (visible.length === 0) { + process.stdout.write( + dim( + flags.unread + ? " no unread topics\n" + : " no topics — run `claudemesh topic create #general`\n", + ), + ); + return EXIT.SUCCESS; + } + + const slugWidth = Math.max(...visible.map((t) => t.meshSlug.length), 6); + const nameWidth = Math.max(...visible.map((t) => t.name.length), 8); + + for (const t of visible) { + const slug = dim(t.meshSlug.padEnd(slugWidth)); + const name = cyan(t.name.padEnd(nameWidth)); + const unread = + t.unread > 0 + ? yellow(`${t.unread} unread`.padStart(10)) + : dim("·".padStart(10)); + const last = t.lastMessageAt + ? dim(formatRelativeTime(t.lastMessageAt)) + : dim("never"); + process.stdout.write(` ${slug} ${name} ${unread} ${last}\n`); + } + return EXIT.SUCCESS; + }, + ); +} + +function formatRelativeTime(iso: string): string { + const then = new Date(iso).getTime(); + const now = Date.now(); + const sec = Math.max(0, Math.floor((now - then) / 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`; +} diff --git a/apps/cli/src/entrypoints/cli.ts b/apps/cli/src/entrypoints/cli.ts index d0eb1b0..9d78638 100644 --- a/apps/cli/src/entrypoints/cli.ts +++ b/apps/cli/src/entrypoints/cli.ts @@ -124,6 +124,7 @@ Topic (conversation scope, v0.2.0) claudemesh topic post encrypted REST post (v0.3.0 v2) [--reply-to ] 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 member list mesh roster with online state [--online] claudemesh notification list recent @-mentions of you [--since ] @@ -683,9 +684,14 @@ async function main(): Promise { if (!sub || sub === "workspace" || sub === "overview") { const { runMe } = await import("~/commands/me.js"); process.exit(await runMe(f)); + } else if (sub === "topics") { + const { runMeTopics } = await import("~/commands/me.js"); + process.exit(await runMeTopics({ ...f, unread: !!flags.unread })); } else { console.error( - "Usage: claudemesh me (cross-mesh overview; future: me topics, me notifications, me activity)", + "Usage: claudemesh me (cross-mesh overview)\n" + + " claudemesh me topics (cross-mesh topic list)\n" + + " claudemesh me topics --unread (only unread topics)", ); 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 ca6abb1..dd4138e 100644 --- a/apps/web/src/app/[locale]/dashboard/(user)/layout.tsx +++ b/apps/web/src/app/[locale]/dashboard/(user)/layout.tsx @@ -25,6 +25,11 @@ const menu = [ href: pathsConfig.dashboard.user.meshes.index, icon: Icons.Share, }, + { + title: "topics", + href: pathsConfig.dashboard.user.topics, + icon: Icons.MessageSquare, + }, { title: "invites", href: pathsConfig.dashboard.user.invites, diff --git a/apps/web/src/app/[locale]/dashboard/(user)/topics/page.tsx b/apps/web/src/app/[locale]/dashboard/(user)/topics/page.tsx new file mode 100644 index 0000000..1e523dc --- /dev/null +++ b/apps/web/src/app/[locale]/dashboard/(user)/topics/page.tsx @@ -0,0 +1,253 @@ +import Link from "next/link"; + +import { db } from "@turbostarter/db/server"; +import { + mesh, + meshMember, + meshTopic, + meshTopicMember, + meshTopicMessage, +} from "@turbostarter/db/schema/mesh"; +import { and, asc, count, eq, inArray, isNull, or, sql } 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: "Topics", + description: "Every topic across every mesh — sorted by activity.", +}); + +const formatRelative = (iso: string | null) => { + if (!iso) return "never"; + 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`; +}; + +export default async function WorkspaceTopicsPage() { + const { user } = await getSession(); + if (!user) { + return null; + } + + // Resolve every active membership for this user → list of (memberId, mesh). + const memberships = await db + .select({ + memberId: meshMember.id, + meshId: meshMember.meshId, + meshSlug: mesh.slug, + meshName: mesh.name, + }) + .from(meshMember) + .innerJoin(mesh, eq(mesh.id, meshMember.meshId)) + .where( + and( + eq(meshMember.userId, user.id), + isNull(meshMember.revokedAt), + isNull(mesh.archivedAt), + ), + ); + + const meshIds = memberships.map((m) => m.meshId); + const myMemberIds = memberships.map((m) => m.memberId); + const memberByMeshId = new Map(memberships.map((m) => [m.meshId, m])); + + const topics = meshIds.length + ? await db + .select({ + id: meshTopic.id, + meshId: meshTopic.meshId, + name: meshTopic.name, + description: meshTopic.description, + createdAt: meshTopic.createdAt, + }) + .from(meshTopic) + .where( + and(inArray(meshTopic.meshId, meshIds), isNull(meshTopic.archivedAt)), + ) + .orderBy(asc(meshTopic.name)) + : []; + + const topicIds = topics.map((t) => t.id); + + const lastMessages = topicIds.length + ? await db + .select({ + topicId: meshTopicMessage.topicId, + lastAt: sql`max(${meshTopicMessage.createdAt})`, + }) + .from(meshTopicMessage) + .where(inArray(meshTopicMessage.topicId, topicIds)) + .groupBy(meshTopicMessage.topicId) + : []; + const lastByTopic = new Map(lastMessages.map((r) => [r.topicId, r.lastAt])); + + const unreadCounts = + topicIds.length && myMemberIds.length + ? await db + .select({ + topicId: meshTopicMessage.topicId, + n: count(meshTopicMessage.id), + }) + .from(meshTopicMessage) + .leftJoin( + meshTopicMember, + and( + eq(meshTopicMember.topicId, meshTopicMessage.topicId), + inArray(meshTopicMember.memberId, myMemberIds), + ), + ) + .where( + and( + inArray(meshTopicMessage.topicId, topicIds), + sql`${meshTopicMessage.senderMemberId} <> ALL(${myMemberIds})`, + or( + isNull(meshTopicMember.lastReadAt), + sql`${meshTopicMessage.createdAt} > ${meshTopicMember.lastReadAt}`, + ), + ), + ) + .groupBy(meshTopicMessage.topicId) + : []; + const unreadByTopic = new Map(unreadCounts.map((r) => [r.topicId, Number(r.n)])); + + const items = topics + .map((t) => { + const m = memberByMeshId.get(t.meshId)!; + const lastAt = lastByTopic.get(t.id); + return { + ...t, + meshSlug: m.meshSlug, + meshName: m.meshName, + unread: unreadByTopic.get(t.id) ?? 0, + lastMessageAt: lastAt ? new Date(lastAt).toISOString() : null, + }; + }) + .sort((a, b) => { + if (a.lastMessageAt && b.lastMessageAt) { + return b.lastMessageAt.localeCompare(a.lastMessageAt); + } + if (a.lastMessageAt) return -1; + if (b.lastMessageAt) return 1; + return a.name.localeCompare(b.name); + }); + + const totalUnread = items.reduce((acc, t) => acc + t.unread, 0); + + return ( +
+
+
+
+ +

+ Every topic, +
+ across every{" "} + mesh. +

+
+ + +
+ + {items.length} + topics + + + 0 ? "text-[var(--cm-clay)]" : "text-[var(--cm-fg)]"}`} + > + {totalUnread} + + unread + + + {memberships.length} + meshes + +
+
+
+ + {items.length === 0 ? ( +

+ No topics yet.{" "} + + Open a mesh + {" "} + to start one. +

+ ) : ( +
    + {items.map((t, i) => ( + +
  • + + + {t.meshSlug} + + + + + {t.name} + + {t.description ? ( + + {t.description} + + ) : null} + + + + {t.unread > 0 ? ( + + + {t.unread} + + ) : ( + + · + + )} + + + + {formatRelative(t.lastMessageAt)} + + +
  • +
    + ))} +
+ )} +
+
+ ); +} diff --git a/apps/web/src/config/paths.ts b/apps/web/src/config/paths.ts index a01cfd6..f6c5c2f 100644 --- a/apps/web/src/config/paths.ts +++ b/apps/web/src/config/paths.ts @@ -103,6 +103,7 @@ const pathsConfig = { topic: (id: string, name: string) => `${DASHBOARD_PREFIX}/meshes/${id}/topics/${encodeURIComponent(name)}`, }, + topics: `${DASHBOARD_PREFIX}/topics`, invites: `${DASHBOARD_PREFIX}/invites`, settings: { index: `${DASHBOARD_PREFIX}/settings`, diff --git a/packages/api/src/modules/mesh/v1-router.ts b/packages/api/src/modules/mesh/v1-router.ts index 4f6b967..d48c8d9 100644 --- a/packages/api/src/modules/mesh/v1-router.ts +++ b/packages/api/src/modules/mesh/v1-router.ts @@ -496,6 +496,154 @@ export const v1Router = new Hono() }); }) + // GET /v1/me/topics — cross-mesh topic list for the caller's user. + // + // For each topic across every mesh the user belongs to, returns + // mesh context + unread count (vs that user's `topic_member.last_read_at` + // in that mesh) + last-message timestamp. Sorted by lastMessageAt + // desc so the most-active topics surface first — the natural "what + // should I read" view. + .get("/me/topics", 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, + meshSlug: mesh.slug, + meshName: mesh.name, + }) + .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({ topics: [], totals: { topics: 0, unread: 0 } }); + } + + const meshIds = memberships.map((m) => m.meshId); + const memberByMeshId = new Map(memberships.map((m) => [m.meshId, m])); + + const topics = await db + .select({ + id: meshTopic.id, + meshId: meshTopic.meshId, + name: meshTopic.name, + description: meshTopic.description, + visibility: meshTopic.visibility, + createdAt: meshTopic.createdAt, + }) + .from(meshTopic) + .where( + and(inArray(meshTopic.meshId, meshIds), isNull(meshTopic.archivedAt)), + ) + .orderBy(asc(meshTopic.name)); + + if (topics.length === 0) { + return c.json({ topics: [], totals: { topics: 0, unread: 0 } }); + } + + const topicIds = topics.map((t) => t.id); + const myMemberIds = memberships.map((m) => m.memberId); + + // Last message timestamp per topic. + const lastMessages = await db + .select({ + topicId: meshTopicMessage.topicId, + lastAt: sql`max(${meshTopicMessage.createdAt})`, + }) + .from(meshTopicMessage) + .where(inArray(meshTopicMessage.topicId, topicIds)) + .groupBy(meshTopicMessage.topicId); + const lastByTopic = new Map( + lastMessages.map((r) => [r.topicId, r.lastAt]), + ); + + // Unread count per topic — compares topic_message.created_at against + // the user's own member row's last_read_at in that mesh's topic. + // A message authored by the user themselves doesn't count as unread. + const unreadCounts = await db + .select({ + topicId: meshTopicMessage.topicId, + unread: count(meshTopicMessage.id), + }) + .from(meshTopicMessage) + .leftJoin( + meshTopicMember, + and( + eq(meshTopicMember.topicId, meshTopicMessage.topicId), + inArray(meshTopicMember.memberId, myMemberIds), + ), + ) + .where( + and( + inArray(meshTopicMessage.topicId, topicIds), + sql`${meshTopicMessage.createdAt} > COALESCE(${meshTopicMember.lastReadAt}, '1970-01-01'::timestamp)`, + sql`${meshTopicMessage.senderMemberId} NOT IN (${sql.join( + myMemberIds.map((id) => sql`${id}`), + sql`, `, + )})`, + ), + ) + .groupBy(meshTopicMessage.topicId); + const unreadByTopic = new Map( + unreadCounts.map((r) => [r.topicId, Number(r.unread)]), + ); + + const items = topics.map((t) => { + const m = memberByMeshId.get(t.meshId)!; + const lastAt = lastByTopic.get(t.id); + return { + topicId: t.id, + name: t.name, + description: t.description, + visibility: t.visibility, + createdAt: t.createdAt.toISOString(), + meshId: t.meshId, + meshSlug: m.meshSlug, + meshName: m.meshName, + memberId: m.memberId, + unread: unreadByTopic.get(t.id) ?? 0, + lastMessageAt: lastAt ? new Date(lastAt).toISOString() : null, + }; + }); + + // Sort by lastMessageAt desc, with never-posted topics last (alphabetical). + items.sort((a, b) => { + if (a.lastMessageAt && b.lastMessageAt) { + return b.lastMessageAt.localeCompare(a.lastMessageAt); + } + if (a.lastMessageAt) return -1; + if (b.lastMessageAt) return 1; + return a.name.localeCompare(b.name); + }); + + return c.json({ + topics: items, + totals: { + topics: items.length, + unread: items.reduce((a, t) => a + t.unread, 0), + }, + }); + }) + // GET /v1/topics — list topics in the key's mesh // Includes per-topic unread counts when the key has an issuing member // (i.e. dashboard keys; CLI-minted keys also carry it). Counts are