2 Commits

Author SHA1 Message Date
Alejandro Gutiérrez
c795df4fd4 feat(workspace): claudemesh me topics + dashboard topics page
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
ships v0.4.0 phase 2: a cross-mesh topic feed.

api: GET /v1/me/topics aggregates topics across every mesh the
caller belongs to with per-topic unread counts (vs the user's
member-row last_read_at) and last-message timestamps. Sorted by
last activity.

cli (1.11.0): claudemesh me topics renders the feed; --unread
filters to topics with pending reads; --json returns raw.

web: /dashboard/topics ssr's the same view server-side (direct
db queries, no apikey-mint roundtrip) and adds a Topics entry
to the dashboard sidebar between Meshes and Invites.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 00:39:58 +01:00
Alejandro Gutiérrez
aa6c7be4eb build(sdk): add exports.bun condition pointing at src for compile
bun build --compile in the cli release workflow couldn't resolve
@claudemesh/sdk because dist/ never gets built (--ignore-scripts).
adding exports.bun -> ./src/index.ts lets bun consume the typescript
sources directly while npm consumers keep using dist/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 00:04:35 +01:00
8 changed files with 526 additions and 2 deletions

View File

@@ -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",

View File

@@ -109,3 +109,106 @@ export async function runMe(flags: MeFlags): Promise<number> {
},
);
}
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<number> {
return withRestKey(
{
meshSlug: flags.mesh ?? null,
purpose: "workspace-topics",
capabilities: ["read"],
},
async ({ secret }) => {
const ws = await request<WorkspaceTopicsResponse>({
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`;
}

View File

@@ -124,6 +124,7 @@ Topic (conversation scope, v0.2.0)
claudemesh topic post <t> <msg> encrypted REST post (v0.3.0 v2) [--reply-to <id>]
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 <ISO>]
@@ -683,9 +684,14 @@ async function main(): Promise<void> {
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);
}

View File

@@ -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,

View File

@@ -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<Date>`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 (
<div className="@container relative h-full p-6 md:p-10">
<div
aria-hidden
className="pointer-events-none absolute inset-0 z-0"
style={{
background:
"radial-gradient(ellipse 70% 50% at 85% -5%, rgba(188,209,202,0.08), transparent 70%)",
}}
/>
<div className="relative z-10 mx-auto max-w-[1100px]">
<header className="mb-10 grid gap-6 border-b border-[var(--cm-border-soft,rgba(217,119,87,0.1))] pb-8 md:mb-14 md:grid-cols-[1fr_auto] md:items-end md:pb-10">
<Reveal delay={0}>
<h1
className="text-[clamp(2rem,1.6rem+2.5vw,3.25rem)] leading-[1.05] tracking-tight"
style={{ fontFamily: "var(--cm-font-serif)", fontWeight: 400 }}
>
Every <span className="italic text-[var(--cm-clay)]">topic</span>,
<br />
<span className="italic text-[var(--cm-fg-tertiary)]">across every</span>{" "}
mesh.
</h1>
</Reveal>
<Reveal delay={1}>
<div className="flex items-baseline gap-6 font-mono text-[12px] uppercase tracking-[0.18em] text-[var(--cm-fg-tertiary)]">
<span>
<span className="mr-2 text-[var(--cm-fg)]">{items.length}</span>
topics
</span>
<span>
<span
className={`mr-2 ${totalUnread > 0 ? "text-[var(--cm-clay)]" : "text-[var(--cm-fg)]"}`}
>
{totalUnread}
</span>
unread
</span>
<span>
<span className="mr-2 text-[var(--cm-fg)]">{memberships.length}</span>
meshes
</span>
</div>
</Reveal>
</header>
{items.length === 0 ? (
<p className="text-[var(--cm-fg-secondary)]">
No topics yet.{" "}
<Link
href={pathsConfig.dashboard.user.meshes.index}
className="text-[var(--cm-clay)] underline-offset-4 hover:underline"
>
Open a mesh
</Link>{" "}
to start one.
</p>
) : (
<ul className="divide-y divide-[var(--cm-border-soft,rgba(217,119,87,0.1))] border-y border-[var(--cm-border-soft,rgba(217,119,87,0.1))]">
{items.map((t, i) => (
<Reveal key={t.id} delay={Math.min(i, 8)}>
<li>
<Link
href={pathsConfig.dashboard.user.meshes.topic(t.meshId, t.name)}
className="group flex items-center gap-5 px-2 py-4 transition-colors duration-200 hover:bg-[var(--cm-bg-hover)]"
>
<span className="flex w-32 shrink-0 items-center font-mono text-[11px] uppercase tracking-[0.16em] text-[var(--cm-fg-tertiary)]">
{t.meshSlug}
</span>
<span className="flex min-w-0 flex-1 items-baseline gap-3">
<span
className="truncate text-[18px] tracking-tight text-[var(--cm-fg)] group-hover:text-[var(--cm-clay)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
{t.name}
</span>
{t.description ? (
<span className="hidden truncate text-[13px] text-[var(--cm-fg-tertiary)] md:inline">
{t.description}
</span>
) : null}
</span>
<span className="w-24 shrink-0 text-right">
{t.unread > 0 ? (
<span className="inline-flex items-center gap-1.5 rounded-full border border-[rgba(217,119,87,0.4)] bg-[rgba(217,119,87,0.08)] px-2.5 py-0.5 font-mono text-[11px] text-[var(--cm-clay)]">
<span className="size-[6px] rounded-full bg-[var(--cm-clay)]" />
{t.unread}
</span>
) : (
<span className="font-mono text-[11px] text-[var(--cm-fg-tertiary)]">
·
</span>
)}
</span>
<span className="w-20 shrink-0 text-right font-mono text-[11px] text-[var(--cm-fg-tertiary)]">
{formatRelative(t.lastMessageAt)}
</span>
</Link>
</li>
</Reveal>
))}
</ul>
)}
</div>
</div>
);
}

View File

@@ -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`,

View File

@@ -496,6 +496,154 @@ export const v1Router = new Hono<Env>()
});
})
// 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<Date>`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

View File

@@ -4,6 +4,14 @@
"description": "SDK for connecting any process to a claudemesh mesh",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"bun": "./src/index.ts",
"import": "./dist/index.js",
"default": "./dist/index.js"
}
},
"scripts": {
"build": "tsc",
"clean": "rm -rf dist"