VIBN Frontend for Coolify deployment

This commit is contained in:
2026-02-15 19:25:52 -08:00
commit 40bf8428cd
398 changed files with 76513 additions and 0 deletions

View File

@@ -0,0 +1,385 @@
"use client";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Upload, Github, Plug, FileText, Download, Copy, Check } from "lucide-react";
import { useState, useRef } from "react";
import { auth } from "@/lib/firebase/config";
import { toast } from "sonner";
import { GitHubRepoPicker } from "./github-repo-picker";
import { CursorIcon } from "@/components/icons/custom-icons";
interface CollectorActionsProps {
projectId: string;
className?: string;
}
export function CollectorActions({ projectId, className }: CollectorActionsProps) {
const [uploading, setUploading] = useState(false);
const [showGithubPicker, setShowGithubPicker] = useState(false);
const [showPasteDialog, setShowPasteDialog] = useState(false);
const [showCursorImportDialog, setShowCursorImportDialog] = useState(false);
const [pasteTitle, setPasteTitle] = useState("");
const [pasteContent, setPasteContent] = useState("");
const [isPasting, setIsPasting] = useState(false);
const [copiedConfig, setCopiedConfig] = useState(false);
const [copiedCommand, setCopiedCommand] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
const files = event.target.files;
if (!files || files.length === 0) return;
setUploading(true);
try {
const user = auth.currentUser;
if (!user) {
toast.error("Please sign in to upload files");
return;
}
const token = await user.getIdToken();
for (const file of Array.from(files)) {
const formData = new FormData();
formData.append("file", file);
const response = await fetch(`/api/projects/${projectId}/knowledge/upload-document`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
},
body: formData,
});
if (!response.ok) {
throw new Error(`Failed to upload ${file.name}`);
}
}
toast.success(`Uploaded ${files.length} file(s)`);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
} catch (error) {
console.error("Upload error:", error);
toast.error("Failed to upload files");
} finally {
setUploading(false);
}
};
const handleExtensionClick = () => {
window.open("https://chrome.google.com/webstore", "_blank");
toast.info("Install the Vibn browser extension and link it to this project");
};
const handleCopyConfig = () => {
const vibnConfig = {
projectId: projectId,
version: "1.0.0"
};
const content = JSON.stringify(vibnConfig, null, 2);
navigator.clipboard.writeText(content);
setCopiedConfig(true);
toast.success("Configuration copied to clipboard!");
setTimeout(() => setCopiedConfig(false), 2000);
};
const handleCopyCommand = () => {
navigator.clipboard.writeText("Vibn: Import Historical Conversations");
setCopiedCommand(true);
toast.success("Command copied to clipboard!");
setTimeout(() => setCopiedCommand(false), 2000);
};
const handlePasteSubmit = async () => {
if (!pasteTitle.trim() || !pasteContent.trim()) {
toast.error("Please provide both title and content");
return;
}
setIsPasting(true);
try {
const user = auth.currentUser;
if (!user) {
toast.error("Please sign in to save content");
return;
}
const token = await user.getIdToken();
const response = await fetch(`/api/projects/${projectId}/knowledge/import-ai-chat`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
title: pasteTitle,
transcript: pasteContent,
}),
});
if (!response.ok) {
throw new Error("Failed to import chat");
}
toast.success("AI chat imported successfully!");
setShowPasteDialog(false);
setPasteTitle("");
setPasteContent("");
} catch (error) {
console.error("Paste error:", error);
toast.error("Failed to import chat");
} finally {
setIsPasting(false);
}
};
return (
<>
<input
ref={fileInputRef}
type="file"
multiple
accept=".txt,.md,.json,.pdf,.doc,.docx"
onChange={handleFileSelect}
className="hidden"
/>
{/* Upload Documents */}
<Button
variant="outline"
className="w-full justify-start h-auto py-2 px-3"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
>
<Upload className="h-4 w-4 mr-2 flex-shrink-0" />
<span className="text-xs">
{uploading ? "Uploading..." : "Upload Documents"}
</span>
</Button>
{/* Connect GitHub */}
<Button
variant="outline"
className="w-full justify-start h-auto py-2 px-3"
onClick={() => setShowGithubPicker(true)}
>
<Github className="h-4 w-4 mr-2 flex-shrink-0" />
<span className="text-xs">Connect GitHub</span>
</Button>
{/* Get Extension */}
<Button
variant="outline"
className="w-full justify-start h-auto py-2 px-3"
onClick={handleExtensionClick}
>
<Plug className="h-4 w-4 mr-2 flex-shrink-0" />
<span className="text-xs">Get Extension</span>
</Button>
{/* Paste AI Chat */}
<Button
variant="outline"
className="w-full justify-start h-auto py-2 px-3"
onClick={() => setShowPasteDialog(true)}
>
<FileText className="h-4 w-4 mr-2 flex-shrink-0" />
<span className="text-xs">Paste AI Chat</span>
</Button>
{/* Import Cursor History */}
<Button
variant="outline"
className="w-full justify-start h-auto py-2 px-3"
onClick={() => setShowCursorImportDialog(true)}
>
<CursorIcon className="h-4 w-4 mr-2 flex-shrink-0" />
<span className="text-xs">Import Cursor History</span>
</Button>
{/* GitHub Picker Dialog */}
{showGithubPicker && (
<GitHubRepoPicker
projectId={projectId}
onClose={() => setShowGithubPicker(false)}
/>
)}
{/* Paste AI Chat Dialog */}
<Dialog open={showPasteDialog} onOpenChange={setShowPasteDialog}>
<DialogContent className="max-w-2xl max-h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle>Import AI Chat</DialogTitle>
<DialogDescription>
Paste a conversation from ChatGPT, Claude, or any other AI tool
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto space-y-4">
<div>
<Label htmlFor="paste-title">Title</Label>
<Input
id="paste-title"
placeholder="e.g., Product Vision Discussion"
value={pasteTitle}
onChange={(e) => setPasteTitle(e.target.value)}
className="mt-1"
/>
</div>
<div className="flex-1">
<Label htmlFor="paste-content">Chat Content</Label>
<Textarea
id="paste-content"
placeholder="Paste your entire conversation here..."
value={pasteContent}
onChange={(e) => setPasteContent(e.target.value)}
className="mt-1 min-h-[300px] font-mono text-xs"
/>
</div>
</div>
<div className="flex justify-end gap-2 pt-4 border-t">
<Button
variant="outline"
onClick={() => setShowPasteDialog(false)}
disabled={isPasting}
>
Cancel
</Button>
<Button
onClick={handlePasteSubmit}
disabled={isPasting || !pasteTitle.trim() || !pasteContent.trim()}
>
{isPasting ? "Importing..." : "Import Chat"}
</Button>
</div>
</DialogContent>
</Dialog>
{/* Cursor Import Dialog */}
<Dialog open={showCursorImportDialog} onOpenChange={setShowCursorImportDialog}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<CursorIcon className="h-5 w-5" />
Import Cursor Conversation History
</DialogTitle>
<DialogDescription>
Import all conversations from Cursor related to this project
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="rounded-lg bg-muted p-4 space-y-3">
<p className="text-sm font-medium">Step 1: Create .vibn file</p>
<p className="text-xs text-muted-foreground mb-2">
In your project root directory (where you open Cursor), create a file named <code className="text-xs bg-background px-1 py-0.5 rounded">.vibn</code>
</p>
<div className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs font-medium">Paste this content into the file:</p>
<Button
size="sm"
variant="ghost"
onClick={handleCopyConfig}
className="h-7 gap-2"
>
{copiedConfig ? (
<>
<Check className="h-3 w-3" />
Copied
</>
) : (
<>
<Copy className="h-3 w-3" />
Copy
</>
)}
</Button>
</div>
<pre className="text-xs bg-background p-3 rounded border overflow-x-auto">
<code>{JSON.stringify({ projectId: projectId, version: "1.0.0" }, null, 2)}</code>
</pre>
</div>
</div>
<div className="rounded-lg bg-muted p-4 space-y-3">
<p className="text-sm font-medium">Step 2: Reload Cursor window</p>
<p className="text-xs text-muted-foreground">
After creating the .vibn file, reload Cursor to register the new configuration:
</p>
<ul className="list-disc list-inside space-y-1 text-xs text-muted-foreground ml-2">
<li>Command Palette <strong>Developer: Reload Window</strong></li>
<li>Or: Close and reopen Cursor</li>
</ul>
</div>
<div className="rounded-lg bg-muted p-4 space-y-3">
<p className="text-sm font-medium">Step 3: Run import command</p>
<ol className="list-decimal list-inside space-y-2 text-sm text-muted-foreground ml-2">
<li>Open Command Palette (Cmd+Shift+P / Ctrl+Shift+P)</li>
<li>Run this command:</li>
</ol>
<div className="flex items-center gap-2 mt-2">
<code className="flex-1 text-xs bg-background px-3 py-2 rounded border">
Vibn: Import Historical Conversations
</code>
<Button
size="sm"
variant="ghost"
onClick={handleCopyCommand}
className="h-8"
>
{copiedCommand ? (
<Check className="h-3 w-3" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
</div>
</div>
<div className="rounded-lg bg-blue-500/10 border border-blue-500/20 p-4">
<p className="text-sm text-blue-700 dark:text-blue-400">
💡 <strong>Note:</strong> The Cursor Monitor extension must be installed and configured with your Vibn API key.
</p>
</div>
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">What gets imported:</p>
<ul className="text-xs text-muted-foreground space-y-1 ml-4">
<li> All chat sessions from the workspace</li>
<li> User prompts and AI responses</li>
<li> File edit history and context</li>
<li> Conversation timestamps and metadata</li>
</ul>
</div>
</div>
<div className="flex justify-end gap-2 pt-4 border-t">
<Button
variant="outline"
onClick={() => setShowCursorImportDialog(false)}
>
Close
</Button>
</div>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,225 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { CheckCircle2, Circle, Loader2 } from "lucide-react";
import { useEffect, useState } from "react";
import { db } from "@/lib/firebase/config";
import { doc, onSnapshot, collection, query, where } from "firebase/firestore";
interface CollectorChecklistProps {
projectId: string;
className?: string;
}
export function CollectorChecklist({ projectId, className }: CollectorChecklistProps) {
const [loading, setLoading] = useState(true);
const [currentPhase, setCurrentPhase] = useState<string | null>(null);
const [hasDocuments, setHasDocuments] = useState(false);
const [documentCount, setDocumentCount] = useState(0);
const [githubConnected, setGithubConnected] = useState(false);
const [githubRepo, setGithubRepo] = useState<string | null>(null);
const [extensionLinked, setExtensionLinked] = useState(false);
const [readyForNextPhase, setReadyForNextPhase] = useState(false);
useEffect(() => {
if (!projectId) return;
const unsubscribe = onSnapshot(
doc(db, "projects", projectId),
(snapshot) => {
if (snapshot.exists()) {
const data = snapshot.data();
const phase = data?.currentPhase || 'collector';
setCurrentPhase(phase);
// Get actual state from project data
const repo = data?.githubRepo || null;
setGithubConnected(!!repo);
setGithubRepo(repo);
// Check handoff for readiness
const handoff = data?.phaseData?.phaseHandoffs?.collector;
setReadyForNextPhase(handoff?.readyForNextPhase || false);
console.log("[CollectorChecklist] Project state:", {
currentPhase: phase,
githubRepo: repo,
readyForNextPhase: handoff?.readyForNextPhase,
userId: data?.userId, // Log the user ID
});
// Also log it prominently for easy copying
console.log(`🆔 YOUR USER ID: ${data?.userId}`);
} else {
console.log("[CollectorChecklist] Project not found:", projectId);
}
setLoading(false);
},
(error) => {
console.error("[CollectorChecklist] Error loading checklist:", error);
setLoading(false);
}
);
return () => unsubscribe();
}, [projectId]);
// Separate effect to check for linked sessions (extension linked)
useEffect(() => {
if (!projectId) return;
console.log(`[CollectorChecklist] Querying sessions for projectId: "${projectId}"`);
const sessionsRef = collection(db, 'sessions');
const q = query(sessionsRef, where('projectId', '==', projectId));
const unsubscribe = onSnapshot(q, (snapshot) => {
const hasLinkedSessions = !snapshot.empty;
setExtensionLinked(hasLinkedSessions);
console.log("[CollectorChecklist] Extension linked (sessions found):", hasLinkedSessions, `(${snapshot.size} sessions)`);
// Debug: Show session IDs if found
if (snapshot.size > 0) {
const sessionIds = snapshot.docs.map(doc => doc.id);
console.log("[CollectorChecklist] Found session IDs:", sessionIds);
}
}, (error) => {
console.error("[CollectorChecklist] Error querying sessions:", error);
});
return () => unsubscribe();
}, [projectId]);
// Separate effect to count documents from knowledge_items collection
// Count both uploaded documents AND pasted AI chat content
useEffect(() => {
if (!projectId) return;
const q = query(
collection(db, 'knowledge_items'),
where('projectId', '==', projectId)
);
const unsubscribe = onSnapshot(q, (snapshot) => {
// Filter for document types (uploaded files and pasted content)
const docs = snapshot.docs.filter(doc => {
const sourceType = doc.data().sourceType;
return sourceType === 'imported_document' || sourceType === 'imported_ai_chat';
});
const count = docs.length;
setHasDocuments(count > 0);
setDocumentCount(count);
console.log("[CollectorChecklist] Document count:", count, "(documents + pasted content)");
});
return () => unsubscribe();
}, [projectId]);
if (loading) {
return (
<div className={className}>
<div className="mb-3">
<h3 className="text-sm font-semibold">Setup Progress</h3>
</div>
<div className="flex items-center justify-center py-4">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
</div>
);
}
const checklist = [
{
id: "documents",
label: "Documents uploaded",
checked: hasDocuments,
count: documentCount,
},
{
id: "github",
label: "GitHub connected",
checked: githubConnected,
repo: githubRepo,
},
{
id: "extension",
label: "Extension linked",
checked: extensionLinked,
},
];
const completedCount = checklist.filter((item) => item.checked).length;
const progress = (completedCount / checklist.length) * 100;
// If phase is beyond collector, show completed state
const isCompleted = currentPhase === 'analyzed' || currentPhase === 'vision' || currentPhase === 'mvp';
return (
<div className={className}>
{/* Header */}
<div className="mb-3">
<h3 className="text-sm font-semibold">
{isCompleted ? '✓ Setup Complete' : 'Setup Progress'}
</h3>
<div className="mt-2">
<div className="w-full bg-secondary h-2 rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all duration-300"
style={{ width: `${isCompleted ? 100 : progress}%` }}
/>
</div>
<p className="text-xs text-muted-foreground mt-1">
{isCompleted ? '3 of 3 complete' : `${completedCount} of ${checklist.length} complete`}
</p>
</div>
</div>
{/* Checklist Items */}
<div className="space-y-2">
{checklist.map((item) => (
<div
key={item.id}
className={`flex items-center gap-2 py-2 px-3 rounded-md border ${
(item.checked || isCompleted)
? "bg-green-50 border-green-200"
: "bg-muted/50 border-muted text-muted-foreground opacity-60"
}`}
>
{(item.checked || isCompleted) ? (
<CheckCircle2 className="h-4 w-4 text-green-600 flex-shrink-0" />
) : (
<Circle className="h-4 w-4 text-muted-foreground flex-shrink-0" />
)}
<div className="flex-1 min-w-0">
<p className="text-xs font-medium">
{item.label}
{item.checked && item.count !== undefined && (
<span className="text-muted-foreground ml-1">
({item.count})
</span>
)}
</p>
{item.checked && item.repo && (
<p className="text-[10px] text-muted-foreground truncate">
{item.repo}
</p>
)}
</div>
</div>
))}
</div>
{/* Ready indicator */}
{readyForNextPhase && (
<div className="mt-4 pt-4 border-t">
<p className="text-xs text-green-600 font-medium">
Ready for extraction phase
</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,318 @@
"use client";
import { useEffect, useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { CheckCircle2, AlertTriangle, HelpCircle, Users, Lightbulb, Wrench, AlertCircle, Sparkles, X, Plus, Save, Edit2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { auth } from "@/lib/firebase/config";
import { toast } from "sonner";
interface ExtractionHandoff {
phase: string;
readyForNextPhase: boolean;
confidence: number;
confirmed: {
problems?: string[];
targetUsers?: string[];
features?: string[];
constraints?: string[];
opportunities?: string[];
};
uncertain: Record<string, any>;
missing: string[];
questionsForUser: string[];
timestamp: string;
}
interface ExtractionResultsEditableProps {
projectId: string;
className?: string;
}
export function ExtractionResultsEditable({ projectId, className }: ExtractionResultsEditableProps) {
const [extraction, setExtraction] = useState<ExtractionHandoff | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isEditing, setIsEditing] = useState(false);
const [isSaving, setIsSaving] = useState(false);
// Local editable state
const [editedProblems, setEditedProblems] = useState<string[]>([]);
const [editedUsers, setEditedUsers] = useState<string[]>([]);
const [editedFeatures, setEditedFeatures] = useState<string[]>([]);
const [editedConstraints, setEditedConstraints] = useState<string[]>([]);
const [editedOpportunities, setEditedOpportunities] = useState<string[]>([]);
useEffect(() => {
const fetchExtraction = async () => {
try {
setLoading(true);
const response = await fetch(`/api/projects/${projectId}/extraction-handoff`);
if (!response.ok) {
if (response.status === 404) {
setExtraction(null);
return;
}
throw new Error(`Failed to fetch extraction: ${response.statusText}`);
}
const data = await response.json();
const handoff = data.handoff;
setExtraction(handoff);
// Initialize editable state
setEditedProblems(handoff.confirmed.problems || []);
setEditedUsers(handoff.confirmed.targetUsers || []);
setEditedFeatures(handoff.confirmed.features || []);
setEditedConstraints(handoff.confirmed.constraints || []);
setEditedOpportunities(handoff.confirmed.opportunities || []);
} catch (err) {
console.error("[ExtractionResults] Error:", err);
setError(err instanceof Error ? err.message : "Failed to load extraction");
} finally {
setLoading(false);
}
};
if (projectId) {
fetchExtraction();
}
}, [projectId]);
const handleSave = async () => {
setIsSaving(true);
try {
const user = auth.currentUser;
if (!user) {
toast.error("Please sign in to save changes");
return;
}
const token = await user.getIdToken();
const response = await fetch(`/api/projects/${projectId}/extraction-handoff`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
confirmed: {
problems: editedProblems.filter(p => p.trim()),
targetUsers: editedUsers.filter(u => u.trim()),
features: editedFeatures.filter(f => f.trim()),
constraints: editedConstraints.filter(c => c.trim()),
opportunities: editedOpportunities.filter(o => o.trim()),
},
}),
});
if (!response.ok) {
throw new Error("Failed to save changes");
}
// Update local state
if (extraction) {
setExtraction({
...extraction,
confirmed: {
problems: editedProblems.filter(p => p.trim()),
targetUsers: editedUsers.filter(u => u.trim()),
features: editedFeatures.filter(f => f.trim()),
constraints: editedConstraints.filter(c => c.trim()),
opportunities: editedOpportunities.filter(o => o.trim()),
},
});
}
setIsEditing(false);
toast.success("Changes saved");
} catch (err) {
console.error("[ExtractionResults] Save error:", err);
toast.error("Failed to save changes");
} finally {
setIsSaving(false);
}
};
const handleCancel = () => {
// Reset to original values
if (extraction) {
setEditedProblems(extraction.confirmed.problems || []);
setEditedUsers(extraction.confirmed.targetUsers || []);
setEditedFeatures(extraction.confirmed.features || []);
setEditedConstraints(extraction.confirmed.constraints || []);
setEditedOpportunities(extraction.confirmed.opportunities || []);
}
setIsEditing(false);
};
const renderEditableList = (
items: string[],
setItems: (items: string[]) => void,
Icon: any,
iconColor: string,
title: string
) => {
const safeItems = items || [];
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm flex items-center gap-2">
<Icon className={cn("h-4 w-4", iconColor)} />
{title}
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{safeItems.map((item, index) => (
<div key={index} className="flex items-center gap-2">
{isEditing ? (
<>
<Input
value={item}
onChange={(e) => {
const newItems = [...items];
newItems[index] = e.target.value;
setItems(newItems);
}}
className="flex-1 text-sm"
/>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => {
const newItems = items.filter((_, i) => i !== index);
setItems(newItems);
}}
>
<X className="h-4 w-4" />
</Button>
</>
) : (
<p className="text-sm text-muted-foreground">{item}</p>
)}
</div>
))}
{isEditing && (
<Button
variant="outline"
size="sm"
className="w-full mt-2"
onClick={() => setItems([...items, ""])}
>
<Plus className="h-4 w-4 mr-2" />
Add Item
</Button>
)}
{!isEditing && items.length === 0 && (
<p className="text-xs text-muted-foreground italic">None</p>
)}
</CardContent>
</Card>
);
};
if (loading) {
return (
<div className={cn("space-y-4", className)}>
<Skeleton className="h-32 w-full" />
<Skeleton className="h-32 w-full" />
<Skeleton className="h-32 w-full" />
</div>
);
}
if (error) {
return (
<Card className={cn("border-destructive", className)}>
<CardContent className="pt-6">
<div className="flex items-center gap-2 text-destructive">
<AlertCircle className="h-5 w-5" />
<p>{error}</p>
</div>
</CardContent>
</Card>
);
}
if (!extraction) {
return (
<Card className={cn("border-dashed", className)}>
<CardContent className="pt-6">
<div className="text-center text-muted-foreground">
<Sparkles className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">No extraction results yet</p>
<p className="text-xs mt-1">Upload documents and trigger extraction to see insights</p>
</div>
</CardContent>
</Card>
);
}
return (
<div className={cn("space-y-4", className)}>
{/* Header */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Extracted Insights</CardTitle>
<p className="text-xs text-muted-foreground mt-1">
Review and edit the extracted information
</p>
</div>
<div className="flex items-center gap-2">
<Badge variant={extraction.readyForNextPhase ? "default" : "secondary"}>
{Math.round(extraction.confidence * 100)}% confidence
</Badge>
{!isEditing ? (
<Button
variant="outline"
size="sm"
onClick={() => setIsEditing(true)}
>
<Edit2 className="h-4 w-4 mr-2" />
Edit
</Button>
) : (
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={handleCancel}
disabled={isSaving}
>
Cancel
</Button>
<Button
size="sm"
onClick={handleSave}
disabled={isSaving}
>
<Save className="h-4 w-4 mr-2" />
{isSaving ? "Saving..." : "Save"}
</Button>
</div>
)}
</div>
</div>
</CardHeader>
</Card>
{/* Editable Lists */}
{renderEditableList(editedProblems, setEditedProblems, AlertTriangle, "text-orange-600", "Problems & Pain Points")}
{renderEditableList(editedUsers, setEditedUsers, Users, "text-blue-600", "Target Users")}
{renderEditableList(editedFeatures, setEditedFeatures, Lightbulb, "text-yellow-600", "Key Features")}
{renderEditableList(editedConstraints, setEditedConstraints, Wrench, "text-purple-600", "Constraints & Requirements")}
{renderEditableList(editedOpportunities, setEditedOpportunities, Sparkles, "text-green-600", "Opportunities")}
</div>
);
}

View File

@@ -0,0 +1,305 @@
"use client";
import { useEffect, useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { CheckCircle2, AlertTriangle, HelpCircle, Users, Lightbulb, Wrench, AlertCircle, Sparkles } from "lucide-react";
import { cn } from "@/lib/utils";
interface ExtractionHandoff {
phase: string;
readyForNextPhase: boolean;
confidence: number;
confirmed: {
problems?: string[];
targetUsers?: string[];
features?: string[];
constraints?: string[];
opportunities?: string[];
};
uncertain: Record<string, any>;
missing: string[];
questionsForUser: string[];
timestamp: string;
}
interface ExtractionResultsProps {
projectId: string;
className?: string;
}
export function ExtractionResults({ projectId, className }: ExtractionResultsProps) {
const [extraction, setExtraction] = useState<ExtractionHandoff | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchExtraction = async () => {
try {
setLoading(true);
const response = await fetch(`/api/projects/${projectId}/extraction-handoff`);
if (!response.ok) {
if (response.status === 404) {
setExtraction(null);
return;
}
throw new Error(`Failed to fetch extraction: ${response.statusText}`);
}
const data = await response.json();
setExtraction(data.handoff);
} catch (err) {
console.error("[ExtractionResults] Error:", err);
setError(err instanceof Error ? err.message : "Failed to load extraction");
} finally {
setLoading(false);
}
};
if (projectId) {
fetchExtraction();
}
}, [projectId]);
if (loading) {
return (
<div className={cn("space-y-4", className)}>
<Skeleton className="h-32 w-full" />
<Skeleton className="h-32 w-full" />
<Skeleton className="h-32 w-full" />
</div>
);
}
if (error) {
return (
<Card className={cn("border-destructive", className)}>
<CardContent className="pt-6">
<div className="flex items-center gap-2 text-destructive">
<AlertCircle className="h-5 w-5" />
<p>{error}</p>
</div>
</CardContent>
</Card>
);
}
if (!extraction) {
return (
<Card className={cn("border-dashed", className)}>
<CardContent className="pt-6">
<div className="text-center text-muted-foreground">
<Sparkles className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">No extraction results yet</p>
<p className="text-xs mt-1">Upload documents and trigger extraction to see insights</p>
</div>
</CardContent>
</Card>
);
}
const { confirmed } = extraction;
const confidencePercent = Math.round(extraction.confidence * 100);
return (
<div className={cn("space-y-4", className)}>
{/* Header with Confidence Score */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg">Extraction Results</CardTitle>
<div className="flex items-center gap-2">
<Badge variant={extraction.readyForNextPhase ? "default" : "secondary"}>
{extraction.readyForNextPhase ? (
<>
<CheckCircle2 className="h-3 w-3 mr-1" />
Ready
</>
) : (
<>
<HelpCircle className="h-3 w-3 mr-1" />
Incomplete
</>
)}
</Badge>
<Badge variant="outline" className={cn(
confidencePercent >= 70 ? "border-green-500 text-green-600" :
confidencePercent >= 40 ? "border-yellow-500 text-yellow-600" :
"border-red-500 text-red-600"
)}>
{confidencePercent}% confidence
</Badge>
</div>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Extracted on {new Date(extraction.timestamp).toLocaleString()}
</p>
</CardContent>
</Card>
{/* Problems / Pain Points */}
{confirmed.problems && confirmed.problems.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<AlertTriangle className="h-4 w-4 text-orange-500" />
Problems & Pain Points
<Badge variant="secondary">{confirmed.problems.length}</Badge>
</CardTitle>
</CardHeader>
<CardContent>
<ul className="space-y-2">
{confirmed.problems.map((problem, idx) => (
<li key={idx} className="flex items-start gap-2">
<span className="text-orange-500 mt-1"></span>
<span className="text-sm">{problem}</span>
</li>
))}
</ul>
</CardContent>
</Card>
)}
{/* Target Users */}
{confirmed.targetUsers && confirmed.targetUsers.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Users className="h-4 w-4 text-blue-500" />
Target Users
<Badge variant="secondary">{confirmed.targetUsers.length}</Badge>
</CardTitle>
</CardHeader>
<CardContent>
<ul className="space-y-2">
{confirmed.targetUsers.map((user, idx) => (
<li key={idx} className="flex items-start gap-2">
<span className="text-blue-500 mt-1"></span>
<span className="text-sm">{user}</span>
</li>
))}
</ul>
</CardContent>
</Card>
)}
{/* Features */}
{confirmed.features && confirmed.features.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Lightbulb className="h-4 w-4 text-yellow-500" />
Key Features
<Badge variant="secondary">{confirmed.features.length}</Badge>
</CardTitle>
</CardHeader>
<CardContent>
<ul className="space-y-2">
{confirmed.features.map((feature, idx) => (
<li key={idx} className="flex items-start gap-2">
<span className="text-yellow-500 mt-1"></span>
<span className="text-sm">{feature}</span>
</li>
))}
</ul>
</CardContent>
</Card>
)}
{/* Constraints */}
{confirmed.constraints && confirmed.constraints.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Wrench className="h-4 w-4 text-purple-500" />
Constraints & Requirements
<Badge variant="secondary">{confirmed.constraints.length}</Badge>
</CardTitle>
</CardHeader>
<CardContent>
<ul className="space-y-2">
{confirmed.constraints.map((constraint, idx) => (
<li key={idx} className="flex items-start gap-2">
<span className="text-purple-500 mt-1"></span>
<span className="text-sm">{constraint}</span>
</li>
))}
</ul>
</CardContent>
</Card>
)}
{/* Opportunities */}
{confirmed.opportunities && confirmed.opportunities.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Sparkles className="h-4 w-4 text-green-500" />
Opportunities
<Badge variant="secondary">{confirmed.opportunities.length}</Badge>
</CardTitle>
</CardHeader>
<CardContent>
<ul className="space-y-2">
{confirmed.opportunities.map((opportunity, idx) => (
<li key={idx} className="flex items-start gap-2">
<span className="text-green-500 mt-1"></span>
<span className="text-sm">{opportunity}</span>
</li>
))}
</ul>
</CardContent>
</Card>
)}
{/* Missing Information */}
{extraction.missing && extraction.missing.length > 0 && (
<Card className="border-yellow-200 bg-yellow-50/50">
<CardHeader>
<CardTitle className="text-base flex items-center gap-2 text-yellow-800">
<HelpCircle className="h-4 w-4" />
Missing Information
</CardTitle>
</CardHeader>
<CardContent>
<ul className="space-y-1">
{extraction.missing.map((item, idx) => (
<li key={idx} className="text-sm text-yellow-800 flex items-center gap-2">
<span>-</span>
<span>{item}</span>
</li>
))}
</ul>
</CardContent>
</Card>
)}
{/* Questions for User */}
{extraction.questionsForUser && extraction.questionsForUser.length > 0 && (
<Card className="border-blue-200 bg-blue-50/50">
<CardHeader>
<CardTitle className="text-base flex items-center gap-2 text-blue-800">
<HelpCircle className="h-4 w-4" />
Questions for Clarification
</CardTitle>
</CardHeader>
<CardContent>
<ul className="space-y-2">
{extraction.questionsForUser.map((question, idx) => (
<li key={idx} className="text-sm text-blue-800 flex items-start gap-2">
<span className="font-medium">{idx + 1}.</span>
<span>{question}</span>
</li>
))}
</ul>
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,129 @@
"use client";
import { CheckCircle2, Circle } from "lucide-react";
import { useEffect, useState } from "react";
import { db } from "@/lib/firebase/config";
import { doc, onSnapshot } from "firebase/firestore";
interface ExtractionReviewChecklistProps {
projectId: string;
className?: string;
}
export function ExtractionReviewChecklist({ projectId, className }: ExtractionReviewChecklistProps) {
const [hasProblems, setHasProblems] = useState(false);
const [hasUsers, setHasUsers] = useState(false);
const [hasFeatures, setHasFeatures] = useState(false);
const [reviewedWithAI, setReviewedWithAI] = useState(false);
const [readyForVision, setReadyForVision] = useState(false);
useEffect(() => {
if (!projectId) return;
const unsubscribe = onSnapshot(
doc(db, "projects", projectId),
(snapshot) => {
if (snapshot.exists()) {
const data = snapshot.data();
const extraction = data?.phaseData?.phaseHandoffs?.extraction;
if (extraction) {
// Check if we have key data
setHasProblems((extraction.confirmed?.problems?.length || 0) > 0);
setHasUsers((extraction.confirmed?.targetUsers?.length || 0) > 0);
setHasFeatures((extraction.confirmed?.features?.length || 0) > 0);
setReadyForVision(extraction.readyForNextPhase || false);
}
// Check if user has reviewed with AI (simplified: check if there are any messages in chat)
// This is a placeholder - you might track this differently
setReviewedWithAI(data?.lastChatAt ? true : false);
}
}
);
return () => unsubscribe();
}, [projectId]);
const checklist = [
{
id: "problems",
label: "Problems identified",
checked: hasProblems,
},
{
id: "users",
label: "Target users defined",
checked: hasUsers,
},
{
id: "features",
label: "Key features extracted",
checked: hasFeatures,
},
{
id: "reviewed",
label: "Reviewed with AI",
checked: reviewedWithAI,
},
];
const completedCount = checklist.filter((item) => item.checked).length;
const progress = (completedCount / checklist.length) * 100;
const isCompleted = completedCount === checklist.length || readyForVision;
return (
<div className={className}>
{/* Header */}
<div className="mb-3">
<h3 className="text-sm font-semibold">
{isCompleted ? '✓ Review Complete' : 'Extraction Review'}
</h3>
<div className="mt-2">
<div className="w-full bg-secondary h-2 rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all duration-300"
style={{ width: `${isCompleted ? 100 : progress}%` }}
/>
</div>
<p className="text-xs text-muted-foreground mt-1">
{isCompleted ? `${checklist.length} of ${checklist.length} complete` : `${completedCount} of ${checklist.length} complete`}
</p>
</div>
</div>
{/* Checklist Items */}
<div className="space-y-2">
{checklist.map((item) => (
<div
key={item.id}
className={`flex items-center gap-2 py-2 px-3 rounded-md border ${
(item.checked || isCompleted)
? "bg-green-50 border-green-200"
: "bg-muted/50 border-muted text-muted-foreground opacity-60"
}`}
>
{(item.checked || isCompleted) ? (
<CheckCircle2 className="h-4 w-4 text-green-600 flex-shrink-0" />
) : (
<Circle className="h-4 w-4 text-muted-foreground flex-shrink-0" />
)}
<div className="flex-1 min-w-0">
<p className="text-xs font-medium">{item.label}</p>
</div>
</div>
))}
</div>
{/* Ready indicator */}
{readyForVision && (
<div className="mt-4 pt-4 border-t">
<p className="text-xs text-green-600 font-medium">
Ready for Vision phase
</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,269 @@
'use client';
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Github, Loader2, CheckCircle2, ExternalLink, X } from 'lucide-react';
import { auth } from '@/lib/firebase/config';
import { doc, updateDoc, serverTimestamp } from 'firebase/firestore';
import { db } from '@/lib/firebase/config';
import { toast } from 'sonner';
import { initiateGitHubOAuth } from '@/lib/github/oauth';
interface GitHubRepoPickerProps {
projectId: string;
onRepoSelected?: (repo: any) => void;
onClose?: () => void;
}
export function GitHubRepoPicker({ projectId, onRepoSelected, onClose }: GitHubRepoPickerProps) {
const [loading, setLoading] = useState(false);
const [connected, setConnected] = useState(false);
const [repos, setRepos] = useState<any[]>([]);
const [selectedRepo, setSelectedRepo] = useState<any>(null);
const [saving, setSaving] = useState(false);
useEffect(() => {
checkConnection();
}, []);
const checkConnection = async () => {
setLoading(true);
try {
const user = auth.currentUser;
if (!user) return;
const token = await user.getIdToken();
const statusResponse = await fetch('/api/github/connect', {
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (statusResponse.ok) {
const statusData = await statusResponse.json();
setConnected(statusData.connected);
if (statusData.connected) {
const reposResponse = await fetch('/api/github/repos', {
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (reposResponse.ok) {
const reposData = await reposResponse.json();
setRepos(reposData);
}
}
}
} catch (error) {
console.error('Error checking GitHub connection:', error);
} finally {
setLoading(false);
}
};
const handleConnect = () => {
const redirectUri = `${window.location.origin}/api/github/oauth/callback`;
initiateGitHubOAuth(redirectUri);
};
const handleSelectRepo = async (repo: any) => {
setSelectedRepo(repo);
setSaving(true);
try {
const user = auth.currentUser;
if (!user) {
toast.error('Please sign in');
return;
}
// Update project with GitHub info
await updateDoc(doc(db, 'projects', projectId), {
githubRepo: repo.full_name,
githubRepoId: repo.id,
githubRepoUrl: repo.html_url,
githubDefaultBranch: repo.default_branch,
hasGithub: true,
updatedAt: serverTimestamp(),
});
// Try to automatically associate existing sessions with this repo
try {
const token = await user.getIdToken();
const associateResponse = await fetch(`/api/projects/${projectId}/associate-github-sessions`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
githubRepo: repo.full_name,
githubRepoUrl: repo.html_url,
}),
});
if (associateResponse.ok) {
const data = await associateResponse.json();
console.log('🔗 Session association result:', data);
if (data.sessionsAssociated > 0) {
toast.success(`Connected to ${repo.full_name}!`, {
description: `Found and linked ${data.sessionsAssociated} existing chat sessions from this repository`,
duration: 5000,
});
} else {
// No sessions found - show helpful message
toast.success(`Connected to ${repo.full_name}!`, {
description: `Repository linked! Future chat sessions from this repo will be automatically tracked here.`,
duration: 5000,
});
console.log(` No matching sessions found. This could mean:
- No chat sessions exist yet for this repo
- Sessions are already linked to other projects
- Workspace folder name doesn't match repo name (${repo.name})`);
}
} else {
// Connection succeeded but session association failed - still show success
toast.success(`Connected to ${repo.full_name}!`);
console.warn('Session association failed but connection succeeded');
}
} catch (associateError) {
// Don't fail the whole operation if association fails
console.error('Error associating sessions:', associateError);
toast.success(`Connected to ${repo.full_name}!`);
}
// Notify parent component
if (onRepoSelected) {
onRepoSelected(repo);
}
} catch (error) {
console.error('Error connecting repo:', error);
toast.error('Failed to connect repository');
setSelectedRepo(null);
} finally {
setSaving(false);
}
};
if (loading) {
return (
<Card className="my-2">
<CardContent className="flex items-center justify-center py-6">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</CardContent>
</Card>
);
}
if (!connected) {
return (
<Card className="my-2 border-blue-500/50 bg-blue-50/50 dark:bg-blue-950/20">
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Github className="h-5 w-5" />
Connect GitHub
</CardTitle>
<CardDescription>
Connect your GitHub account to select a repository
</CardDescription>
</CardHeader>
<CardContent>
<Button onClick={handleConnect} className="w-full">
<Github className="mr-2 h-4 w-4" />
Connect GitHub Account
</Button>
</CardContent>
</Card>
);
}
if (selectedRepo) {
return (
<Card className="my-2 border-green-500/50 bg-green-50/50 dark:bg-green-950/20">
<CardContent className="flex items-center gap-3 py-4">
<CheckCircle2 className="h-5 w-5 text-green-600" />
<div className="flex-1">
<p className="font-medium">{selectedRepo.full_name}</p>
<p className="text-sm text-muted-foreground">Repository connected!</p>
</div>
<a
href={selectedRepo.html_url}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-blue-600 hover:underline flex items-center gap-1"
>
<ExternalLink className="h-3 w-3" />
View
</a>
{onClose && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 ml-2"
onClick={onClose}
>
<X className="h-4 w-4" />
</Button>
)}
</CardContent>
</Card>
);
}
return (
<Card className="my-2">
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Github className="h-5 w-5" />
Select Repository
</CardTitle>
<CardDescription>
Choose which repository to connect to this project
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2 max-h-[300px] overflow-y-auto">
{repos.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">
No repositories found
</p>
) : (
repos.map((repo) => (
<button
key={repo.id}
onClick={() => handleSelectRepo(repo)}
disabled={saving}
className="w-full text-left p-3 rounded-lg border-2 border-border hover:border-primary transition-all disabled:opacity-50"
>
<div className="font-medium">{repo.full_name}</div>
{repo.description && (
<div className="text-sm text-muted-foreground truncate mt-1">
{repo.description}
</div>
)}
<div className="flex items-center gap-2 mt-2">
{repo.language && (
<span className="text-xs bg-muted px-2 py-0.5 rounded">
{repo.language}
</span>
)}
{repo.private && (
<span className="text-xs bg-yellow-500/10 text-yellow-600 px-2 py-0.5 rounded">
Private
</span>
)}
</div>
</button>
))
)}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,90 @@
"use client";
import { useEffect, useState } from "react";
import { db } from "@/lib/firebase/config";
import { doc, onSnapshot } from "firebase/firestore";
import { CollectorChecklist } from "./collector-checklist";
import { ExtractionReviewChecklist } from "./extraction-review-checklist";
import { ExtractionResults } from "./extraction-results";
import { CollectorActions } from "./collector-actions";
import { ProjectConfigGenerator } from "./project-config-generator";
interface PhaseSidebarProps {
projectId: string;
projectName?: string;
className?: string;
}
export function PhaseSidebar({ projectId, projectName, className }: PhaseSidebarProps) {
const [currentPhase, setCurrentPhase] = useState<string>("collector");
const [projectNameState, setProjectNameState] = useState<string>(projectName || "");
useEffect(() => {
if (!projectId) return;
const unsubscribe = onSnapshot(
doc(db, "projects", projectId),
(snapshot) => {
if (snapshot.exists()) {
const data = snapshot.data();
const phase = data?.currentPhase || "collector";
setCurrentPhase(phase);
setProjectNameState(data?.name || data?.productName || "");
}
}
);
return () => unsubscribe();
}, [projectId]);
return (
<div className={className}>
{/* Top: Show checklist based on current phase */}
{currentPhase === "collector" && (
<CollectorChecklist projectId={projectId} />
)}
{(currentPhase === "extraction_review" || currentPhase === "analyzed") && (
<ExtractionReviewChecklist projectId={projectId} />
)}
{/* Bottom: Phase-specific content */}
<div className="mt-6 pt-6 border-t">
{currentPhase === "collector" && (
<>
<h3 className="text-sm font-semibold mb-3">Quick Actions</h3>
<div className="space-y-2">
<ProjectConfigGenerator projectId={projectId} projectName={projectNameState} />
<CollectorActions projectId={projectId} />
</div>
</>
)}
{(currentPhase === "extraction_review" || currentPhase === "analyzed") && (
<p className="text-sm text-muted-foreground">
Review the extracted insights in the chat area above.
</p>
)}
{currentPhase === "vision" && (
<>
<h3 className="text-sm font-semibold mb-3">Vision Phase</h3>
<p className="text-xs text-muted-foreground">
Defining your product vision and MVP...
</p>
</>
)}
{currentPhase === "mvp" && (
<>
<h3 className="text-sm font-semibold mb-3">MVP Planning</h3>
<p className="text-xs text-muted-foreground">
Planning your minimum viable product...
</p>
</>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,171 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Download, Search, CheckCircle2, FileText } from "lucide-react";
import { toast } from "sonner";
interface ProjectConfigGeneratorProps {
projectId: string;
projectName: string;
}
export function ProjectConfigGenerator({ projectId, projectName }: ProjectConfigGeneratorProps) {
const [showDialog, setShowDialog] = useState(false);
const [searching, setSearching] = useState(false);
const [fileFound, setFileFound] = useState<boolean | null>(null);
const vibnConfig = {
projectId,
projectName,
version: "1.0",
};
const configContent = JSON.stringify(vibnConfig, null, 2);
const handleSearchFile = async () => {
setSearching(true);
setFileFound(null);
try {
// Prompt user to select the .vibn file
const input = document.createElement('input');
input.type = 'file';
input.accept = '.vibn,.json';
input.onchange = async (e: any) => {
const file = e.target?.files?.[0];
if (!file) {
setSearching(false);
return;
}
try {
const text = await file.text();
const config = JSON.parse(text);
if (config.projectId === projectId) {
setFileFound(true);
toast.success("File verified!", {
description: "Your .vibn file is correctly configured for this project",
});
} else {
setFileFound(false);
toast.error("Project ID mismatch", {
description: "This .vibn file is for a different project",
});
}
} catch (error) {
setFileFound(false);
toast.error("Invalid file", {
description: "Could not read or parse the .vibn file",
});
}
setSearching(false);
};
input.click();
} catch (error) {
toast.error("Failed to search for file");
setSearching(false);
}
};
const handleDownload = () => {
const blob = new Blob([configContent], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = ".vibn";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success("Configuration downloaded!", {
description: "Save it to your project root and restart Cursor",
});
};
return (
<>
<Button
variant="outline"
className="w-full justify-start h-auto py-2 px-3"
onClick={() => setShowDialog(true)}
>
<FileText className="h-4 w-4 mr-2 flex-shrink-0" />
<span className="text-xs">Link Workspace</span>
</Button>
<Dialog open={showDialog} onOpenChange={setShowDialog}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FileText className="h-5 w-5" />
Link Your Workspace
</DialogTitle>
<DialogDescription>
Add a <code className="text-xs bg-muted px-1 py-0.5 rounded">.vibn</code> file to your project root so the Cursor extension automatically tracks sessions.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Primary Action */}
<Button onClick={handleDownload} className="w-full" size="lg">
<Download className="h-5 w-5 mr-2" />
Download .vibn
</Button>
{/* Instructions */}
<div className="text-sm space-y-3 bg-muted/50 p-3 rounded-md">
<p className="font-medium">Setup Steps:</p>
<ol className="text-xs text-muted-foreground space-y-1 list-decimal ml-4">
<li>Download the file above</li>
<li>Save it to your project root as <code className="bg-background px-1 rounded">.vibn</code></li>
<li>Commit and push to GitHub</li>
<li>Extension will auto-link sessions to this project</li>
</ol>
</div>
{/* Verification */}
<div className="pt-2 space-y-2">
<Button
onClick={handleSearchFile}
variant="outline"
className="w-full"
size="sm"
disabled={searching}
>
{searching ? (
<>
<Search className="h-4 w-4 mr-2 animate-spin" />
Verifying...
</>
) : fileFound === true ? (
<>
<CheckCircle2 className="h-4 w-4 mr-2 text-green-600" />
File Found
</>
) : (
<>
<Search className="h-4 w-4 mr-2" />
Verify File Added
</>
)}
</Button>
{fileFound === false && (
<p className="text-xs text-red-600 text-center">File not found or incorrect projectId</p>
)}
{fileFound === true && (
<p className="text-xs text-green-600 text-center">Ready! Push to GitHub and you're all set.</p>
)}
</div>
</div>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,245 @@
'use client';
import { useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Loader2, CheckCircle2, Sparkles } from 'lucide-react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
interface VisionFormProps {
projectId: string;
workspace?: string;
onComplete?: () => void;
}
export function VisionForm({ projectId, workspace, onComplete }: VisionFormProps) {
const [currentQuestion, setCurrentQuestion] = useState(1);
const [answers, setAnswers] = useState({
q1: '',
q2: '',
q3: '',
});
const [saving, setSaving] = useState(false);
const [generating, setGenerating] = useState(false);
const questions = [
{
number: 1,
key: 'q1' as const,
question: 'Who has the problem you want to fix and what is it?',
placeholder: 'E.g., Fantasy hockey GMs need an advantage over their competitors...',
},
{
number: 2,
key: 'q2' as const,
question: 'Tell me a story of this person using your tool and experiencing your vision?',
placeholder: 'E.g., The user connects their hockey pool site...',
},
{
number: 3,
key: 'q3' as const,
question: 'How much did that improve things for them?',
placeholder: 'E.g., They feel relieved and excited...',
},
];
const currentQ = questions[currentQuestion - 1];
const handleNext = () => {
if (!answers[currentQ.key].trim()) {
toast.error('Please answer the question before continuing');
return;
}
setCurrentQuestion(currentQuestion + 1);
};
const handleBack = () => {
setCurrentQuestion(currentQuestion - 1);
};
const handleSubmit = async () => {
if (!answers.q3.trim()) {
toast.error('Please answer the question');
return;
}
try {
setSaving(true);
// Save vision answers to Firestore
const response = await fetch(`/api/projects/${projectId}/vision`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
visionAnswers: {
q1: answers.q1,
q2: answers.q2,
q3: answers.q3,
allAnswered: true,
updatedAt: new Date().toISOString(),
},
}),
});
if (!response.ok) {
throw new Error('Failed to save vision answers');
}
toast.success('Vision captured! Generating your MVP plan...');
setSaving(false);
setGenerating(true);
// Trigger MVP generation
const mvpResponse = await fetch(`/api/projects/${projectId}/mvp-checklist`, {
method: 'POST',
});
if (!mvpResponse.ok) {
throw new Error('Failed to generate MVP plan');
}
setGenerating(false);
toast.success('MVP plan generated! Redirecting to plan page...');
// Redirect to plan page
if (workspace) {
setTimeout(() => {
window.location.href = `/${workspace}/project/${projectId}/plan`;
}, 1000);
}
onComplete?.();
} catch (error) {
console.error('Error saving vision:', error);
toast.error('Failed to save vision and generate plan');
setSaving(false);
setGenerating(false);
}
};
if (generating) {
return (
<div className="flex flex-col items-center justify-center h-[400px] space-y-4">
<Sparkles className="h-12 w-12 text-primary animate-pulse" />
<h3 className="text-xl font-semibold">Generating your MVP plan...</h3>
<p className="text-muted-foreground">This may take up to a minute</p>
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="max-w-2xl mx-auto py-8 space-y-6">
{/* Progress indicator */}
<div className="flex items-center justify-center space-x-4 mb-8">
{questions.map((q, idx) => (
<div key={q.number} className="flex items-center">
<div
className={cn(
'flex items-center justify-center w-8 h-8 rounded-full border-2 font-semibold text-sm transition-all',
currentQuestion > q.number
? 'bg-primary text-primary-foreground border-primary'
: currentQuestion === q.number
? 'border-primary text-primary'
: 'border-muted text-muted-foreground'
)}
>
{currentQuestion > q.number ? (
<CheckCircle2 className="h-4 w-4" />
) : (
q.number
)}
</div>
{idx < questions.length - 1 && (
<div
className={cn(
'w-12 h-0.5 mx-2 transition-all',
currentQuestion > q.number ? 'bg-primary' : 'bg-muted'
)}
/>
)}
</div>
))}
</div>
{/* Current question card */}
<Card>
<CardHeader>
<CardTitle className="text-2xl">
Question {currentQ.number} of 3
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<label className="text-lg font-medium leading-relaxed">
{currentQ.question}
</label>
<Textarea
value={answers[currentQ.key]}
onChange={(e) =>
setAnswers({ ...answers, [currentQ.key]: e.target.value })
}
placeholder={currentQ.placeholder}
className="min-h-[120px] text-base"
autoFocus
/>
</div>
<div className="flex items-center justify-between pt-4">
<Button
variant="outline"
onClick={handleBack}
disabled={currentQuestion === 1}
>
Back
</Button>
{currentQuestion < 3 ? (
<Button onClick={handleNext}>
Next Question
</Button>
) : (
<Button onClick={handleSubmit} disabled={saving}>
{saving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : (
<>
<Sparkles className="mr-2 h-4 w-4" />
Generate MVP Plan
</>
)}
</Button>
)}
</div>
</CardContent>
</Card>
{/* Show all answers summary */}
{currentQuestion > 1 && (
<Card className="bg-muted/50">
<CardHeader>
<CardTitle className="text-sm font-medium text-muted-foreground">
Your Vision So Far
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{questions.slice(0, currentQuestion - 1).map((q) => (
<div key={q.number} className="text-sm">
<p className="font-medium text-muted-foreground mb-1">
Q{q.number}: {q.question}
</p>
<p className="text-foreground">{answers[q.key]}</p>
</div>
))}
</CardContent>
</Card>
)}
</div>
);
}