feat(dashboards): agent-built custom dashboards (#55)

Persist agent-generated UIs as bookmarkable dashboards
with CRUD, sidebar nav, and iterative editing support.
Max 5 per user. Fresh data on each visit via saved queries.

Co-authored-by: Nicholai <nicholaivogelfilms@gmail.com>
This commit is contained in:
Nicholai 2026-02-07 09:02:19 -07:00 committed by GitHub
parent 404a881758
commit b24f94e570
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 4727 additions and 24 deletions

View File

@ -9,6 +9,7 @@ export default defineConfig({
"./src/db/schema-ai-config.ts",
"./src/db/schema-theme.ts",
"./src/db/schema-google.ts",
"./src/db/schema-dashboards.ts",
],
out: "./drizzle",
dialect: "sqlite",

12
drizzle/0018_left_veda.sql Executable file
View File

@ -0,0 +1,12 @@
CREATE TABLE `custom_dashboards` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`name` text NOT NULL,
`description` text DEFAULT '' NOT NULL,
`spec_data` text NOT NULL,
`queries` text NOT NULL,
`render_prompt` text NOT NULL,
`created_at` text NOT NULL,
`updated_at` text NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);

3604
drizzle/meta/0018_snapshot.json Executable file

File diff suppressed because it is too large Load Diff

View File

@ -127,6 +127,13 @@
"when": 1770442889458,
"tag": "0017_outstanding_colonel_america",
"breakpoints": true
},
{
"idx": 18,
"version": "6",
"when": 1770471997491,
"tag": "0018_left_veda",
"breakpoints": true
}
]
}

336
src/app/actions/dashboards.ts Executable file
View File

