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>
This commit is contained in:
Alejandro Gutiérrez
2026-05-03 00:39:58 +01:00
parent aa6c7be4eb
commit c795df4fd4
7 changed files with 518 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);
}