with one click
frontend
// Rules and best practices when working on the dashboard and elements React frontend codebases
// Rules and best practices when working on the dashboard and elements React frontend codebases
Concepts, external interfaces, and conventions for Gram's audit logging subsystem ā the internal Go API for recording actor/action/subject events and the `/rpc/auditlogs.*` management API that exposes them. Activate whenever the task involves recording or exposing audit events (adding or changing audit coverage on a service, introducing a new audited subject or action, writing tests that assert an event was recorded, changing how entries are displayed or filtered).
Rules and best practices when writing and editing Go (Golang) code
Concepts, external interfaces, and conventions for Gram's management API ā the Goa-designed HTTP-RPC surface under `/rpc/<service>.<method>` that powers the dashboard, CLI, and public SDK. Activate whenever the task involves designing, implementing, or modifying a management endpoint (new service, new method, payload/result changes, OpenAPI/SDK surface changes, CLI changes, wiring a new service into the server).
Concepts, external interfaces, and conventions for Gram's role-based access control (RBAC) subsystem ā scopes, grants, principals, system roles, and the `authz.Engine.Require` enforcement path used inside handlers. Activate whenever the task involves authorization (adding or modifying a scope or resource type, declaring a new role or grant, gating a handler, changing scope inheritance, exposing RBAC state through the dashboard).
Rules when working with PostgreSQL database in Gram
Conventions for authoring or editing analyzers in the `glint/` Go static-analysis package ā Gram's custom golangci-lint plugin built on `go/analysis`. Activate this skill whenever the task involves adding, modifying, or testing a `glint` analyzer (new rule key, new diagnostic, settings struct, fixture under `glint/testdata/`, wiring in `BuildAnalyzers`), even if the user does not say "glint" explicitly ā phrases like "add a lint rule", "write a custom analyzer", "go/analysis", or "enforce X via golangci-lint" should trigger it.
| name | frontend |
| description | Rules and best practices when working on the dashboard and elements React frontend codebases |
| metadata | {"relevant_files":["client/dashboard/**","elements/**"]} |
pnpm package manager@gram/sdk package (sourced from workspace at ./client/sdk)client/sdk/REACT_QUERY.md is very helpful for understanding how to use React Query hooks that come with the SDK.@tanstack/react-query instead of manual useEffect/useState patternsqueryKeyInstance vs toolsets.getBySlug). Use broad invalidation helpers like invalidateAllToolset(queryClient) to ensure all consumers refresh.The core rule: every UI pattern that appears in more than two places must be centralized so it can be changed in a single location.
components/ before writing anythingBefore writing any JSX for a UI element, check client/dashboard/src/components/ for an existing component. This includes layout wrappers, table headers, empty states, filter pill groups, search inputs, badges, cards ā anything. Reuse what exists. Never create a one-off <div className="..."> when a named component already exists for that purpose.
If no component exists and you expect the pattern to appear in more than a few places across the app, create one in client/dashboard/src/components/ before using it. Name it for what it is, not where it happens to appear first (e.g., PageTabsTrigger, not SourceDetailTabTrigger).
If the same Tailwind className string (or any meaningful substring of one) appears on 3+ elements anywhere in the codebase, extract it to:
cva variantconst used in cn()The symptom to watch for: copy-pasting a className prop. That is always wrong.
If you find yourself copy-pasting a JSX structure ā even with minor variations ā stop and extract a parameterized component. Three near-identical blocks is the threshold.
Never use immediately-invoked function expressions inside JSX ({(() => { ... })()} ). Extract to a named sub-component or a variable above the return statement.
A ternary that wraps onto multiple lines, or chains a second ?: inside a branch, is unreadable. Reach for one of these instead:
switch statement when mapping a discriminator (e.g. a kind / type string) to one of several values.// ā wrong ā nested multi-line ternary
attachmentType={
sourceKind === "function"
? "functions"
: sourceKind === "externalmcp" || sourceKind === "remotemcp"
? "external_mcp"
: "openapi"
}
// ā
right ā hoisted helper with a switch
function attachmentTypeForSourceKind(sourceKind: string | undefined): string {
switch (sourceKind) {
case "function":
return "functions";
case "externalmcp":
case "remotemcp":
return "external_mcp";
default:
return "openapi";
}
}
attachmentType={attachmentTypeForSourceKind(sourceKind)}
Single-line, single-branch ternaries (isOpen ? "x" : "y") are fine.
A component that has grown past ~150 lines of JSX is doing too much. Break it up. If a page has multiple tabs, each tab's content is its own component.
Many pages render the same <h1> + <p> header block in 2ā3 conditional render paths (loading skeleton, empty state, populated state). Examples observed: InsightsTools.tsx, LogsTools.tsx, LogsAgents.tsx, SecurityOverview.tsx, PolicyCenter.tsx. Symptoms: a copy change touches the same string in 3 places; Edit with replace_all fails because indentation differs between the duplicates.
When adding or editing page headers, lift the title and subtitle into a small <PageHeader title="ā¦" subtitle="ā¦" /> (or pass them as props to a shared shell), not into each render branch. When editing existing duplicated copy, target a unique trailing fragment of the string (e.g. "in chat messages.") so a single replace_all covers every copy regardless of indentation ā and file a follow-up to extract a shared header.
When the same empty-state component is reused across pages but needs different copy per caller (e.g. HooksEmptyState rendered from both /insights/tools and /logs/tools), add optional title / subtitle props with sensible defaults rather than forking the component:
export function HooksEmptyState({
title = "No logs captured",
subtitle = "Install Observability plugin in your AI agent to start capturing tool execution logs",
}: { title?: string; subtitle?: string } = {}) {
/* ⦠*/
}
Backwards-compatible callers stay <HooksEmptyState />; only the variant caller passes overrides. Avoids divergent copies of the surrounding scaffolding (provider cards, setup dialogs, etc.).
Use Moonshine's Table from @speakeasy-api/moonshine for dashboard tables. The legacy @/components/ui/table wrapper has been removed; do not recreate it, add new shadcn table wrappers, or hand-roll table styling with raw <table> markup when Moonshine can express the UI. If you find a lingering legacy table pattern, migrate it to Moonshine when touched.
import { Column, Table } from "@speakeasy-api/moonshine";
For normal data tables, prefer the declarative columns / data / rowKey API. Define Column<T>[] near the component so render functions stay typed, use render for rich cells, and use width for stable layouts instead of ad hoc cell class widths.
const columns: Column<Role>[] = [
{
key: "name",
header: "Name",
width: "180px",
render: (role) => <Type className="font-medium">{role.name}</Type>,
},
{
key: "members",
header: "Members",
width: "100px",
render: (role) => <Type>{role.memberCount}</Type>,
},
];
<Table columns={columns} data={roles} rowKey={(row) => row.id} />;
For empty and loading states, use the Table's built-in empty surface and the shared SkeletonTable from @/components/ui/skeleton. Do not rebuild a one-off empty <tbody> or skeleton table.
<Table
columns={columns}
data={filteredKeys}
rowKey={(row) => row.id}
className="max-h-[500px] overflow-y-auto"
noResultsMessage={<Type>No matching API keys</Type>}
/>
Search and filter controls are siblings above the table. Keep filter state outside the table, derive filtered rows with useMemo, and pass the result to data. Use existing controls such as SearchBar, MultiSelect, Select, or page-specific filter pills; do not put form controls inside Table.Header unless they are truly column headers. If the table is paginated, reset the page index when filters change.
const [search, setSearch] = useState("");
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const filteredRows = useMemo(() => {
const normalizedSearch = search.trim().toLowerCase();
return rows.filter((row) => {
const matchesSearch =
normalizedSearch.length === 0 ||
row.name.toLowerCase().includes(normalizedSearch);
const matchesTags =
selectedTags.length === 0 ||
row.tags.some((tag) => selectedTags.includes(tag));
return matchesSearch && matchesTags;
});
}, [rows, search, selectedTags]);
<Stack direction="horizontal" gap={2} className="mb-4 h-fit">
<SearchBar
value={search}
onChange={(value) => {
setSearch(value);
setPage(0);
}}
placeholder="Search tools"
className="w-64"
/>
<MultiSelect
options={tagOptions}
defaultValue={selectedTags}
onValueChange={(value) => {
setSelectedTags(value);
setPage(0);
}}
placeholder="Filter by tag"
autoSize
/>
</Stack>
<Table
columns={columns}
data={filteredRows}
rowKey={(row) => row.id}
noResultsMessage={<Type>No matching tools</Type>}
/>;
Footers that summarize, paginate, or load more rows should usually be sibling bars immediately below the table. Moonshine's table API does not require a special footer component for this; keep the table declarative and put pagination/load-more controls after it.
<Table columns={columns} data={visibleRows} rowKey={(row) => row.id} />;
{
totalPages > 1 && (
<div className="flex items-center justify-between border-t px-4 py-3">
<Type className="text-muted-foreground text-sm">
{pageStart}-{pageEnd} of {filteredRows.length}
</Type>
<div className="flex items-center gap-1">
<Button
variant="tertiary"
size="sm"
onClick={() => setPage((page) => page - 1)}
disabled={page === 0}
>
Previous
</Button>
<Button
variant="tertiary"
size="sm"
onClick={() => setPage((page) => page + 1)}
disabled={page >= totalPages - 1}
>
Next
</Button>
</div>
</div>
);
}
Use the compound API only when the body needs custom structure that the declarative API cannot express, such as mixed rows, a full-width CTA row, or a custom no-results branch. Keep the Moonshine wrapper, header, row, and cell components as the default primitives.
<Table columns={columns}>
<Table.Header columns={columns} />
{items.length === 0 ? (
<Table.NoResultsMessage>No results found.</Table.NoResultsMessage>
) : (
<Table.Body>
{items.map((item) => (
<Table.Row key={item.id} row={item} columns={columns} />
))}
</Table.Body>
)}
<Table.Row>
<div className="border-border bg-muted/20 col-span-full border-t py-5 text-center">
<Type className="text-muted-foreground text-sm">
Want to grant new members access?
</Type>
<Button variant="tertiary" size="sm" className="mt-2">
Configure Roles
</Button>
</div>
</Table.Row>
</Table>
Use grouped or expandable rows through Moonshine's table props instead of nesting unrelated cards or custom accordions around a table. Current patterns use hideHeader for grouped parent rows and renderExpandedContent for nested details.
<Table
columns={groupColumns}
data={groups}
rowKey={(row) => row.key}
hideHeader
renderExpandedContent={(group) => (
<Table
columns={childColumns}
data={group.items}
rowKey={(row) => row.id}
hideHeader
/>
)}
/>
Raw <tr> / <td> should be rare and stay inside a Moonshine <Table.Body> only when native table semantics are needed and Moonshine does not expose them, such as a colSpan overflow row. If the row is a normal data row, use <Table.Row row={row} columns={columns} /> or the declarative data prop.
These patterns were established in the audit log (#2140) and deployment log (#2167) redesigns. Apply them whenever building search, filtering, or keyboard navigation.
Never create new RegExp() inside a render callback (e.g., highlightMatch). Extract it to a useMemo keyed on the search query:
const searchRegex = useMemo(() => {
if (!searchQuery) return null;
const escaped = searchQuery.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return new RegExp(`(${escaped})`, "gi");
}, [searchQuery]);
Then use searchRegex inside useCallback-wrapped functions.
Wrap search queries with useDeferredValue before passing them to expensive useMemo computations (e.g., filtering all logs). This keeps the input responsive while React defers the downstream recomputation:
const deferredSearchQuery = useDeferredValue(searchQuery);
const filteredIndices = useMemo(() => { /* expensive filter */ }, [deferredSearchQuery, ...]);
If a value can be computed from current state, derive it inline ā don't sync it via useEffect. This prevents a flash of stale values between renders:
// DO: derive during render
const effectiveSearchIndex =
searchMatchIndices.length > 0
? Math.min(currentSearchIndex, searchMatchIndices.length - 1)
: 0;
// DON'T: clamp via useEffect (causes stale render flash)
useEffect(() => {
if (currentSearchIndex >= searchMatchIndices.length) setCurrentSearchIndex(0);
}, [searchMatchIndices.length]);
When a component has keyboard navigation (j/k/g/G) with a currentIndex state, reset it when the underlying data changes (filters, pagination, data refresh):
useEffect(() => {
setCurrentLogIndex(null);
}, [logs]); // or parsedLogs, depending on the component
App.tsx wraps the entire app in a global TooltipProvider. Never add another TooltipProvider inside a component ā doing so creates a redundant Radix context per instance and contributes to ResizeObserver loop completed with undelivered notifications errors in the browser.
Use <Tooltip>, <TooltipTrigger>, and <TooltipContent> directly ā they inherit the global provider automatically. For simple cases use the existing <SimpleTooltip tooltip="..."> wrapper from @/components/ui/tooltip.
// ā
correct
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent>Hello</TooltipContent>
</Tooltip>
// ā wrong ā TooltipProvider already exists at the app root
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent>Hello</TooltipContent>
</Tooltip>
</TooltipProvider>
Use the right primitive for the link type ā mixing them causes full-page reloads, broken multi-tenancy, or missing security headers.
Internal navigation (any URL inside the dashboard): Use the route helpers from client/dashboard/src/routes.tsx. Top-level routes and subpages expose .Link, .href(), and .goTo():
// Wrap a child node with .Link
<routes.sources.Link>
<Button>Connect a Source</Button>
</routes.sources.Link>
// Subpages get .Link too ā use it instead of building strings
<routes.insights.tools.Link>
<Button>Track AI usage</Button>
</routes.insights.tools.Link>
// Plain react-router Link with .href() when you need a className or are inside <p>
<Link to={routes.plugins.href()} className="underline underline-offset-2">
Observability plugin
</Link>
Never hardcode org/project slugs in an href (e.g. https://app.getgram.ai/speakeasy-team/projects/default/plugins). The route helpers resolve the current :orgSlug / :projectSlug from the URL, so the same call works for every tenant.
External links (anywhere outside the dashboard): Use a plain <a> with target="_blank" and rel="noopener noreferrer". This matches the existing pattern (AddServerDialog.tsx:1162, CatalogDetail.tsx:229) and the security attributes are mandatory ā noopener blocks window.opener access; noreferrer strips the Referer header.
<a
href="https://www.speakeasy.com/product/mcp-gateway/catalog"
target="_blank"
rel="noopener noreferrer"
className="underline underline-offset-2 hover:text-foreground"
>
MCP Registry
</a>
The @/components/ui/link wrapper sets target="_blank" when external is true but also injects an icon ā fine for nav rows, too heavy for inline links inside subtext. Reach for the plain <a> for inline external links.
{rangeLabel}, {periodUsage.credits}, or {projectName}. When rewording copy that contains a token, keep the token in place ā replace it with the literal current value (e.g. "the last 30 days") only when the data fetch itself is locked to that value. Otherwise the copy starts lying as soon as the user changes a filter.prettier-plugin-tailwindcss reorders classes on save. Write classes in any order; the formatter will normalize them and the diff stays clean across the codebase.contextInfo= / suggestions= props passed to ExploreWithAI / InsightsConfig. Those strings are sent to the LLM as analytical context; if they drift from the visible label, the AI assistant talks about a card the user can't see.@speakeasy-api/moonshine instead of hardcoded Tailwind color valuesbg-neutral-100, border-gray-200, text-gray-500, etc.@tailwindcss/typography must remain in devDependencies ā the dashboard uses prose and not-prose classes directly (e.g. CatalogDetail.tsx, tool.tsx) which are provided by this plugin.Pre-GA features get a Preview or Beta badge wherever the user would otherwise mistake the feature for being GA. The same ReleaseStageBadge component renders on every surface, so labels never drift.
Source of truth: client/dashboard/src/components/release-stage-badge.tsx ā exports ReleaseStageBadge and the ReleaseStage = "preview" | "beta" type.
Underlying primitive: Moonshine's <Badge> component (@speakeasy-api/moonshine). ReleaseStageBadge composes Moonshine's Badge with background enabled ā this is the source of truth for shape (mono, uppercase, tracked, bordered, rounded-xs, h-5, text-[12px]). Do not override these classes; the design system owns them. The wrapper just picks a semantic variant and adds a tooltip.
Variant ā stage mapping (variant names are hooks, not literal semantics):
preview ā Moonshine warning variant (amber).beta ā Moonshine information variant (Speakeasy brand blue).Moonshine's badge variants (
neutral | destructive | information | success | warning) are tuned for alert/feedback contexts, but the names are just hooks āwarninghere means "experimental, use with caution," not "alert." That's the intended way to reuse the palettes; don't invent new variants without design buy-in.
Never hardcode Tailwind colors (no bg-violet-500, no raw bg-warning-softest spans). If you find yourself reaching for raw classes for a new badge use case, that's a signal to either pick an existing Moonshine variant or add one upstream.
Set stage on the route declaration. app-sidebar.tsx forwards item.stage through ScopeGatedNavItem ā NavButton, which renders the badge with a hover tooltip explaining what the stage means. The badge auto-hides in collapsed-icon mode.
// client/dashboard/src/routes.tsx
assistants: {
title: "Assistants",
url: "assistants",
icon: "bot",
stage: "preview", // ā sidebar pill appears automatically
component: AssistantsRoot,
},
Gotcha: if you ever introduce another sidebar wrapper that calls
NavButtondirectly (instead of going throughNavMenuButton), you must forwardstage={item.stage}explicitly.app-sidebar.tsx'sScopeGatedNavItemdoes this ā copy that pattern.
Pass stage on the primary Page.Section.Title for the page (usually the first Page.Section under Page.Body). Don't put it on secondary section titles like "Recent Chats" ā the badge labels the whole feature, not individual sections.
<Page.Section>
<Page.Section.Title stage="beta">Risk Overview</Page.Section.Title>
<Page.Section.Description>ā¦</Page.Section.Description>
</Page.Section>
Gotcha: pages with multiple render branches (loading / empty / populated) must include the title ā and therefore the
stageā in every branch.PolicyCenter.tsxandSecurityOverview.tsxare the reference for this pattern.
For ObserveTabNav-style tab strips, add stage to the local tab descriptor and render <ReleaseStageBadge size="xs" noTooltip> inline. Use inline-flex items-center gap-2 on the tab <Link> so the badge tracks the label without disrupting the active-tab underline.
// client/dashboard/src/components/observe/ObserveTabNav.tsx
type Tab = { label: string; href: string; stage?: ReleaseStage };
const tabs: Tab[] = [
{ label: "Employees", href: `${baseSlug}/employees`, stage: "preview" },
];
<h1> headings (pages that don't use Page.Section.Title)A handful of pages render their own <h1 className="text-xl font-semibold">ā¦</h1> instead of Page.Section.Title (e.g., InsightsEmployees, InsightsAgents). Wrap the heading and the badge in a flex items-center gap-2 div:
<div className="flex items-center gap-2">
<h1 className="text-xl font-semibold">AI Agent Costs</h1>
<ReleaseStageBadge stage="preview" />
</div>
Consider migrating these pages to
Page.Section.Titlein a follow-up ā but don't bundle that refactor with the badge addition.
Grep stage="preview" and stage="beta" and delete every match:
routes.tsx ā remove the stage: field on the route entryPage.Section.Title stage="ā¦" ā drop the propObserveTabNav (or similar) tab descriptors ā drop the stage field<ReleaseStageBadge> usages ā delete the element and unwrap the flex items-center gap-2 divThere's no other cleanup. The component itself stays in place for the next pre-GA feature.
DrawerContent requires DrawerTitle (and optionally DrawerDescription) inside it. Omitting them generates a console error and breaks screen reader accessibility. DrawerHeader and DrawerFooter are optional layout wrappers.
<Drawer>
<DrawerTrigger>Open</DrawerTrigger>
<DrawerContent>
<DrawerTitle>Session Details</DrawerTitle>
<DrawerDescription>Viewing trace for this chat.</DrawerDescription>
{/* content */}
</DrawerContent>
</Drawer>
If the title is visually redundant, hide it from sighted users while keeping it for screen readers:
<DrawerTitle className="sr-only">Details</DrawerTitle>
DialogContent requires DialogTitle (and optionally DialogDescription) inside it. Omitting them generates a console error and breaks screen reader accessibility. DialogHeader and DialogFooter are optional layout wrappers.
<Dialog>
<DialogTrigger>Open</DialogTrigger>
<DialogContent>
<DialogTitle>Confirm Action</DialogTitle>
<DialogDescription>This cannot be undone.</DialogDescription>
{/* content */}
</DialogContent>
</Dialog>
If the title is visually redundant, hide it from sighted users while keeping it for screen readers:
<DialogTitle className="sr-only">Details</DialogTitle>