feat(ux): empty-state prompt nudges + workspace delete

- Plan/Product/Infrastructure: empty states now suggest a concrete AI
  prompt so non-technical users know exactly what to type rather than
  staring at a blank category ("Try: Add a Postgres database…")
- Workspace settings danger zone: wired Delete Workspace to a new
  POST /api/workspaces/delete endpoint (deletes projects + chat
  threads; Coolify resources intentionally untouched)

Made-with: Cursor
This commit is contained in:
2026-04-30 17:17:22 -07:00
parent 41fbed31f3
commit c862104e35
5 changed files with 148 additions and 18 deletions

View File

@@ -57,6 +57,15 @@ interface CategoryDef {
dashboards?: Record<string, string>;
}
const INFRA_NUDGE = {
databases: 'No database yet. Try: "Add a Postgres database to my project"',
auth: 'No auth provider connected. Try: "Add Google OAuth to my app"',
email: 'No email provider. Try: "Set up email sending with Resend"',
payments: 'No payment provider. Try: "Connect Stripe to my project"',
llm: 'No LLM connected. Try: "Add an OpenAI key to this project"',
secrets: 'No secrets stored yet. Try: "Add my Stripe secret key"',
} as const;
const CATEGORIES: CategoryDef[] = [
{
key: "databases", label: "Databases", icon: Database,
@@ -493,7 +502,9 @@ function CategoryDetail({
<span style={{ color: INK.mid, fontWeight: 400 }}>({count})</span>
</SectionTitle>
{count === 0 ? (
<div style={emptyBox}>None yet.</div>
<div style={emptyBox}>
{INFRA_NUDGE[def.key as keyof typeof INFRA_NUDGE] ?? "None yet."}
</div>
) : def.key === "secrets" ? (
<div style={listBox}>
{anatomy.infrastructure.secrets.byResource.map(r => (

View File

@@ -468,8 +468,11 @@ function SessionsPanel({
<div style={errorBox}><AlertCircle size={13} style={{ marginRight: 6, verticalAlign: -2 }} />{err}</div>
) : !sessions || sessions.length === 0 ? (
<div style={emptyBox}>
No sessions yet. Open the chat panel and start a conversation about this project
it&apos;ll show up here.
No sessions yet.{" "}
<span style={{ display: "block", marginTop: 8, color: INK.mid }}>
Open the chat and ask anything each conversation becomes a session entry here with an AI-generated summary.
</span>
<span style={promptNudge}>Try: &quot;What should I build first for this project?&quot;</span>
</div>
) : (
<ul style={list}>
@@ -577,9 +580,15 @@ function TasksPanel({
<div style={taskListCol}>
{visible.length === 0 && !creating ? (
<div style={{ ...emptyBox, marginTop: 0 }}>
{tasks.length === 0
? "No tasks yet. Create one to scope a feature, refactor, or investigation."
: "Nothing in this view."}
{tasks.length === 0 ? (
<>
No tasks yet.
<span style={{ display: "block", marginTop: 8, color: INK.mid, fontSize: "0.8rem" }}>
Ask the AI to break down your first feature it will create scoped tasks automatically.
</span>
<span style={promptNudge}>Try: &quot;Add user authentication to my app&quot;</span>
</>
) : "Nothing in this view."}
</div>
) : (
<ul style={taskList}>
@@ -947,7 +956,10 @@ function DecisionsPanel({
</button>
)}
{plan.decisions.length === 0 ? (
<div style={{ ...emptyBox, marginTop: 14 }}>No decisions logged yet.</div>
<div style={{ ...emptyBox, marginTop: 14 }}>
No decisions yet the AI logs these automatically when you settle on something in chat.
<span style={promptNudge}>Try: &quot;Which database should I use for this project?&quot;</span>
</div>
) : (
<ul style={{ ...list, marginTop: 14 }}>
{plan.decisions.map((d) => (
@@ -1267,6 +1279,12 @@ const emptyBox: React.CSSProperties = {
background: "#fafaf6", border: `1px dashed ${INK.borderSoft}`, borderRadius: 8,
lineHeight: 1.5, textAlign: "center",
};
const promptNudge: React.CSSProperties = {
display: "block", marginTop: 10,
background: "#f3eee4", borderRadius: 5,
padding: "5px 10px", fontSize: "0.76rem",
color: INK.mid, fontStyle: "italic",
};
const errorBox: React.CSSProperties = {
padding: "12px 14px", fontSize: "0.85rem", color: "#7a1f15",
background: "#fbe9e7", border: `1px solid #f4c2bc`, borderRadius: 8,

View File

@@ -81,8 +81,8 @@ export default function ProductTab() {
{codebases && codebases.length === 0 && (
<RailEmpty>
{reason === "no_repo"
? "No Gitea repo connected to this project yet."
: "Repo is empty — push a first commit."}
? <>No codebase yet. <span style={nudge}>Try: &quot;Start building my app&quot;</span></>
: <>Repo is empty push a first commit. <span style={nudge}>Try: &quot;Scaffold a Next.js app&quot;</span></>}
</RailEmpty>
)}
{codebases?.map(cb => {
@@ -131,7 +131,8 @@ export default function ProductTab() {
<RailGroup title="Images" count={images?.length ?? 0}>
{images && images.length === 0 && (
<RailEmpty>
Self-hosted apps (Twenty, n8n, Plausible) you adopt as part of the product appear here.
Self-hosted tools (Twenty CRM, n8n, Plausible) you run appear here.
<span style={nudge}>Try: &quot;Install Twenty CRM for my project&quot;</span>
</RailEmpty>
)}
{images?.map(img => (
@@ -342,8 +343,13 @@ const countPill: React.CSSProperties = {
const railItems: React.CSSProperties = { display: "flex", flexDirection: "column", gap: 10 };
const railEmpty: React.CSSProperties = {
padding: "10px 12px", fontSize: "0.74rem", color: INK.muted,
fontStyle: "italic", border: `1px dashed ${INK.borderSoft}`, borderRadius: 8,
lineHeight: 1.4,
border: `1px dashed ${INK.borderSoft}`, borderRadius: 8,
lineHeight: 1.6,
};
const nudge: React.CSSProperties = {
display: "block", marginTop: 6, fontStyle: "normal",
background: "#f3eee4", borderRadius: 4, padding: "3px 8px",
fontSize: "0.72rem", color: "#7a6a50",
};
const flatTile: React.CSSProperties = {
display: "flex", alignItems: "center", gap: 10,

View File

@@ -223,24 +223,48 @@ export default function SettingsPage() {
</div>
<CardDescription>Irreversible and destructive actions</CardDescription>
</CardHeader>
<CardContent>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
Deletes all Vibn project and chat data for this workspace.{" "}
<strong>Coolify services and databases are not removed</strong> clean those up
separately in Coolify or via the AI before deleting.
</p>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" disabled>
<Button variant="destructive">
Delete Workspace
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogTitle>Delete workspace &ldquo;{workspace}&rdquo;?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete your workspace and all associated data.
This will permanently delete all projects and chat history in this workspace.
Coolify services and databases will remain running you must stop them
separately. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction className="bg-red-600 hover:bg-red-700">
Delete Workspace
<AlertDialogAction
className="bg-red-600 hover:bg-red-700"
onClick={async () => {
try {
const r = await fetch("/api/workspaces/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug: workspace }),
});
const d = await r.json();
if (!r.ok) throw new Error(d.error || "Delete failed");
toast.success("Workspace deleted");
router.push("/");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Delete failed");
}
}}
>
Yes, delete workspace
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>

View File

@@ -0,0 +1,71 @@
import { NextResponse } from "next/server";
import { authSession } from "@/lib/auth/session-server";
import { query } from "@/lib/db-postgres";
/**
* POST /api/workspaces/delete
* Body: { slug: string }
*
* Deletes the workspace record and all associated projects/threads.
* Coolify resources (services, databases) are NOT deleted — the user
* must clean those up manually or via the AI. This only removes Vibn's
* knowledge of the workspace.
*
* Ownership check: the workspace's owner_user_id must match the
* authenticated user.
*/
export async function POST(request: Request) {
try {
const session = await authSession();
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { slug } = await request.json();
if (!slug) {
return NextResponse.json({ error: "slug is required" }, { status: 400 });
}
// Find the workspace and verify ownership
const wsRows = await query<{ id: string; slug: string }>(
`SELECT vw.id, vw.slug
FROM vibn_workspaces vw
JOIN fs_users u ON u.id = vw.owner_user_id
WHERE vw.slug = $1 AND u.data->>'email' = $2
LIMIT 1`,
[slug, session.user.email],
);
if (!wsRows.length) {
return NextResponse.json({ error: "Workspace not found or unauthorized" }, { status: 404 });
}
const workspaceId = wsRows[0].id;
// Delete all chat threads scoped to the workspace
await query(
`DELETE FROM fs_chat_threads WHERE workspace = $1`,
[slug],
);
// Delete all projects in the workspace
await query(
`DELETE FROM fs_projects WHERE workspace = $1`,
[slug],
);
// Delete the workspace itself
await query(
`DELETE FROM vibn_workspaces WHERE id = $1`,
[workspaceId],
);
return NextResponse.json({ success: true, message: "Workspace deleted" });
} catch (error) {
console.error("[POST /api/workspaces/delete]", error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Delete failed" },
{ status: 500 },
);
}
}