@ -0,0 +1,336 @@
"use server"
import { eq, and, desc } from "drizzle-orm"
import { getCloudflareContext } from "@opennextjs/cloudflare"
import { getDb } from "@/db"
import { customDashboards } from "@/db/schema-dashboards"
import { getCurrentUser } from "@/lib/auth"
import { revalidatePath } from "next/cache"
const MAX_DASHBOARDS = 5
interface SavedQuery {
readonly key: string
readonly queryType: string
readonly id?: string
readonly search?: string
readonly limit?: number
}
export async function getCustomDashboards(): Promise<
| {
readonly success: true
readonly data: ReadonlyArray<{
readonly id: string
readonly name: string
readonly description: string
readonly updatedAt: string
}>
}
| { readonly success: false; readonly error: string }
> {
const user = await getCurrentUser()
if (!user) return { success: false, error: "not authenticated" }
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
const dashboards = await db.query.customDashboards.findMany({
where: (d, { eq: e }) => e(d.userId, user.id),
orderBy: (d) => desc(d.updatedAt),
columns: {
id: true,
name: true,
description: true,
updatedAt: true,
},
})
return { success: true, data: dashboards }
}
export async function getCustomDashboardById(
dashboardId: string,
): Promise<
| {
readonly success: true
readonly data: {
readonly id: string
readonly name: string
readonly description: string
readonly specData: string
readonly queries: string
readonly renderPrompt: string
readonly updatedAt: string
}
}
| { readonly success: false; readonly error: string }
> {
const user = await getCurrentUser()
if (!user) return { success: false, error: "not authenticated" }
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
const dashboard = await db.query.customDashboards.findFirst({
where: (d, { eq: e, and: a }) =>
a(e(d.id, dashboardId), e(d.userId, user.id)),
})
if (!dashboard) {
return { success: false, error: "dashboard not found" }
}
return {
success: true,
data: {
id: dashboard.id,
name: dashboard.name,
description: dashboard.description,
specData: dashboard.specData,
queries: dashboard.queries,
renderPrompt: dashboard.renderPrompt,
updatedAt: dashboard.updatedAt,
},
}
}
export async function saveCustomDashboard(
name: string,
description: string,
specData: string,
queries: string,
renderPrompt: string,
existingId?: string,
): Promise<
| { readonly success: true; readonly id: string }
| { readonly success: false; readonly error: string }
> {
const user = await getCurrentUser()
if (!user) return { success: false, error: "not authenticated" }
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
const now = new Date().toISOString()
const id = existingId ?? crypto.randomUUID()
if (existingId) {
const existing = await db.query.customDashboards.findFirst({
where: (d, { eq: e, and: a }) =>
a(e(d.id, existingId), e(d.userId, user.id)),
})
if (!existing) {
return { success: false, error: "dashboard not found" }
}
await db
.update(customDashboards)
.set({
name,
description,
specData,
queries,
renderPrompt,
updatedAt: now,
})
.where(eq(customDashboards.id, existingId))
} else {
const count = await db.query.customDashboards.findMany({
where: (d, { eq: e }) => e(d.userId, user.id),
columns: { id: true },
})
if (count.length >= MAX_DASHBOARDS) {
return {
success: false,
error: `Maximum of ${MAX_DASHBOARDS} dashboards reached. Delete one to create a new one.`,
}
}
await db.insert(customDashboards).values({
id,
userId: user.id,
name,
description,
specData,
queries,
renderPrompt,
createdAt: now,
updatedAt: now,
})
}
revalidatePath("/", "layout")
return { success: true, id }
}
export async function deleteCustomDashboard(
dashboardId: string,
): Promise<
| { readonly success: true }
| { readonly success: false; readonly error: string }
> {
const user = await getCurrentUser()
if (!user) return { success: false, error: "not authenticated" }
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
const existing = await db.query.customDashboards.findFirst({
where: (d, { eq: e, and: a }) =>
a(e(d.id, dashboardId), e(d.userId, user.id)),
})
if (!existing) {
return { success: false, error: "dashboard not found" }
}
await db
.delete(customDashboards)
.where(
and(
eq(customDashboards.id, dashboardId),
eq(customDashboards.userId, user.id),
),
)
revalidatePath("/", "layout")
return { success: true }
}
export async function executeDashboardQueries(
queriesJson: string,
): Promise<
| {
readonly success: true
readonly data: Record<string, unknown>
}
| { readonly success: false; readonly error: string }
> {
const user = await getCurrentUser()
if (!user) return { success: false, error: "not authenticated" }
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
let queries: ReadonlyArray<SavedQuery>
try {
queries = JSON.parse(queriesJson) as ReadonlyArray<SavedQuery>
} catch {
return { success: false, error: "invalid queries JSON" }
}
const dataContext: Record<string, unknown> = {}
for (const q of queries) {
const cap = q.limit ?? 20
try {
switch (q.queryType) {
case "customers": {
const rows = await db.query.customers.findMany({
limit: cap,
...(q.search
? {
where: (c, { like }) =>
like(c.name, `%${q.search}%`),
}
: {}),
})
dataContext[q.key] = { data: rows, count: rows.length }
break
}
case "vendors": {
const rows = await db.query.vendors.findMany({
limit: cap,
...(q.search
? {
where: (v, { like }) =>
like(v.name, `%${q.search}%`),
}
: {}),
})
dataContext[q.key] = { data: rows, count: rows.length }
break
}
case "projects": {
const rows = await db.query.projects.findMany({
limit: cap,
...(q.search
? {
where: (p, { like }) =>
like(p.name, `%${q.search}%`),
}
: {}),
})
dataContext[q.key] = { data: rows, count: rows.length }
break
}
case "invoices": {
const rows = await db.query.invoices.findMany({
limit: cap,
})
dataContext[q.key] = { data: rows, count: rows.length }
break
}
case "vendor_bills": {
const rows = await db.query.vendorBills.findMany({
limit: cap,
})
dataContext[q.key] = { data: rows, count: rows.length }
break
}
case "schedule_tasks": {
const rows = await db.query.scheduleTasks.findMany({
limit: cap,
...(q.search
? {
where: (t, { like }) =>
like(t.title, `%${q.search}%`),
}
: {}),
})
dataContext[q.key] = { data: rows, count: rows.length }
break
}
case "project_detail": {
if (q.id) {
const row = await db.query.projects.findFirst({
where: (p, { eq: e }) => e(p.id, q.id!),
})
dataContext[q.key] = row
? { data: row }
: { error: "not found" }
}
break
}
case "customer_detail": {
if (q.id) {
const row = await db.query.customers.findFirst({
where: (c, { eq: e }) => e(c.id, q.id!),
})
dataContext[q.key] = row
? { data: row }
: { error: "not found" }
}
break
}
case "vendor_detail": {
if (q.id) {
const row = await db.query.vendors.findFirst({
where: (v, { eq: e }) => e(v.id, q.id!),
})
dataContext[q.key] = row
? { data: row }
: { error: "not found" }
}
break
}
default:
dataContext[q.key] = { error: "unknown query type" }
}
} catch (err) {
dataContext[q.key] = {
error: err instanceof Error ? err.message : "query failed",
}
}
}
return { success: true, data: dataContext }
}

