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:
Semih
2026-05-13 23:32:41 +00:00
parent 0295b88981
commit 5beb89f771
13 changed files with 939 additions and 61 deletions

View 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");
}

View 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>
);
}

View 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>
);
}

View 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" });
}

View 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>
);
}

View File

@@ -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)}

View File

@@ -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>
);
}

View 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>
);
}

View 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: "0100",
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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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