feat(web): topic discoverability — counts on cards + inline creation
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

Two UX wins for the v0.2.0 chat surface:

- Mesh cards on /dashboard now show topic count alongside members and
  tier ("3 MEMBERS · 2 TOPICS · FREE"). Active topics render in clay,
  zero in tertiary. One aggregate query, not N+1.
- Mesh detail page replaces the CLI-hint empty state with an inline
  CreateTopicForm. Non-empty topic lists get a compact "+ new topic"
  pill in the section header. Server action validates name format
  (lowercase letters/digits/dashes, 1-50 chars), inserts via the
  unique (meshId, name) index, auto-subscribes the creator as topic
  lead, then redirects into the chat.

Sidebar audit — kept platform/manage/dev structure as is. Topics are
mesh-scoped so a top-level "topics" entry would have nothing to land
on without a mesh chosen first. Discoverability lives on the mesh
cards instead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-05-02 16:27:19 +01:00
parent c801afd2ab
commit f727620d16
5 changed files with 298 additions and 15 deletions

View File

@@ -17,6 +17,7 @@ import {
DashboardHeaderDescription,
DashboardHeaderTitle,
} from "~/modules/common/layout/dashboard/header";
import { CreateTopicForm } from "~/modules/mesh/create-topic-form";
export const generateMetadata = getMetadata({
title: "Mesh",
@@ -144,42 +145,55 @@ export default async function MeshPage({
</section>
<section className="rounded-lg border">
<header className="flex items-center justify-between border-b px-4 py-3">
<header className="flex items-center justify-between gap-3 border-b px-4 py-3">
<h2 className="font-medium">
Topics{" "}
<span className="text-muted-foreground">({topics.length})</span>
</h2>
{topics.length > 0 ? (
<CreateTopicForm meshId={mesh.id} variant="compact" />
) : null}
</header>
{topics.length === 0 ? (
<p className="text-muted-foreground px-4 py-8 text-center text-sm">
No topics yet. Run{" "}
<code className="bg-muted rounded px-1.5 py-0.5 text-xs">
claudemesh topic create &lt;name&gt;
</code>{" "}
from the CLI.
</p>
<div className="px-4 py-6">
<p
className="mb-4 text-center text-[11px] text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
no topics yet · create one to start a conversation
</p>
<CreateTopicForm meshId={mesh.id} variant="inline" />
</div>
) : (
<div className="divide-y">
{topics.map((t) => (
<Link
key={t.id}
href={pathsConfig.dashboard.user.meshes.topic(mesh.id, t.name)}
className="hover:bg-muted/50 flex flex-col gap-1.5 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:gap-3"
className="group flex flex-col gap-1.5 px-4 py-3 transition-colors hover:bg-[var(--cm-bg-hover)] sm:flex-row sm:items-center sm:justify-between sm:gap-3"
>
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
<span className="font-medium">
<span className="text-muted-foreground">#</span>
<span className="text-[var(--cm-clay)]">#</span>
{t.name}
</span>
<Badge variant="outline" className="text-xs">
{t.visibility}
</Badge>
</div>
{t.description ? (
<span className="text-muted-foreground truncate text-xs">
{t.description}
<div className="flex items-center gap-3">
{t.description ? (
<span className="text-muted-foreground max-w-[40ch] truncate text-xs">
{t.description}
</span>
) : null}
<span
className="text-[11px] text-[var(--cm-fg-tertiary)] transition-transform group-hover:translate-x-0.5"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
open
</span>
) : null}
</div>
</Link>
))}
</div>

View File

@@ -5,6 +5,9 @@ import {
getMyMeshesResponseSchema,
} from "@turbostarter/api/schema";
import { handle } from "@turbostarter/api/utils";
import { db } from "@turbostarter/db/server";
import { meshTopic } from "@turbostarter/db/schema/mesh";
import { and, count, inArray, isNull } from "drizzle-orm";
import { appConfig } from "~/config/app";
import { pathsConfig } from "~/config/paths";
@@ -42,6 +45,24 @@ export default async function UniversePage() {
redirect(`${pathsConfig.dashboard.user.meshes.new}?onboarding=1`);
}
// Decorate each mesh with its non-archived topic count so MeshesGrid
// can show "X TOPICS" inline. One aggregate query, not N+1.
const meshIds = activeMeshes.map((m) => m.id);
const topicCounts = meshIds.length
? await db
.select({ meshId: meshTopic.meshId, n: count() })
.from(meshTopic)
.where(
and(inArray(meshTopic.meshId, meshIds), isNull(meshTopic.archivedAt)),
)
.groupBy(meshTopic.meshId)
: [];
const topicMap = new Map(topicCounts.map((r) => [r.meshId, Number(r.n)]));
const meshesWithTopics = activeMeshes.map((m) => ({
...m,
topicCount: topicMap.get(m.id) ?? 0,
}));
return (
<div className="@container relative h-full p-6 md:p-10">
{/* Subtle radial backdrop, matching marketing hero */}
@@ -65,7 +86,7 @@ export default async function UniversePage() {
appBaseUrl={appConfig.url ?? "https://claudemesh.com"}
/>
<MeshesGrid meshes={activeMeshes} />
<MeshesGrid meshes={meshesWithTopics} />
</div>
</div>
);