View File

@ -44,10 +44,16 @@ export async function POST(req: Request): Promise<Response> {
)
}
const [memories, registry] = await Promise.all([
loadMemoriesForPrompt(db, user.id),
getRegistry(db, envRecord),
])
const { getCustomDashboards } = await import(
"@/app/actions/dashboards"
)
const [memories, registry, dashboardResult] =
await Promise.all([
loadMemoriesForPrompt(db, user.id),
getRegistry(db, envRecord),
getCustomDashboards(),
])
const pluginSections = registry.getPromptSections()
const pluginTools = registry.getTools()
@ -84,6 +90,9 @@ export async function POST(req: Request): Promise<Response> {
timezone,
memories,
pluginSections,
dashboards: dashboardResult.success
? dashboardResult.data
: [],
mode: "full",
}),
messages: await convertToModelMessages(

View File

@ -0,0 +1,44 @@
import { notFound } from "next/navigation"
import {
getCustomDashboardById,
executeDashboardQueries,
} from "@/app/actions/dashboards"
import { SavedDashboardView } from "@/components/saved-dashboard-view"
interface Props {
readonly params: Promise<{ readonly id: string }>
}
export default async function SavedDashboardPage({
params,
}: Props) {
const { id } = await params
const result = await getCustomDashboardById(id)
if (!result.success) notFound()
const dashboard = result.data
const spec = JSON.parse(dashboard.specData)
let dataContext: Record<string, unknown> = {}
if (dashboard.queries) {
const queryResult = await executeDashboardQueries(
dashboard.queries,
)
if (queryResult.success) {
dataContext = queryResult.data
}
}
return (
<SavedDashboardView
dashboard={{
id: dashboard.id,
name: dashboard.name,
description: dashboard.description,
}}
spec={spec}
dataContext={dataContext}
/>
)
}

View File

@ -17,6 +17,7 @@ import {
SidebarProvider,
} from "@/components/ui/sidebar"
import { getProjects } from "@/app/actions/projects"
import { getCustomDashboards } from "@/app/actions/dashboards"
import { ProjectListProvider } from "@/components/project-list-provider"
import { getCurrentUser, toSidebarUser } from "@/lib/auth"
import { BiometricGuard } from "@/components/native/biometric-guard"
@ -29,11 +30,16 @@ export default async function DashboardLayout({
}: {
readonly children: React.ReactNode
}) {
const [projectList, authUser] = await Promise.all([
getProjects(),
getCurrentUser(),
])
const [projectList, authUser, dashboardResult] =
await Promise.all([
getProjects(),
getCurrentUser(),
getCustomDashboards(),
])
const user = authUser ? toSidebarUser(authUser) : null
const dashboardList = dashboardResult.success
? dashboardResult.data
: []
return (
<SettingsProvider>
@ -51,7 +57,7 @@ export default async function DashboardLayout({
} as React.CSSProperties
}
>
<AppSidebar variant="inset" projects={projectList} user={user} />
<AppSidebar variant="inset" projects={projectList} dashboards={dashboardList} user={user} />
<FeedbackWidget>
<SidebarInset className="overflow-hidden">
<OfflineBanner />

View File

@ -78,6 +78,10 @@ interface RenderContextValue {
data: Record<string, unknown>
) => void
clearRender: () => void
loadSpec: (
spec: Spec,
data: Record<string, unknown>
) => void
}
const RenderContext =
@ -171,6 +175,8 @@ export function ChatProvider({
const [dataContext, setDataContext] = React.useState<
Record<string, unknown>
>({})
const [loadedSpec, setLoadedSpec] =
React.useState<Spec | null>(null)
const router = useRouter()
const pathname = usePathname()
@ -228,13 +234,19 @@ export function ChatProvider({
const routerRef = React.useRef(router)
routerRef.current = router
const loadedSpecRef = React.useRef(loadedSpec)
loadedSpecRef.current = loadedSpec
const triggerRender = React.useCallback(
(prompt: string, data: Record<string, unknown>) => {
setDataContext(data)
setLoadedSpec(null)
renderSendRef.current(prompt, {
dataContext: data,
previousSpec:
renderSpecRef.current ?? undefined,
renderSpecRef.current ??
loadedSpecRef.current ??
undefined,
})
},
[]
@ -243,8 +255,18 @@ export function ChatProvider({
const clearRender = React.useCallback(() => {
renderClearRef.current()
setDataContext({})
setLoadedSpec(null)
}, [])
const loadSpec = React.useCallback(
(spec: Spec, data: Record<string, unknown>) => {
renderClearRef.current()
setLoadedSpec(spec)
setDataContext(data)
},
[],
)
// watch chat messages for generateUI tool results
// and trigger render stream directly (no event chain)
const renderDispatchedRef = React.useRef(
@ -275,6 +297,118 @@ export function ChatProvider({
triggerRender(result.renderPrompt, result.dataContext)
}, [chat.messages, triggerRender])
// listen for save-dashboard events from tool dispatch
React.useEffect(() => {
const handler = async (e: Event) => {
const detail = (e as CustomEvent).detail as {
name?: string
description?: string
dashboardId?: string
}
if (!detail?.name) return
const currentSpec =
renderSpecRef.current ?? loadedSpecRef.current
if (!currentSpec) return
const { saveCustomDashboard } = await import(
"@/app/actions/dashboards"
)
const result = await saveCustomDashboard(
detail.name,
detail.description ?? "",
JSON.stringify(currentSpec),
JSON.stringify([]),
detail.name,
detail.dashboardId,
)
if (result.success) {
window.dispatchEvent(
new CustomEvent("agent-toast", {
detail: {
message: `Dashboard "${detail.name}" saved`,
type: "success",
},
})
)
} else {
window.dispatchEvent(
new CustomEvent("agent-toast", {
detail: {
message: result.error,
type: "error",
},
})
)
}
}
window.addEventListener(
"agent-save-dashboard",
handler
)
return () =>
window.removeEventListener(
"agent-save-dashboard",
handler
)
}, [])
// listen for load-dashboard events from tool dispatch
React.useEffect(() => {
const handler = async (e: Event) => {
const detail = (e as CustomEvent).detail as {
dashboardId?: string
spec?: Spec
queries?: string
renderPrompt?: string
editPrompt?: string
}
if (!detail?.spec) return
// run saved queries for fresh data
let freshData: Record<string, unknown> = {}
if (detail.queries) {
const { executeDashboardQueries } = await import(
"@/app/actions/dashboards"
)
const result = await executeDashboardQueries(
detail.queries,
)
if (result.success) {
freshData = result.data
}
}
loadSpec(detail.spec, freshData)
// navigate to /dashboard
if (pathnameRef.current !== "/dashboard") {
routerRef.current.push("/dashboard")
}
setIsOpen(true)
// if editPrompt provided, trigger re-render
if (detail.editPrompt) {
setTimeout(() => {
triggerRender(detail.editPrompt!, freshData)
}, 100)
}
}
window.addEventListener(
"agent-load-dashboard",
handler
)
return () =>
window.removeEventListener(
"agent-load-dashboard",
handler
)
}, [loadSpec, triggerRender])
// listen for navigation events from rendered UI
React.useEffect(() => {
const handler = (e: Event) => {
@ -347,6 +481,7 @@ export function ChatProvider({
setConversationId(crypto.randomUUID())
setResumeLoaded(true)
clearRender()
setLoadedSpec(null)
renderDispatchedRef.current.clear()
}, [chat.setMessages, clearRender])
@ -389,20 +524,23 @@ export function ChatProvider({
const renderValue = React.useMemo(
() => ({
spec: renderStream.spec,
spec: renderStream.spec ?? loadedSpec,
isRendering: renderStream.isStreaming,
error: renderStream.error,
dataContext,
triggerRender,
clearRender,
loadSpec,
}),
[
renderStream.spec,
loadedSpec,
renderStream.isStreaming,
renderStream.error,
dataContext,
triggerRender,
clearRender,
loadSpec,
]
)

View File

@ -1,9 +1,24 @@
"use client"
import { XIcon, Loader2Icon } from "lucide-react"
import * as React from "react"
import {
XIcon,
Loader2Icon,
BookmarkIcon,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { useRenderState } from "./chat-provider"
import { CompassRenderer } from "@/lib/agent/render/compass-renderer"
import { saveCustomDashboard } from "@/app/actions/dashboards"
export function RenderedView() {
const {
@ -14,8 +29,46 @@ export function RenderedView() {
clearRender,
} = useRenderState()
const [saveOpen, setSaveOpen] = React.useState(false)
const [saveName, setSaveName] = React.useState("")
const [saving, setSaving] = React.useState(false)
const hasRoot = !!spec?.root
const handleSave = async () => {
if (!saveName.trim() || !spec) return
setSaving(true)
const result = await saveCustomDashboard(
saveName.trim(),
"",
JSON.stringify(spec),
JSON.stringify([]),
saveName.trim(),
)
setSaving(false)
if (result.success) {
setSaveOpen(false)
setSaveName("")
window.dispatchEvent(
new CustomEvent("agent-toast", {
detail: {
message: `Dashboard "${saveName.trim()}" saved`,
type: "success",
},
})
)
} else {
window.dispatchEvent(
new CustomEvent("agent-toast", {
detail: {
message: result.error,
type: "error",
},
})
)
}
}
return (
<div className="flex flex-1 flex-col min-h-0 p-4">
{/* Header bar */}
@ -31,17 +84,68 @@ export function RenderedView() {
<span>Generated by Slab</span>
)}
</div>
<Button
variant="ghost"
size="sm"
onClick={clearRender}
className="gap-1.5"
>
<XIcon className="size-4" />
Clear
</Button>
<div className="flex items-center gap-1">
{hasRoot && !isRendering && (
<Button
variant="ghost"
size="sm"
onClick={() => setSaveOpen(true)}
className="gap-1.5"
>
<BookmarkIcon className="size-4" />
Save as Dashboard
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={clearRender}
className="gap-1.5"
>
<XIcon className="size-4" />
Clear
</Button>
</div>
</div>
<Dialog open={saveOpen} onOpenChange={setSaveOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Save as Dashboard</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="dashboard-name">Name</Label>
<Input
id="dashboard-name"
placeholder="Project Overview"
value={saveName}
onChange={(e) =>
setSaveName(e.target.value)
}
onKeyDown={(e) => {
if (e.key === "Enter") handleSave()
}}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setSaveOpen(false)}
>
Cancel
</Button>
<Button
onClick={handleSave}
disabled={!saveName.trim() || saving}
>
{saving ? "Saving..." : "Save"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Rendered content */}
<div className="flex-1 overflow-auto">
<div className="mx-auto max-w-6xl">

View File

@ -18,6 +18,7 @@ import {
import { usePathname } from "next/navigation"
import { NavMain } from "@/components/nav-main"
import { NavDashboards } from "@/components/nav-dashboards"
import { NavSecondary } from "@/components/nav-secondary"
import { NavFiles } from "@/components/nav-files"
import { NavProjects } from "@/components/nav-projects"
@ -96,8 +97,13 @@ const data = {
function SidebarNav({
projects,
dashboards = [],
}: {
projects: { id: string; name: string }[]
dashboards?: ReadonlyArray<{
readonly id: string
readonly name: string
}>
}) {
const pathname = usePathname()
const { state, setOpen } = useSidebar()
@ -146,6 +152,7 @@ function SidebarNav({
{mode === "main" && (
<>
<NavMain items={data.navMain} />
<NavDashboards dashboards={dashboards} />
<NavSecondary items={secondaryItems} className="mt-auto" />
</>
)}
@ -155,10 +162,12 @@ function SidebarNav({
export function AppSidebar({
projects = [],
dashboards = [],
user,
...props
}: React.ComponentProps<typeof Sidebar> & {
readonly projects?: ReadonlyArray<{ readonly id: string; readonly name: string }>
readonly dashboards?: ReadonlyArray<{ readonly id: string; readonly name: string }>
readonly user: SidebarUser | null
}) {
return (
@ -192,7 +201,10 @@ export function AppSidebar({
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<SidebarNav projects={projects as { id: string; name: string }[]} />
<SidebarNav
projects={projects as { id: string; name: string }[]}
dashboards={dashboards}
/>
</SidebarContent>
<SidebarFooter>
<NavUser user={user} />

View File

@ -0,0 +1,49 @@
"use client"
import { IconLayoutDashboard } from "@tabler/icons-react"
import Link from "next/link"
import {
SidebarGroup,
SidebarGroupLabel,
SidebarGroupContent,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar"
interface Dashboard {
readonly id: string
readonly name: string
}
export function NavDashboards({
dashboards,
}: {
readonly dashboards: ReadonlyArray<Dashboard>
}) {
if (dashboards.length === 0) return null
return (
<SidebarGroup>
<SidebarGroupLabel>Dashboards</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{dashboards.map((d) => (
<SidebarMenuItem key={d.id}>
<SidebarMenuButton
asChild
tooltip={d.name}
>
<Link href={`/dashboard/boards/${d.id}`}>
<IconLayoutDashboard />
<span>{d.name}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
)
}

View File

@ -0,0 +1,117 @@
"use client"
import * as React from "react"
import { useRouter } from "next/navigation"
import {
RefreshCwIcon,
Trash2Icon,
Loader2Icon,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import { CompassRenderer } from "@/lib/agent/render/compass-renderer"
import {
deleteCustomDashboard,
executeDashboardQueries,
} from "@/app/actions/dashboards"
import type { Spec } from "@json-render/react"
interface SavedDashboardViewProps {
readonly dashboard: {
readonly id: string
readonly name: string
readonly description: string
}
readonly spec: Spec
readonly dataContext: Record<string, unknown>
}
export function SavedDashboardView({
dashboard,
spec,
dataContext: initialData,
}: SavedDashboardViewProps) {
const router = useRouter()
const [refreshing, setRefreshing] = React.useState(false)
const [deleting, setDeleting] = React.useState(false)
const [data, setData] =
React.useState<Record<string, unknown>>(initialData)
const handleRefresh = async () => {
setRefreshing(true)
const result = await executeDashboardQueries(
JSON.stringify([]),
)
if (result.success) {
setData(result.data)
}
setRefreshing(false)
}
const handleDelete = async () => {
if (!confirm("Delete this dashboard?")) return
setDeleting(true)
const result = await deleteCustomDashboard(dashboard.id)
if (result.success) {
router.push("/dashboard")
} else {
setDeleting(false)
window.dispatchEvent(
new CustomEvent("agent-toast", {
detail: {
message: result.error,
type: "error",
},
})
)
}
}
return (
<div className="flex flex-1 flex-col min-h-0 p-4">
<div className="flex items-center justify-between mb-4">
<div>
<h1 className="text-lg font-semibold">
{dashboard.name}
</h1>
{dashboard.description && (
<p className="text-sm text-muted-foreground">
{dashboard.description}
</p>
)}
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={handleRefresh}
disabled={refreshing}
className="gap-1.5"
>
{refreshing ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
<RefreshCwIcon className="size-4" />
)}
Refresh
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleDelete}
disabled={deleting}
className="gap-1.5 text-destructive hover:text-destructive"
>
<Trash2Icon className="size-4" />
Delete
</Button>
</div>
</div>
<div className="flex-1 overflow-auto">
<div className="mx-auto max-w-6xl">
<CompassRenderer spec={spec} data={data} />
</div>
</div>
</div>
)
}

View File

@ -6,6 +6,7 @@ import * as agentSchema from "./schema-agent"
import * as aiConfigSchema from "./schema-ai-config"
import * as themeSchema from "./schema-theme"
import * as googleSchema from "./schema-google"
import * as dashboardSchema from "./schema-dashboards"
const allSchemas = {
...schema,
@ -15,6 +16,7 @@ const allSchemas = {
...aiConfigSchema,
...themeSchema,
...googleSchema,
...dashboardSchema,
}
export function getDb(d1: D1Database) {

24
src/db/schema-dashboards.ts Executable file
View File

@ -0,0 +1,24 @@
import { sqliteTable, text } from "drizzle-orm/sqlite-core"
import { users } from "./schema"
export const customDashboards = sqliteTable(
"custom_dashboards",
{
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
name: text("name").notNull(),
description: text("description").notNull().default(""),
specData: text("spec_data").notNull(),
queries: text("queries").notNull(),
renderPrompt: text("render_prompt").notNull(),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull(),
},
)
export type CustomDashboard =
typeof customDashboards.$inferSelect
export type NewCustomDashboard =
typeof customDashboards.$inferInsert

View File

@ -134,6 +134,26 @@ export function initializeActionHandlers(
}
})
registerActionHandler("SAVE_DASHBOARD", (payload) => {
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent("agent-save-dashboard", {
detail: payload,
})
)
}
})
registerActionHandler("LOAD_DASHBOARD", (payload) => {
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent("agent-load-dashboard", {
detail: payload,
})
)
}
})
registerActionHandler("APPLY_THEME", (payload) => {
if (typeof window !== "undefined") {
window.dispatchEvent(
@ -163,6 +183,8 @@ export const ALL_HANDLER_TYPES = [
"SCROLL_TO",
"FOCUS_ELEMENT",
"GENERATE_UI",
"SAVE_DASHBOARD",
"LOAD_DASHBOARD",
"APPLY_THEME",
"PREVIEW_THEME",
] as const
@ -227,6 +249,28 @@ export function dispatchToolActions(
},
})
break
case "save_dashboard":
executeAction({
type: "SAVE_DASHBOARD",
payload: {
name: output.name,
description: output.description,
dashboardId: output.dashboardId,
},
})
break
case "load_dashboard":
executeAction({
type: "LOAD_DASHBOARD",
payload: {
dashboardId: output.dashboardId,
spec: output.spec,
queries: output.queries,
renderPrompt: output.renderPrompt,
editPrompt: output.editPrompt,
},
})
break
case "apply_theme":
executeAction({
type: "APPLY_THEME",

View File

@ -21,6 +21,12 @@ interface ToolMeta {
readonly adminOnly?: true
}
interface DashboardSummary {
readonly id: string
readonly name: string
readonly description: string
}
interface PromptContext {
readonly userName: string
readonly userRole: string
@ -28,6 +34,7 @@ interface PromptContext {
readonly memories?: string
readonly timezone?: string
readonly pluginSections?: ReadonlyArray<PromptSection>
readonly dashboards?: ReadonlyArray<DashboardSummary>
readonly mode?: PromptMode
}
@ -60,7 +67,8 @@ const TOOL_REGISTRY: ReadonlyArray<ToolMeta> = [
"/dashboard/projects/{id}/schedule, " +
"/dashboard/customers, /dashboard/vendors, " +
"/dashboard/financials, /dashboard/people, " +
"/dashboard/files. If the page doesn't exist, " +
"/dashboard/files, /dashboard/boards/{id}. " +
"If the page doesn't exist, " +
"tell the user what's available.",
category: "navigation",
},
@ -184,6 +192,36 @@ const TOOL_REGISTRY: ReadonlyArray<ToolMeta> = [
"(not presets).",
category: "ui",
},
{
name: "saveDashboard",
summary:
"Save the current rendered UI as a named dashboard. " +
"The client captures the spec and data automatically. " +
"Pass dashboardId to update an existing dashboard.",
category: "ui",
},
{
name: "listDashboards",
summary:
"List the user's saved custom dashboards with " +
"their IDs, names, and descriptions.",
category: "ui",
},
{
name: "editDashboard",
summary:
"Load a saved dashboard for editing. Loads the spec " +
"into the render context on /dashboard. Optionally " +
"pass editPrompt to trigger immediate re-generation.",
category: "ui",
},
{
name: "deleteDashboard",
summary:
"Delete a saved dashboard. Always confirm with " +
"the user before deleting.",
category: "ui",
},
]
// categories included in minimal mode
@ -306,6 +344,8 @@ function buildFirstInteraction(
'show you recent commits, PRs, issues, and contributor activity."',
'"I can also conduct a quick UX interview if you\'d like ' +
'to share feedback about Compass."',
'"I can build you a custom dashboard with charts and ' +
'stats — and save it so you can access it anytime."',
]
return [
@ -554,6 +594,56 @@ function buildThemingRules(
]
}
function buildDashboardRules(
ctx: PromptContext,
mode: PromptMode,
): ReadonlyArray<string> {
if (mode !== "full") return []
const lines = [
"## Custom Dashboards",
"Users can save generated UIs as persistent dashboards " +
"that appear in the sidebar and can be revisited anytime.",
"",
"**Workflow:**",
"1. User asks for a dashboard (e.g. \"build me a " +
"project overview\")",
"2. Use queryData to fetch data, then generateUI to " +
"build the UI",
"3. Once the user is happy, use saveDashboard to persist it",
"4. The dashboard appears in the sidebar at " +
"/dashboard/boards/{id}",
"",
"**Editing:**",
"- Use editDashboard to load a saved dashboard for editing",
"- After loading, use generateUI to make changes " +
"(the system sends patches against the previous spec)",
"- Use saveDashboard with the dashboardId to save updates",
"",
"**Limits:**",
"- Maximum 5 dashboards per user",
"- If the user hits the limit, suggest deleting one first",
"",
"**When to offer dashboard saving:**",
"- After generating a useful UI the user seems happy with",
'- When the user says "save this" or "keep this"',
"- Don't push it — offer once after a good generation",
]
if (ctx.dashboards?.length) {
lines.push(
"",
"**User's saved dashboards:**",
...ctx.dashboards.map(
(d) => `- ${d.name} (id: ${d.id})` +
(d.description ? `: ${d.description}` : ""),
),
)
}
return lines
}
function buildGuidelines(
mode: PromptMode,
): ReadonlyArray<string> {
@ -622,6 +712,7 @@ export function buildSystemPrompt(ctx: PromptContext): string {
buildInterviewProtocol(state.mode),
buildGitHubGuidance(state.mode),
buildThemingRules(state.mode),
buildDashboardRules(ctx, state.mode),
buildGuidelines(state.mode),
buildPluginSections(ctx.pluginSections, state.mode),
]

View File

@ -16,6 +16,11 @@ import {
saveCustomTheme,
setUserThemePreference,
} from "@/app/actions/themes"
import {
getCustomDashboards,
getCustomDashboardById,
deleteCustomDashboard,
} from "@/app/actions/dashboards"
import { THEME_PRESETS, findPreset } from "@/lib/theme/presets"
import type { ThemeDefinition, ColorMap, ThemeFonts, ThemeTokens, ThemeShadows } from "@/lib/theme/types"
@ -58,6 +63,7 @@ const VALID_ROUTES: ReadonlyArray<RegExp> = [
/^\/dashboard\/people$/,
/^\/dashboard\/files$/,
/^\/dashboard\/files\/.+$/,
/^\/dashboard\/boards\/[^/]+$/,
]
function isValidRoute(path: string): boolean {
@ -275,7 +281,7 @@ export const agentTools = {
"/dashboard/projects/{id}/schedule, " +
"/dashboard/customers, /dashboard/vendors, " +
"/dashboard/financials, /dashboard/people, " +
"/dashboard/files",
"/dashboard/files, /dashboard/boards/{id}",
}
}
return {
@ -588,6 +594,103 @@ export const agentTools = {
},
}),
saveDashboard: tool({
description:
"Save the currently rendered UI as a named dashboard. " +
"The client captures the current spec and data context " +
"automatically. Returns an action for the client to " +
"handle the save.",
inputSchema: z.object({
name: z.string().describe("Dashboard display name"),
description: z.string().optional().describe(
"Brief description of the dashboard",
),
dashboardId: z.string().optional().describe(
"Existing dashboard ID to update (for edits)",
),
}),
execute: async (input: {
name: string
description?: string
dashboardId?: string
}) => ({
action: "save_dashboard" as const,
name: input.name,
description: input.description ?? "",
dashboardId: input.dashboardId,
}),
}),
listDashboards: tool({
description:
"List the user's saved custom dashboards.",
inputSchema: z.object({}),
execute: async () => {
const result = await getCustomDashboards()
if (!result.success) return { error: result.error }
return {
dashboards: result.data,
count: result.data.length,
}
},
}),
editDashboard: tool({
description:
"Load a saved dashboard for editing. The client " +
"injects the spec into the render context and " +
"navigates to /dashboard. Optionally pass an " +
"editPrompt to trigger immediate re-generation.",
inputSchema: z.object({
dashboardId: z.string().describe(
"ID of the dashboard to edit",
),
editPrompt: z.string().optional().describe(
"Description of changes to make",
),
}),
execute: async (input: {
dashboardId: string
editPrompt?: string
}) => {
const result = await getCustomDashboardById(
input.dashboardId,
)
if (!result.success) return { error: result.error }
return {
action: "load_dashboard" as const,
dashboardId: input.dashboardId,
spec: JSON.parse(result.data.specData),
queries: result.data.queries,
renderPrompt: result.data.renderPrompt,
editPrompt: input.editPrompt,
}
},
}),
deleteDashboard: tool({
description:
"Delete a saved dashboard. Always confirm with " +
"the user before deleting.",
inputSchema: z.object({
dashboardId: z.string().describe(
"ID of the dashboard to delete",
),
}),
execute: async (input: { dashboardId: string }) => {
const result = await deleteCustomDashboard(
input.dashboardId,
)
if (!result.success) return { error: result.error }
return {
action: "toast" as const,
message: "Dashboard deleted",
type: "success",
}
},
}),
editTheme: tool({
description:
"Edit an existing custom theme. Provide the theme ID " +