feat(phase6c): insight inbox actions + filters + keyboard shortcuts + budget UI + prompt registry view
Server actions (apps/web/src/app/insights/_actions.ts): - setStatus / bulkSetStatus (11 allowed states) - setFounderNotes / setFounderPriority / setSeverityOverride - updateBudgetSetting (key whitelist) All audit-logged + revalidate /insights paths. Inbox enhancements: - Filter bar: severity / status (active default = new+triaged+in_backlog+in_progress+regressed) / type chips with live counts, query-param driven - Per-row action buttons: Backlog / Defer / Dismiss with toast feedback - Keyboard shortcuts (j/k navigate, Enter open, b backlog, e defer, d dismiss, ? help) Skipped when input/textarea focused. Insight detail (/insights/i/[id]): - FounderForm component: 6 status buttons, severity override dropdown, founder_priority pin input, founder_notes textarea (saved separately) Budget settings (/insights/settings/budgets): - Editable rows for monthly/daily caps, per-call max, min_score_for_analysis, cache_ttl_hours, analysis_paused (kill switch) Prompt registry (read-only, full editor in 6e): - /insights/settings/prompts list - /insights/settings/prompts/[id] detail w/ system prompt, user template, output schema, recent cost ledger rows for this template Cmd+K palette extended with Insights group: - Inbox, Cost dashboard, Pipeline, Budget settings, Prompt registry Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
147
apps/web/src/app/insights/_actions.ts
Normal file
147
apps/web/src/app/insights/_actions.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { headers } from "next/headers";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { writeAudit } from "@/lib/audit";
|
||||
|
||||
const ALLOWED_STATUS = new Set([
|
||||
"new",
|
||||
"triaged",
|
||||
"in_backlog",
|
||||
"in_progress",
|
||||
"shipped",
|
||||
"validating",
|
||||
"validated",
|
||||
"deferred",
|
||||
"dismissed",
|
||||
"duplicate",
|
||||
"regressed",
|
||||
]);
|
||||
|
||||
async function requireSession() {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session) throw new Error("unauthenticated");
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function setStatus(insightId: string, status: string) {
|
||||
await requireSession();
|
||||
if (!ALLOWED_STATUS.has(status)) throw new Error(`bad status: ${status}`);
|
||||
await prisma.insight.update({
|
||||
where: { id: insightId },
|
||||
data: { status },
|
||||
});
|
||||
await writeAudit({
|
||||
endpoint: `/insights/${insightId}/status`,
|
||||
method: "POST",
|
||||
requestPayload: { status },
|
||||
responseStatus: 200,
|
||||
});
|
||||
revalidatePath("/insights");
|
||||
revalidatePath(`/insights/i/${insightId}`);
|
||||
}
|
||||
|
||||
export async function setFounderNotes(insightId: string, notes: string) {
|
||||
await requireSession();
|
||||
await prisma.insight.update({
|
||||
where: { id: insightId },
|
||||
data: { founderNotes: notes.slice(0, 4000) || null },
|
||||
});
|
||||
await writeAudit({
|
||||
endpoint: `/insights/${insightId}/notes`,
|
||||
method: "POST",
|
||||
requestPayload: { length: notes.length },
|
||||
responseStatus: 200,
|
||||
});
|
||||
revalidatePath(`/insights/i/${insightId}`);
|
||||
}
|
||||
|
||||
export async function setFounderPriority(insightId: string, priority: number | null) {
|
||||
await requireSession();
|
||||
await prisma.insight.update({
|
||||
where: { id: insightId },
|
||||
data: { founderPriority: priority },
|
||||
});
|
||||
await writeAudit({
|
||||
endpoint: `/insights/${insightId}/priority`,
|
||||
method: "POST",
|
||||
requestPayload: { priority },
|
||||
responseStatus: 200,
|
||||
});
|
||||
revalidatePath("/insights");
|
||||
revalidatePath(`/insights/i/${insightId}`);
|
||||
}
|
||||
|
||||
export async function setSeverityOverride(insightId: string, severity: string | null) {
|
||||
await requireSession();
|
||||
if (severity !== null && !["P0", "P1", "P2", "P3", "INFO"].includes(severity)) {
|
||||
throw new Error("bad severity");
|
||||
}
|
||||
await prisma.insight.update({
|
||||
where: { id: insightId },
|
||||
data: { founderSeverityOverride: severity, severity: severity ?? undefined },
|
||||
});
|
||||
await writeAudit({
|
||||
endpoint: `/insights/${insightId}/severity`,
|
||||
method: "POST",
|
||||
requestPayload: { severity },
|
||||
responseStatus: 200,
|
||||
});
|
||||
revalidatePath("/insights");
|
||||
revalidatePath(`/insights/i/${insightId}`);
|
||||
}
|
||||
|
||||
export async function bulkSetStatus(insightIds: string[], status: string) {
|
||||
await requireSession();
|
||||
if (!ALLOWED_STATUS.has(status)) throw new Error("bad status");
|
||||
const ids = insightIds.filter(Boolean).slice(0, 200);
|
||||
await prisma.insight.updateMany({
|
||||
where: { id: { in: ids } },
|
||||
data: { status },
|
||||
});
|
||||
await writeAudit({
|
||||
endpoint: `/insights/bulk/status`,
|
||||
method: "POST",
|
||||
requestPayload: { count: ids.length, status },
|
||||
responseStatus: 200,
|
||||
});
|
||||
revalidatePath("/insights");
|
||||
}
|
||||
|
||||
export async function updateBudgetSetting(key: string, value: number | boolean) {
|
||||
await requireSession();
|
||||
const allowed = new Set([
|
||||
"monthly_hard_cap_usd",
|
||||
"daily_soft_cap_usd",
|
||||
"daily_hard_cap_usd",
|
||||
"per_call_max_usd",
|
||||
"min_score_for_analysis",
|
||||
"cache_ttl_hours",
|
||||
"analysis_paused",
|
||||
]);
|
||||
if (!allowed.has(key)) throw new Error("bad setting key");
|
||||
const existing = await prisma.budgetSetting.findFirst({
|
||||
where: { projectKey: null, settingKey: key },
|
||||
});
|
||||
if (existing) {
|
||||
await prisma.budgetSetting.update({
|
||||
where: { id: existing.id },
|
||||
data: { settingValue: value as unknown as object },
|
||||
});
|
||||
} else {
|
||||
await prisma.budgetSetting.create({
|
||||
data: { projectKey: null, settingKey: key, settingValue: value as unknown as object },
|
||||
});
|
||||
}
|
||||
await writeAudit({
|
||||
endpoint: `/insights/budget/${key}`,
|
||||
method: "POST",
|
||||
requestPayload: { value },
|
||||
responseStatus: 200,
|
||||
});
|
||||
revalidatePath("/insights/settings/budgets");
|
||||
revalidatePath("/insights/costs");
|
||||
revalidatePath("/insights");
|
||||
}
|
||||
93
apps/web/src/app/insights/_filter-bar.tsx
Normal file
93
apps/web/src/app/insights/_filter-bar.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter, useSearchParams, usePathname } from "next/navigation";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
const SEVERITIES = ["P0", "P1", "P2", "P3", "INFO"];
|
||||
const STATUSES = ["active", "new", "triaged", "in_backlog", "shipped", "validated", "deferred", "dismissed"];
|
||||
const TYPES = ["bug", "ux_friction", "payment", "onboarding", "provider_quality"];
|
||||
|
||||
export function FilterBar({
|
||||
severityCounts,
|
||||
statusCounts,
|
||||
typeCounts,
|
||||
}: {
|
||||
severityCounts: Record<string, number>;
|
||||
statusCounts: Record<string, number>;
|
||||
typeCounts: Record<string, number>;
|
||||
}) {
|
||||
const params = useSearchParams();
|
||||
const router = useRouter();
|
||||
const path = usePathname();
|
||||
|
||||
const setParam = (k: string, v: string | null) => {
|
||||
const sp = new URLSearchParams(params.toString());
|
||||
if (v === null || v === "") sp.delete(k);
|
||||
else sp.set(k, v);
|
||||
router.push(`${path}?${sp.toString()}`);
|
||||
};
|
||||
|
||||
const cur = (k: string) => params.get(k) ?? "";
|
||||
|
||||
return (
|
||||
<div className="space-y-2 rounded-md border p-3">
|
||||
<Row label="Severity">
|
||||
<Chip active={!cur("sev")} onClick={() => setParam("sev", null)}>
|
||||
all
|
||||
</Chip>
|
||||
{SEVERITIES.map((s) => (
|
||||
<Chip key={s} active={cur("sev") === s} onClick={() => setParam("sev", s)}>
|
||||
{s} <span className="opacity-60">({severityCounts[s] ?? 0})</span>
|
||||
</Chip>
|
||||
))}
|
||||
</Row>
|
||||
<Row label="Status">
|
||||
<Chip active={!cur("status") || cur("status") === "active"} onClick={() => setParam("status", null)}>
|
||||
active
|
||||
</Chip>
|
||||
{STATUSES.slice(1).map((s) => (
|
||||
<Chip key={s} active={cur("status") === s} onClick={() => setParam("status", s)}>
|
||||
{s} <span className="opacity-60">({statusCounts[s] ?? 0})</span>
|
||||
</Chip>
|
||||
))}
|
||||
</Row>
|
||||
<Row label="Type">
|
||||
<Chip active={!cur("type")} onClick={() => setParam("type", null)}>
|
||||
all
|
||||
</Chip>
|
||||
{TYPES.map((t) => (
|
||||
<Chip key={t} active={cur("type") === t} onClick={() => setParam("type", t)}>
|
||||
{t} <span className="opacity-60">({typeCounts[t] ?? 0})</span>
|
||||
</Chip>
|
||||
))}
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
<span className="w-16 text-muted-foreground">{label}</span>
|
||||
<div className="flex flex-wrap gap-1">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Chip({
|
||||
active,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button onClick={onClick} type="button" className="cursor-pointer">
|
||||
<Badge variant={active ? "default" : "outline"} className="font-mono text-[11px]">
|
||||
{children}
|
||||
</Badge>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
80
apps/web/src/app/insights/_inbox-row.tsx
Normal file
80
apps/web/src/app/insights/_inbox-row.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { TableCell, TableRow } from "@/components/ui/table";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { setStatus } from "./_actions";
|
||||
|
||||
type InsightRow = {
|
||||
id: string;
|
||||
severity: string;
|
||||
type: string;
|
||||
status: string;
|
||||
fingerprint: string;
|
||||
title: string;
|
||||
occurrenceCount: number;
|
||||
confidence: number | null;
|
||||
priorityScore: number;
|
||||
lastSeenAt: string;
|
||||
};
|
||||
|
||||
function severityVariant(sev: string): "default" | "destructive" | "secondary" | "outline" {
|
||||
if (sev === "P0" || sev === "P1") return "destructive";
|
||||
if (sev === "P2") return "default";
|
||||
return "outline";
|
||||
}
|
||||
|
||||
export function InboxRow({ row, focused }: { row: InsightRow; focused: boolean }) {
|
||||
const [pending, start] = useTransition();
|
||||
const [flash, setFlash] = useState<string | null>(null);
|
||||
|
||||
const act = (next: string, label: string) =>
|
||||
start(async () => {
|
||||
try {
|
||||
await setStatus(row.id, next);
|
||||
setFlash(label);
|
||||
setTimeout(() => setFlash(null), 2500);
|
||||
} catch (e) {
|
||||
setFlash(`err: ${(e as Error).message}`);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<TableRow data-row-id={row.id} className={focused ? "bg-muted/60" : ""}>
|
||||
<TableCell>
|
||||
<Badge variant={severityVariant(row.severity)}>{row.severity}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<a href={`/insights/i/${row.id}`} className="font-medium underline">
|
||||
{row.title}
|
||||
</a>
|
||||
<div className="font-mono text-[10px] text-muted-foreground">
|
||||
{row.fingerprint.slice(0, 16)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">{row.type}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{row.priorityScore}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{row.occurrenceCount}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{row.confidence?.toFixed(2) ?? "—"}</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{row.lastSeenAt.slice(0, 16).replace("T", " ")}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{row.status}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{flash && <span className="mr-2 text-xs text-muted-foreground">{flash}</span>}
|
||||
<Button size="sm" variant="outline" disabled={pending} onClick={() => act("in_backlog", "queued")}>
|
||||
Backlog
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" disabled={pending} onClick={() => act("deferred", "deferred")}>
|
||||
Defer
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" disabled={pending} onClick={() => act("dismissed", "dismissed")}>
|
||||
Dismiss
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
65
apps/web/src/app/insights/_inbox-shortcuts.tsx
Normal file
65
apps/web/src/app/insights/_inbox-shortcuts.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { setStatus } from "./_actions";
|
||||
|
||||
// Tiny keyboard navigation: j/k cycle rows, Enter opens, d=dismiss, e=defer, b=backlog.
|
||||
// Ignores when an input/textarea has focus.
|
||||
export function InboxShortcuts({ insightIds }: { insightIds: string[] }) {
|
||||
const router = useRouter();
|
||||
const [focusedIdx, setFocusedIdx] = useState<number>(-1);
|
||||
const [, start] = useTransition();
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (target && /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName)) return;
|
||||
if (insightIds.length === 0) return;
|
||||
|
||||
const i = focusedIdx < 0 ? 0 : focusedIdx;
|
||||
if (e.key === "j") {
|
||||
e.preventDefault();
|
||||
setFocusedIdx(Math.min(insightIds.length - 1, i + 1));
|
||||
scrollRowIntoView(insightIds[Math.min(insightIds.length - 1, i + 1)]);
|
||||
} else if (e.key === "k") {
|
||||
e.preventDefault();
|
||||
setFocusedIdx(Math.max(0, i - 1));
|
||||
scrollRowIntoView(insightIds[Math.max(0, i - 1)]);
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
router.push(`/insights/i/${insightIds[i]}`);
|
||||
} else if (e.key === "d" && focusedIdx >= 0) {
|
||||
e.preventDefault();
|
||||
start(() => setStatus(insightIds[i], "dismissed"));
|
||||
} else if (e.key === "e" && focusedIdx >= 0) {
|
||||
e.preventDefault();
|
||||
start(() => setStatus(insightIds[i], "deferred"));
|
||||
} else if (e.key === "b" && focusedIdx >= 0) {
|
||||
e.preventDefault();
|
||||
start(() => setStatus(insightIds[i], "in_backlog"));
|
||||
} else if (e.key === "?") {
|
||||
e.preventDefault();
|
||||
alert("Inbox shortcuts:\nj/k = next/prev\nEnter = open\nb = backlog\ne = defer\nd = dismiss");
|
||||
}
|
||||
};
|
||||
|
||||
// Apply focused class via data attribute query
|
||||
const applyFocus = () => {
|
||||
document.querySelectorAll("[data-row-id]").forEach((el, idx) => {
|
||||
if (idx === focusedIdx) el.classList.add("ring-2", "ring-primary/40");
|
||||
else el.classList.remove("ring-2", "ring-primary/40");
|
||||
});
|
||||
};
|
||||
applyFocus();
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [focusedIdx, insightIds, router]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function scrollRowIntoView(id: string) {
|
||||
const el = document.querySelector(`[data-row-id="${id}"]`);
|
||||
if (el) el.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||
}
|
||||
151
apps/web/src/app/insights/i/[id]/_founder-form.tsx
Normal file
151
apps/web/src/app/insights/i/[id]/_founder-form.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
setStatus,
|
||||
setFounderNotes,
|
||||
setFounderPriority,
|
||||
setSeverityOverride,
|
||||
} from "../../_actions";
|
||||
|
||||
type Props = {
|
||||
insightId: string;
|
||||
initialNotes: string;
|
||||
initialPriority: number | null;
|
||||
initialSeverityOverride: string | null;
|
||||
currentStatus: string;
|
||||
};
|
||||
|
||||
const STATUS_BUTTONS: Array<{ label: string; value: string; variant?: "default" | "destructive" | "outline" }> = [
|
||||
{ label: "Triage", value: "triaged" },
|
||||
{ label: "Backlog", value: "in_backlog" },
|
||||
{ label: "Shipped", value: "shipped" },
|
||||
{ label: "Defer", value: "deferred", variant: "outline" },
|
||||
{ label: "Duplicate", value: "duplicate", variant: "outline" },
|
||||
{ label: "Dismiss", value: "dismissed", variant: "destructive" },
|
||||
];
|
||||
|
||||
const SEVERITIES = ["P0", "P1", "P2", "P3", "INFO"] as const;
|
||||
|
||||
export function FounderForm({
|
||||
insightId,
|
||||
initialNotes,
|
||||
initialPriority,
|
||||
initialSeverityOverride,
|
||||
currentStatus,
|
||||
}: Props) {
|
||||
const [notes, setNotes] = useState(initialNotes);
|
||||
const [priority, setPriority] = useState<string>(
|
||||
initialPriority === null ? "" : String(initialPriority),
|
||||
);
|
||||
const [sevOverride, setSevOverride] = useState<string>(initialSeverityOverride ?? "");
|
||||
const [pending, start] = useTransition();
|
||||
const [flash, setFlash] = useState<string | null>(null);
|
||||
|
||||
const fire = (fn: () => Promise<unknown>, label: string) =>
|
||||
start(async () => {
|
||||
try {
|
||||
await fn();
|
||||
setFlash(label);
|
||||
setTimeout(() => setFlash(null), 2000);
|
||||
} catch (e) {
|
||||
setFlash(`err: ${(e as Error).message}`);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-md border p-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground w-20">status</span>
|
||||
<span className="font-mono text-xs">{currentStatus}</span>
|
||||
{STATUS_BUTTONS.map((b) => (
|
||||
<Button
|
||||
key={b.value}
|
||||
size="sm"
|
||||
variant={b.variant ?? "outline"}
|
||||
disabled={pending || b.value === currentStatus}
|
||||
onClick={() => fire(() => setStatus(insightId, b.value), `→ ${b.value}`)}
|
||||
>
|
||||
{b.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground w-20">severity</span>
|
||||
<select
|
||||
className="rounded border bg-background px-2 py-1 text-xs"
|
||||
value={sevOverride}
|
||||
onChange={(e) => setSevOverride(e.target.value)}
|
||||
>
|
||||
<option value="">(no override)</option>
|
||||
{SEVERITIES.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={pending}
|
||||
onClick={() =>
|
||||
fire(
|
||||
() => setSeverityOverride(insightId, sevOverride || null),
|
||||
sevOverride ? `severity=${sevOverride}` : "cleared",
|
||||
)
|
||||
}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground w-20">priority</span>
|
||||
<input
|
||||
type="number"
|
||||
className="w-24 rounded border bg-background px-2 py-1 text-xs"
|
||||
placeholder="(auto)"
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={pending}
|
||||
onClick={() =>
|
||||
fire(
|
||||
() => setFounderPriority(insightId, priority === "" ? null : Number(priority)),
|
||||
priority === "" ? "auto" : `priority=${priority}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
Pin
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-1 text-xs text-muted-foreground">founder notes</div>
|
||||
<textarea
|
||||
className="w-full rounded border bg-background p-2 text-sm font-mono"
|
||||
rows={3}
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="Notes for future-you…"
|
||||
/>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={pending}
|
||||
onClick={() => fire(() => setFounderNotes(insightId, notes), "saved")}
|
||||
>
|
||||
Save notes
|
||||
</Button>
|
||||
{flash && <span className="text-xs text-muted-foreground">{flash}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { PanelShell } from "@/components/panel-shell";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { notFound } from "next/navigation";
|
||||
import { FounderForm } from "./_founder-form";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -49,6 +49,14 @@ export default async function InsightDetailPage({
|
||||
<div className="col-span-2">fingerprint: <span className="font-mono">{insight.fingerprint}</span></div>
|
||||
</div>
|
||||
|
||||
<FounderForm
|
||||
insightId={insight.id}
|
||||
initialNotes={insight.founderNotes ?? ""}
|
||||
initialPriority={insight.founderPriority}
|
||||
initialSeverityOverride={insight.founderSeverityOverride}
|
||||
currentStatus={insight.status}
|
||||
/>
|
||||
|
||||
<h2 className="mt-4 text-sm font-medium">AI Analysis</h2>
|
||||
<div className="space-y-2 text-sm">
|
||||
{renderBody(body)}
|
||||
|
||||
@@ -8,22 +8,39 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { FilterBar } from "./_filter-bar";
|
||||
import { InboxRow } from "./_inbox-row";
|
||||
import { InboxShortcuts } from "./_inbox-shortcuts";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function severityVariant(sev: string): "default" | "destructive" | "secondary" | "outline" {
|
||||
if (sev === "P0") return "destructive";
|
||||
if (sev === "P1") return "destructive";
|
||||
if (sev === "P2") return "default";
|
||||
return "outline";
|
||||
}
|
||||
type SearchParams = {
|
||||
sev?: string;
|
||||
status?: string;
|
||||
type?: string;
|
||||
};
|
||||
|
||||
export default async function InsightInboxPage() {
|
||||
const [byStatus, byPromptTag, todayCost, monthCost, recent] = await Promise.all([
|
||||
export default async function InsightInboxPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>;
|
||||
}) {
|
||||
const sp = await searchParams;
|
||||
|
||||
const where: Record<string, unknown> = {};
|
||||
if (sp.sev) where.severity = sp.sev;
|
||||
if (sp.type) where.type = sp.type;
|
||||
if (!sp.status || sp.status === "active") {
|
||||
where.status = { in: ["new", "triaged", "in_backlog", "in_progress", "regressed"] };
|
||||
} else {
|
||||
where.status = sp.status;
|
||||
}
|
||||
|
||||
const [byStatus, bySev, byType, todayCost, monthCost, recent] = await Promise.all([
|
||||
prisma.insight.groupBy({ by: ["status"], _count: { status: true } }),
|
||||
prisma.insight.groupBy({ by: ["sourcePromptTag"], _count: { sourcePromptTag: true } }),
|
||||
prisma.insight.groupBy({ by: ["severity"], _count: { severity: true } }),
|
||||
prisma.insight.groupBy({ by: ["type"], _count: { type: true } }),
|
||||
prisma.costLedger.aggregate({
|
||||
where: { createdAt: { gte: startOfDayUtc(new Date()) } },
|
||||
_sum: { costTotalUsd: true },
|
||||
@@ -33,36 +50,58 @@ export default async function InsightInboxPage() {
|
||||
_sum: { costTotalUsd: true },
|
||||
}),
|
||||
prisma.insight.findMany({
|
||||
where: { status: { in: ["new", "triaged", "in_backlog", "regressed"] } },
|
||||
where,
|
||||
orderBy: [{ founderPriority: "desc" }, { priorityScore: "desc" }, { lastSeenAt: "desc" }],
|
||||
take: 80,
|
||||
}),
|
||||
]);
|
||||
|
||||
const counts: Record<string, number> = {};
|
||||
for (const row of byStatus) counts[row.status] = row._count.status;
|
||||
const statusCounts: Record<string, number> = {};
|
||||
for (const row of byStatus) statusCounts[row.status] = row._count.status;
|
||||
const sevCounts: Record<string, number> = {};
|
||||
for (const row of bySev) sevCounts[row.severity] = row._count.severity;
|
||||
const typeCounts: Record<string, number> = {};
|
||||
for (const row of byType) typeCounts[row.type] = row._count.type;
|
||||
|
||||
const today = Number(todayCost._sum.costTotalUsd ?? 0);
|
||||
const month = Number(monthCost._sum.costTotalUsd ?? 0);
|
||||
|
||||
const ids = recent.map((r) => r.id);
|
||||
const rowsForClient = recent.map((r) => ({
|
||||
id: r.id,
|
||||
severity: r.severity,
|
||||
type: r.type,
|
||||
status: r.status,
|
||||
fingerprint: r.fingerprint,
|
||||
title: r.title,
|
||||
occurrenceCount: r.occurrenceCount,
|
||||
confidence: r.confidence,
|
||||
priorityScore: r.priorityScore,
|
||||
lastSeenAt: r.lastSeenAt.toISOString(),
|
||||
}));
|
||||
|
||||
return (
|
||||
<PanelShell title="Insights · inbox">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
AI-generated insights from Sase.tr session pipeline. Sorted by priority.{" "}
|
||||
<a href="/insights/pipeline" className="underline">View pipeline</a> ·{" "}
|
||||
<a href="/insights/costs" className="underline">Cost dashboard</a>
|
||||
AI-generated insights from Sase.tr session pipeline. Press <kbd>?</kbd> for shortcuts.{" "}
|
||||
<a href="/insights/pipeline" className="underline">Pipeline</a> ·{" "}
|
||||
<a href="/insights/costs" className="underline">Costs</a> ·{" "}
|
||||
<a href="/insights/settings/budgets" className="underline">Budgets</a> ·{" "}
|
||||
<a href="/insights/settings/prompts" className="underline">Prompts</a>
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-5">
|
||||
<KPI label="New" value={String(counts.new ?? 0)} />
|
||||
<KPI label="In backlog" value={String(counts.in_backlog ?? 0)} />
|
||||
<KPI label="Shipped" value={String(counts.shipped ?? 0)} />
|
||||
<KPI label="New" value={String(statusCounts.new ?? 0)} />
|
||||
<KPI label="In backlog" value={String(statusCounts.in_backlog ?? 0)} />
|
||||
<KPI label="Shipped" value={String(statusCounts.shipped ?? 0)} />
|
||||
<KPI label="Today $" value={`$${today.toFixed(3)}`} />
|
||||
<KPI label="Month $" value={`$${month.toFixed(2)}`} />
|
||||
</div>
|
||||
|
||||
<FilterBar severityCounts={sevCounts} statusCounts={statusCounts} typeCounts={typeCounts} />
|
||||
|
||||
<h2 className="mt-2 text-sm font-medium text-muted-foreground">
|
||||
Active insights ({recent.length})
|
||||
{recent.length} insight{recent.length === 1 ? "" : "s"} · sorted by priority
|
||||
</h2>
|
||||
|
||||
<div className="rounded-md border">
|
||||
@@ -74,59 +113,27 @@ export default async function InsightInboxPage() {
|
||||
<TableHead className="w-[100px]">Type</TableHead>
|
||||
<TableHead className="w-[80px]">Score</TableHead>
|
||||
<TableHead className="w-[60px]">Occ</TableHead>
|
||||
<TableHead className="w-[100px]">Conf</TableHead>
|
||||
<TableHead className="w-[80px]">Conf</TableHead>
|
||||
<TableHead className="w-[120px]">Last seen</TableHead>
|
||||
<TableHead className="w-[100px]">Status</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{recent.length === 0 ? (
|
||||
{rowsForClient.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-center text-xs text-muted-foreground">
|
||||
No insights yet — LLM analysis runs every 4 minutes.
|
||||
<TableCell colSpan={9} className="text-center text-xs text-muted-foreground">
|
||||
No insights match filters.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
recent.map((i) => (
|
||||
<TableRow key={i.id}>
|
||||
<TableCell>
|
||||
<Badge variant={severityVariant(i.severity)}>{i.severity}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<a href={`/insights/i/${i.id}`} className="font-medium underline">
|
||||
{i.title}
|
||||
</a>
|
||||
<div className="font-mono text-[10px] text-muted-foreground">
|
||||
{i.fingerprint.slice(0, 16)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">{i.type}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{i.priorityScore}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{i.occurrenceCount}</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{i.confidence?.toFixed(2) ?? "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{i.lastSeenAt.toISOString().slice(0, 16).replace("T", " ")}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{i.status}</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
rowsForClient.map((r, idx) => <InboxRow key={r.id} row={r} focused={idx === 0 && false} />)
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{byPromptTag.length > 0 && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
by template:{" "}
|
||||
{byPromptTag
|
||||
.map((b) => `${b.sourcePromptTag}=${b._count.sourcePromptTag}`)
|
||||
.join(" · ")}
|
||||
</div>
|
||||
)}
|
||||
<InboxShortcuts insightIds={ids} />
|
||||
</PanelShell>
|
||||
);
|
||||
}
|
||||
|
||||
68
apps/web/src/app/insights/settings/budgets/_budget-row.tsx
Normal file
68
apps/web/src/app/insights/settings/budgets/_budget-row.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { updateBudgetSetting } from "../../_actions";
|
||||
|
||||
type Props = {
|
||||
settingKey: string;
|
||||
label: string;
|
||||
initial: number | boolean;
|
||||
unit?: string;
|
||||
help?: string;
|
||||
};
|
||||
|
||||
export function BudgetRow({ settingKey, label, initial, unit, help }: Props) {
|
||||
const isBool = typeof initial === "boolean";
|
||||
const [val, setVal] = useState<string>(isBool ? "" : String(initial));
|
||||
const [bool, setBool] = useState<boolean>(isBool ? (initial as boolean) : false);
|
||||
const [pending, start] = useTransition();
|
||||
const [flash, setFlash] = useState<string | null>(null);
|
||||
|
||||
const save = () =>
|
||||
start(async () => {
|
||||
try {
|
||||
await updateBudgetSetting(settingKey, isBool ? bool : Number(val));
|
||||
setFlash("saved");
|
||||
setTimeout(() => setFlash(null), 2000);
|
||||
} catch (e) {
|
||||
setFlash(`err: ${(e as Error).message}`);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-[240px_1fr_auto_60px] items-center gap-3 py-2 border-b">
|
||||
<div>
|
||||
<div className="text-sm font-medium">{label}</div>
|
||||
{help && <div className="text-[11px] text-muted-foreground">{help}</div>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isBool ? (
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={bool}
|
||||
onChange={(e) => setBool(e.target.checked)}
|
||||
/>
|
||||
{bool ? "PAUSED" : "active"}
|
||||
</label>
|
||||
) : (
|
||||
<>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
className="w-32 rounded border bg-background px-2 py-1 text-sm font-mono"
|
||||
value={val}
|
||||
onChange={(e) => setVal(e.target.value)}
|
||||
/>
|
||||
{unit && <span className="text-xs text-muted-foreground">{unit}</span>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Button size="sm" variant="outline" disabled={pending} onClick={save}>
|
||||
Save
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground">{flash ?? ""}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
93
apps/web/src/app/insights/settings/budgets/page.tsx
Normal file
93
apps/web/src/app/insights/settings/budgets/page.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { PanelShell } from "@/components/panel-shell";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { BudgetRow } from "./_budget-row";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const SCHEMA: Array<{ key: string; label: string; unit?: string; help?: string; default: number | boolean }> = [
|
||||
{
|
||||
key: "monthly_hard_cap_usd",
|
||||
label: "Monthly hard cap",
|
||||
unit: "USD/month",
|
||||
help: "All LLM calls halt when month-to-date spend reaches this.",
|
||||
default: 30,
|
||||
},
|
||||
{
|
||||
key: "daily_soft_cap_usd",
|
||||
label: "Daily soft cap",
|
||||
unit: "USD/day",
|
||||
help: "Above this, pro tier downgrades to flash for the rest of today.",
|
||||
default: 1.5,
|
||||
},
|
||||
{
|
||||
key: "daily_hard_cap_usd",
|
||||
label: "Daily hard cap",
|
||||
unit: "USD/day",
|
||||
help: "All non-P0 paused above this until UTC midnight.",
|
||||
default: 3,
|
||||
},
|
||||
{
|
||||
key: "per_call_max_usd",
|
||||
label: "Per-call max",
|
||||
unit: "USD",
|
||||
help: "Single LLM call must not exceed this. Currently warn-only.",
|
||||
default: 0.2,
|
||||
},
|
||||
{
|
||||
key: "min_score_for_analysis",
|
||||
label: "Minimum score",
|
||||
unit: "0–100",
|
||||
help: "Sessions tagged below this score are discarded (not analyzed).",
|
||||
default: 30,
|
||||
},
|
||||
{
|
||||
key: "cache_ttl_hours",
|
||||
label: "Insight cache TTL",
|
||||
unit: "hours",
|
||||
help: "Same-fingerprint sessions within this window attach to existing insight (no new LLM call).",
|
||||
default: 6,
|
||||
},
|
||||
{
|
||||
key: "analysis_paused",
|
||||
label: "Pause analysis",
|
||||
help: "Kill switch — no LLM calls until cleared. Pipeline still ingests/tags/compresses.",
|
||||
default: false,
|
||||
},
|
||||
];
|
||||
|
||||
export default async function BudgetsSettingsPage() {
|
||||
const rows = await prisma.budgetSetting.findMany({ where: { projectKey: null } });
|
||||
const map: Record<string, unknown> = {};
|
||||
for (const r of rows) map[r.settingKey] = r.settingValue;
|
||||
|
||||
return (
|
||||
<PanelShell title="Insights · budget settings">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Edits apply within seconds (worker reads on each analyze cycle).{" "}
|
||||
<a href="/insights" className="underline">Inbox</a> ·{" "}
|
||||
<a href="/insights/costs" className="underline">Cost dashboard</a>
|
||||
</p>
|
||||
|
||||
<div className="rounded-md border p-4">
|
||||
{SCHEMA.map((s) => {
|
||||
const value = (map[s.key] as number | boolean | undefined) ?? s.default;
|
||||
return (
|
||||
<BudgetRow
|
||||
key={s.key}
|
||||
settingKey={s.key}
|
||||
label={s.label}
|
||||
initial={value}
|
||||
unit={s.unit}
|
||||
help={s.help}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
All edits are written to <span className="font-mono">budget_settings</span> and logged to{" "}
|
||||
<span className="font-mono">audit_log</span>.
|
||||
</p>
|
||||
</PanelShell>
|
||||
);
|
||||
}
|
||||
65
apps/web/src/app/insights/settings/prompts/[id]/page.tsx
Normal file
65
apps/web/src/app/insights/settings/prompts/[id]/page.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { PanelShell } from "@/components/panel-shell";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function PromptDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const p = await prisma.promptTemplate.findUnique({ where: { id } });
|
||||
if (!p) notFound();
|
||||
|
||||
const recentCost = await prisma.costLedger.findMany({
|
||||
where: { promptTag: p.tag, promptVersion: p.version },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 5,
|
||||
});
|
||||
|
||||
return (
|
||||
<PanelShell title={`${p.tag}@v${p.version}`}>
|
||||
<a href="/insights/settings/prompts" className="text-xs underline text-muted-foreground">
|
||||
← Back to prompts
|
||||
</a>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant={p.modelTier === "pro" ? "default" : "outline"}>{p.modelTier}</Badge>
|
||||
<Badge variant="outline">max_out {p.maxOutputTokens}</Badge>
|
||||
<Badge variant="outline">temp {p.temperature}</Badge>
|
||||
<Badge variant={p.active ? "default" : "outline"}>{p.active ? "active" : "off"}</Badge>
|
||||
</div>
|
||||
|
||||
<h2 className="text-sm font-medium">System prompt</h2>
|
||||
<pre className="rounded-md border bg-muted/30 p-3 text-xs whitespace-pre-wrap font-mono">
|
||||
{p.systemPrompt}
|
||||
</pre>
|
||||
|
||||
<h2 className="text-sm font-medium">User template</h2>
|
||||
<pre className="rounded-md border bg-muted/30 p-3 text-xs whitespace-pre-wrap font-mono">
|
||||
{p.userPromptTemplate}
|
||||
</pre>
|
||||
|
||||
<h2 className="text-sm font-medium">Output schema</h2>
|
||||
<pre className="rounded-md border bg-muted/30 p-3 text-xs whitespace-pre-wrap font-mono">
|
||||
{JSON.stringify(p.outputSchemaJson, null, 2)}
|
||||
</pre>
|
||||
|
||||
{recentCost.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-sm font-medium">Recent calls</h2>
|
||||
<div className="text-xs space-y-1 font-mono">
|
||||
{recentCost.map((c) => (
|
||||
<div key={c.id}>
|
||||
{c.createdAt.toISOString().slice(0, 19)} · in {c.tokensInputCacheMiss}/{c.tokensInputCacheHit} · out {c.tokensOutput} · ${c.costTotalUsd.toFixed(5)} · {c.errorCode ?? "ok"}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</PanelShell>
|
||||
);
|
||||
}
|
||||
77
apps/web/src/app/insights/settings/prompts/page.tsx
Normal file
77
apps/web/src/app/insights/settings/prompts/page.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { PanelShell } from "@/components/panel-shell";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function PromptsListPage() {
|
||||
const prompts = await prisma.promptTemplate.findMany({
|
||||
orderBy: [{ tag: "asc" }, { version: "desc" }],
|
||||
});
|
||||
|
||||
return (
|
||||
<PanelShell title="Insights · prompt registry">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Prompt templates seeded at worker boot. Editor coming in Phase 6e.{" "}
|
||||
<a href="/insights" className="underline">Inbox</a>
|
||||
</p>
|
||||
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Tag</TableHead>
|
||||
<TableHead>Version</TableHead>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Tier</TableHead>
|
||||
<TableHead>Max out</TableHead>
|
||||
<TableHead>Temp</TableHead>
|
||||
<TableHead>Active</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{prompts.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center text-xs text-muted-foreground">
|
||||
No prompts yet — worker seeds on boot.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
prompts.map((p) => (
|
||||
<TableRow key={p.id}>
|
||||
<TableCell className="font-mono text-xs">
|
||||
<a href={`/insights/settings/prompts/${p.id}`} className="underline">
|
||||
{p.tag}
|
||||
</a>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">v{p.version}</TableCell>
|
||||
<TableCell className="text-sm">{p.name}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={p.modelTier === "pro" ? "default" : "outline"}>
|
||||
{p.modelTier}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">{p.maxOutputTokens}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{p.temperature}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={p.active ? "default" : "outline"}>
|
||||
{p.active ? "active" : "off"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</PanelShell>
|
||||
);
|
||||
}
|
||||
@@ -13,11 +13,15 @@ import {
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
ActivityIcon,
|
||||
CoinsIcon,
|
||||
FolderIcon,
|
||||
LayoutDashboardIcon,
|
||||
LightbulbIcon,
|
||||
LogOutIcon,
|
||||
ScrollTextIcon,
|
||||
Settings2Icon,
|
||||
SlidersHorizontalIcon,
|
||||
SparklesIcon,
|
||||
TerminalIcon,
|
||||
} from "lucide-react";
|
||||
import { signOut } from "@/lib/auth-client";
|
||||
@@ -61,6 +65,26 @@ export function CommandPalette({ projects }: { projects: ProjectLite[] }) {
|
||||
|
||||
<CommandSeparator />
|
||||
|
||||
<CommandGroup heading="Insights">
|
||||
<CommandItem keywords={["inbox","triage"]} onSelect={() => go("/insights")}>
|
||||
<LightbulbIcon /> Insight inbox
|
||||
</CommandItem>
|
||||
<CommandItem keywords={["cost","spend","budget","llm"]} onSelect={() => go("/insights/costs")}>
|
||||
<CoinsIcon /> Cost dashboard
|
||||
</CommandItem>
|
||||
<CommandItem keywords={["pipeline","sessions","posthog"]} onSelect={() => go("/insights/pipeline")}>
|
||||
<ActivityIcon /> Pipeline (sessions)
|
||||
</CommandItem>
|
||||
<CommandItem keywords={["budget","cap","pause"]} onSelect={() => go("/insights/settings/budgets")}>
|
||||
<SlidersHorizontalIcon /> Budget settings
|
||||
</CommandItem>
|
||||
<CommandItem keywords={["prompt","template","llm"]} onSelect={() => go("/insights/settings/prompts")}>
|
||||
<SparklesIcon /> Prompt registry
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
|
||||
<CommandSeparator />
|
||||
|
||||
<CommandGroup heading="Projects">
|
||||
{projects.map((p) => (
|
||||
<CommandItem
|
||||
|
||||
Reference in New Issue
Block a user