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:
@@ -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 => (
|
||||
|
||||
@@ -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'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: "What should I build first for this project?"</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: "Add user authentication to my app"</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: "Which database should I use for this project?"</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,
|
||||
|
||||
@@ -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: "Start building my app"</span></>
|
||||
: <>Repo is empty — push a first commit. <span style={nudge}>Try: "Scaffold a Next.js app"</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: "Install Twenty CRM for my project"</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,
|
||||
|
||||
@@ -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 “{workspace}”?</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>
|
||||
|
||||
71
app/api/workspaces/delete/route.ts
Normal file
71
app/api/workspaces/delete/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user