feat: template details page#351
Conversation
Add a per-template detail page reachable from the templates list at
/templates/[templateId]/{overview,builds,tags}. v1 is read-only across
all three tabs, except the visibility dropdown on the header which
reuses the existing templates.updateTemplate mutation.
Routing
- New route group [templateId]/(detail-tabs) wraps overview, builds,
and tags so the existing builds/[buildId] build-detail page is
untouched (sibling, not inside the group).
- [templateId]/page.tsx redirects to /overview.
- Names: name-cell click on the list page navigates to /overview.
Default (E2B) templates remain non-clickable. Copy moves to a
hover-revealed icon button in the corner of the cell.
Header
- Reusable usePageTitle Zustand hook (~30 LOC) lets pages override
the dashboard title bar with fetched data. Global header reads the
override; falls back to pathname-derived layout config when none.
- DetailsRow with 5 cells: Memory, CPU, Created, Updated, Visibility.
- No-successful-build state renders '--' for cpu/memory.
- Visibility dropdown reuses the existing updateTemplate mutation
with optimistic updates across both list and detail caches.
Builds tab
- BuildsTable now accepts optional templateId prop; when set, the
Template column is hidden and a scoped filter hook
(useTemplateBuildsFilters, statuses-only URL state) is used.
- BuildsHeader gains showSearchInput and scoped props; the all-team
/templates/builds page is unchanged.
Tags tab
- New templates.getTags tRPC procedure + repository method backed by
the existing GET /templates/{templateID}/tags infra endpoint.
- Read-only table: tag pill | linked build ID + relative time.
- Sortable on both columns; client-side search by name; info banner
copy matches Figma; empty state.
tRPC + repository
- New templates.getTemplate procedure filters server-side over the
existing getTeamTemplates result; throws NOT_FOUND when the
templateID isn't in the team's list (catches stale links, wrong
team, and default-template URL guessing).
Telemetry
- PostHog events: 'template detail opened' (mount), 'template detail
tab switched' (tab change), 'template visibility changed from
header' (mutation success). 'template detail opened' is also
emitted on name-cell click from the list with fromTab=list.
Co-Authored-By: Craft Agent <agents-noreply@craft.do>
Scoped builds tab now matches the all-team builds toolbar (search + status filter). New URL param 'q' lives on the scoped filter hook. The backend query stays scoped by templateID (authoritative); 'q' applies as a client-side substring filter on build IDs over the already-loaded pages. This avoids cross-template name-match leakage that 'buildIdOrTemplate=<q>' could otherwise introduce. BuildsHeader unifies the all-team and scoped search via a single 'search/setSearch' pair backed by the appropriate hook, with placeholder swapped to 'Search by build ID' in scoped mode. Co-Authored-By: Craft Agent <agents-noreply@craft.do>
Align toolbar with Figma 5418:195576 / 5630:152026: - Info text is plain gray body, no icon, no border, no background. - Info + tag count sit on a single horizontal row with space-between (mirrors the design's 'tags identify\u2026' / '8 tags in total' bar). - 'Read more.' is rendered as an underlined inert span until a docs URL is wired in. - Build column header is renamed 'Assigned to' to match the design and the actual semantic (timestamp = when the tag was assigned). Co-Authored-By: Craft Agent <agents-noreply@craft.do>
Replaces the inert placeholder with a live link to https://e2b.dev/docs/template/tags, with a trailing external-link icon for consistency with other off-site references in the dashboard. Co-Authored-By: Craft Agent <agents-noreply@craft.do>
Mirror the DataTableHead visual treatment used on /templates/list: - font-mono uppercase prose-label - text-fg-tertiary by default \u2014 dims the column name - prose-label-highlight + text-fg on the active column - SortAscIcon / SortDescIcon (was a generic three-line SortIcon) - icon hidden until hover when inactive; on hover it previews the direction the first click will sort to (defaultDir prop per column: asc for Name, desc for Assigned to) - active column shows the current direction icon always Co-Authored-By: Craft Agent <agents-noreply@craft.do>
Persistent underline (decoration-fg-tertiary) makes the build IDs read as links at rest, matching the Figma. Hover transitions the decoration to the brighter foreground color. Co-Authored-By: Craft Agent <agents-noreply@craft.do>
BuildsHeader carried two booleans (scoped, showSearchInput) and called both filter hooks every render to keep hook order stable. It also mirrored URL state into local useState via two useEffects to keep the inputs snappy while the URL writes debounced. Replace with the same pattern used by features/dashboard/sandbox/events: - BuildsStatusFilter: pure presentational, statuses + onStatusesChange, no hooks beyond UI primitives. Mirrors EventTypeFilter. - AllBuildsHeader: uses useFilters, composes the filter + search. - TemplateBuildsHeader: uses useTemplateBuildsFilters (statuses + q), composes the same filter + search with a scoped placeholder. The bespoke local-state + mirroring useEffect goes away. Search inputs switch to the DebouncedInput primitive (ui/primitives/input.tsx), which owns the mirror+debounce pattern once instead of duplicating it per consumer. Filter setters drop the useDebounceCallback wrapper \u2014 status clicks aren't bursty events and debouncing was only there to mask the URL-write lag the local mirror was compensating for. Both consumers updated: - /templates/builds \u2192 <AllBuildsHeader /> - /templates/[templateId]/builds \u2192 <TemplateBuildsHeader /> Rule alignment (vercel-composition-patterns, vercel-react-best-practices): - architecture-avoid-boolean-props: zero booleans on either header - patterns-explicit-variants: each consumer is named and self-contained - rerender-derived-state-no-effect: no state mirroring in the headers - state-decouple-implementation: BuildsStatusFilter doesn't know where its state comes from Co-Authored-By: Craft Agent <agents-noreply@craft.do>
Remove JSDoc blocks and inline comments that restate the code or narrate section boundaries. Keep only the ~10 single-line notes that communicate non-obvious context naming cannot express: - use-page-title cleanup-equality (subtle ownership check) - layout.ts pathname-fallback role + glob ordering + flush-tabs note - repository.server.ts: 'no per-template GET on infra' explainer - title-binder.tsx: 'drives title + 404 entry point for all tabs' - list/table-cells: 'defaults have no detail page' - status-filter: 'can't deselect the last status' business rule - use-filters / use-template-builds-filters: 'debounce in DebouncedInput' - table.tsx: 'hook order stability' + 'q is client-side narrow' Also rename BuildsTable's 'colSpan' to 'visibleColumnCount' so the isTemplateScoped ? 5 : 6 calculation reads itself without needing a 3-line comment. Net change: -131 +20 = -111 lines of comment surface area on the branch (~60% reduction). Co-Authored-By: Craft Agent <agents-noreply@craft.do>
BuildsTable previously called both useFilters and useTemplateBuildsFilters
every render to keep React hook order stable, and derived
isTemplateScoped from the presence of an optional templateId prop \u2014
which then gated six conditional branches (statuses selection,
buildIdOrTemplate selection, client-side q narrowing, colgroup,
TableHead, per-row TableCell).
Replace the templateId presence-based mode toggle with explicit,
pre-resolved props:
- filters: { statuses, buildIdOrTemplate } \u2014 passed straight to the
backend query
- postFilter?: (build) => boolean \u2014 optional client-side narrowing
- showTemplateColumn?: boolean \u2014 explicit structural visibility toggle
Filter hooks move up to the two page consumers, each calling exactly
one (the right one for its route). The detail builds page builds its
own postFilter from the scoped 'q' URL param; the templateID flows
into filters.buildIdOrTemplate directly. Both pages become 'use
client' (they were trivial wrappers with no server-only logic).
Rule alignment (vercel-composition-patterns):
- architecture-avoid-boolean-props: the one remaining boolean
(showTemplateColumn) is a purely structural visibility flag, not the
exponential-state-space behavioural booleans the rule targets
- state-decouple-implementation: BuildsTable no longer knows which URL
hook drives its filters \u2014 consumer chooses, table works with any
source
The variant identity now lives at the page level: TemplateBuildsPage
vs TemplateDetailBuildsPage are the explicit variants. Each calls one
hook, builds its own props, hands a fully-resolved configuration to a
single shared component.
Co-Authored-By: Craft Agent <agents-noreply@craft.do>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 478a8fe671
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| | 'names' | ||
| | 'createdAt' | ||
| | 'updatedAt' | ||
| | 'createdBy' | ||
| | 'lastSpawnedAt' | ||
| | 'spawnCount' | ||
| | 'buildCount' |
There was a problem hiding this comment.
🟣 Pre-existing nit cleanup: createDefaultTemplatesRepository.getDefaultTemplatesCached in src/core/modules/templates/repository.server.ts still assigns createdBy: null on each mapped DefaultTemplate, even though this PR removed createdBy from the Template Pick (models.ts) and from every mock entry (mock-data.ts). It's dead — no compile error, no consumer reads it — but the assignment should be dropped to finish the cleanup the PR started.
Extended reasoning...
What the issue is. This PR removes createdBy from the Pick<> in src/core/modules/templates/models.ts (lines 14–19 in the diff) and removes createdBy: … / createdBy: null entries from every mock template in src/configs/mock-data.ts. However, the matching mapping in createDefaultTemplatesRepository.getDefaultTemplatesCached (around src/core/modules/templates/repository.server.ts:585) was not touched and still does createdBy: null inside its .map(...) callback when building templates: DefaultTemplate[].\n\nWhy this is not a build break. The original report hypothesized a TypeScript excess-property error, and a verifier confirmed via bunx tsc --noEmit that no such error fires. The literal flows through generic inference into the callback's return type U, the resulting U[] is then structurally assignable to DefaultTemplate[] (extra properties are fine for subtyping at the array level, and excess-property freshness doesn't apply once the literal has been inferred through a generic). Vercel's "Ready" deployment status is consistent with that. So the original "build break" framing is incorrect — I'm filing this corrected.\n\nWhat the actual impact is. Each returned DefaultTemplate object carries a createdBy: null property that the type system no longer surfaces and no consumer reads. The OpenAPI DefaultTemplate response shape doesn't include it, and the dashboard renders templates from the typed shape, so there is zero behavioral effect at runtime. This is dead/stale code, not a bug that affects users — which is why I'm marking it pre-existing/nit rather than normal.\n\nStep-by-step proof.\n1. Template in models.ts (post-PR) is Pick<…, 'templateID' | 'buildID' | … | 'updatedAt' | 'lastSpawnedAt' | …> — no createdBy.\n2. DefaultTemplate = Template & { isDefault: true; defaultDescription?: string } — also no createdBy.\n3. In repository.server.ts, the mapped object literal still includes createdBy: null, between updatedAt: t.createdAt, and lastSpawnedAt: t.createdAt,.\n4. TS narrows the callback return to the literal's shape, then unifies into U[] and finally checks U[] assignability against DefaultTemplate[] — that check accepts extra properties (no fresh-literal excess-property check applies post-inference), so the file compiles cleanly.\n5. At runtime, every entry of templates carries an untyped createdBy: null property. Nothing in the dashboard reads it, so it just sits there.\n\nHow to fix it. Drop the createdBy: null, line in the .map callback alongside the matching removals in models.ts and mock-data.ts. That completes the cleanup with no behavior change.
Add template detail page with Overview, Builds, and Tags tabs.
Changes:
Other UI fixes: