refactor: simplify project nav to AI Chat (overview) + Design only
- AI Chat nav item now routes to /overview instead of /v_ai_chat - Removed Plan, Docs, Tech, Journey nav items - Deleted old v_ai_chat page - Cleaned up unused imports and route detection logic Made-with: Cursor
This commit is contained in:
@@ -1,765 +0,0 @@
|
||||
"use client";
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Send, Loader2, Paperclip, X, FileText, RotateCcw, Upload, CheckCircle2, AlertTriangle, Sparkles } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { toast } from "sonner";
|
||||
import { GitHubRepoPicker } from "@/components/ai/github-repo-picker";
|
||||
import { PhaseSidebar } from "@/components/ai/phase-sidebar";
|
||||
import { CollapsibleSidebar } from "@/components/ui/collapsible-sidebar";
|
||||
import { ExtractionResultsEditable } from "@/components/ai/extraction-results-editable";
|
||||
import type { ChatExtractionData } from "@/lib/ai/chat-extraction-types";
|
||||
import { VisionForm } from "@/components/ai/vision-form";
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
showGitHubPicker?: boolean;
|
||||
meta?: {
|
||||
mode?: string;
|
||||
projectPhase?: string;
|
||||
artifactsUsed?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
const MODE_LABELS: Record<string, string> = {
|
||||
collector_mode: "Collecting context",
|
||||
extraction_review_mode: "Reviewing signals",
|
||||
vision_mode: "Product vision",
|
||||
mvp_mode: "MVP planning",
|
||||
marketing_mode: "Marketing & launch",
|
||||
general_chat_mode: "General product chat",
|
||||
};
|
||||
|
||||
type ChatApiResponse = {
|
||||
reply: string;
|
||||
mode?: string;
|
||||
projectPhase?: string;
|
||||
artifactsUsed?: string[];
|
||||
};
|
||||
|
||||
function ModeBadge({ mode, phase, artifacts }: { mode: string | null; phase: string | null; artifacts?: string[] }) {
|
||||
if (!mode) return null;
|
||||
return (
|
||||
<div className="flex flex-col items-end gap-1 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="rounded-full border px-2 py-0.5">
|
||||
{MODE_LABELS[mode] ?? mode}
|
||||
</span>
|
||||
{phase ? <span>{phase}</span> : null}
|
||||
</div>
|
||||
{artifacts && artifacts.length > 0 ? (
|
||||
<span className="text-[10px] text-muted-foreground/80">
|
||||
Using: {artifacts.join(', ')}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GettingStartedPage() {
|
||||
const params = useParams();
|
||||
const projectId = params.projectId as string;
|
||||
const workspace = params.workspace as string;
|
||||
const { status: sessionStatus } = useSession();
|
||||
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [input, setInput] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const [attachedFiles, setAttachedFiles] = useState<Array<{name: string, content: string, type: string}>>([]);
|
||||
const [routerMode, setRouterMode] = useState<string | null>(null);
|
||||
const [routerPhase, setRouterPhase] = useState<string | null>(null);
|
||||
const [routerArtifacts, setRouterArtifacts] = useState<string[]>([]);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [showExtractor, setShowExtractor] = useState(false);
|
||||
const [extractForm, setExtractForm] = useState({
|
||||
title: "",
|
||||
provider: "chatgpt",
|
||||
transcript: "",
|
||||
sourceLink: "",
|
||||
});
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const [extractionStatus, setExtractionStatus] = useState<"idle" | "importing" | "extracting" | "done" | "error">("idle");
|
||||
const [extractionError, setExtractionError] = useState<string | null>(null);
|
||||
const [lastExtraction, setLastExtraction] = useState<ChatExtractionData | null>(null);
|
||||
const [currentPhase, setCurrentPhase] = useState<string>("collector");
|
||||
const [hasVisionAnswers, setHasVisionAnswers] = useState<boolean>(false);
|
||||
const [checkingVision, setCheckingVision] = useState(true);
|
||||
|
||||
// Auto-scroll to bottom when new messages arrive
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
// Load project phase + vision answers from the Postgres-backed API
|
||||
useEffect(() => {
|
||||
if (!projectId) return;
|
||||
|
||||
const loadProject = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/projects/${projectId}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const phase = data.project?.currentPhase || 'collector';
|
||||
setCurrentPhase(phase);
|
||||
const hasAnswers = data.project?.visionAnswers?.allAnswered === true;
|
||||
setHasVisionAnswers(hasAnswers);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading project:', error);
|
||||
} finally {
|
||||
setCheckingVision(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadProject();
|
||||
}, [projectId]);
|
||||
|
||||
// Initialize with AI welcome message
|
||||
useEffect(() => {
|
||||
if (!isInitialized && projectId && sessionStatus !== 'loading') {
|
||||
const initialize = async () => {
|
||||
if (sessionStatus === 'unauthenticated') {
|
||||
setIsLoading(false);
|
||||
setIsInitialized(true);
|
||||
setTimeout(() => sendChatMessage("Hello"), 500);
|
||||
return;
|
||||
}
|
||||
|
||||
// Signed in via NextAuth — load conversation history
|
||||
try {
|
||||
// Fetch existing conversation history
|
||||
const historyResponse = await fetch(`/api/ai/conversation?projectId=${projectId}`);
|
||||
|
||||
let existingMessages: Message[] = [];
|
||||
|
||||
if (historyResponse.ok) {
|
||||
type StoredMessage = {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
createdAt?: string | { _seconds: number };
|
||||
};
|
||||
const historyData = await historyResponse.json() as { messages: StoredMessage[] };
|
||||
existingMessages = historyData.messages
|
||||
.filter((msg) =>
|
||||
msg.content !== '[VISION_AGENT_AUTO_START]' &&
|
||||
msg.content.trim() !== "Hi! I'm here to help." &&
|
||||
msg.content.trim() !== "Hello" // Filter out auto-generated greeting trigger
|
||||
)
|
||||
.map((msg) => ({
|
||||
id: crypto.randomUUID(),
|
||||
role: msg.role,
|
||||
content: msg.content,
|
||||
timestamp: msg.createdAt
|
||||
? (typeof msg.createdAt === 'string'
|
||||
? new Date(msg.createdAt)
|
||||
: new Date(msg.createdAt._seconds * 1000))
|
||||
: new Date(),
|
||||
}));
|
||||
|
||||
console.log(`[Chat] Loaded ${existingMessages.length} messages from history`);
|
||||
}
|
||||
|
||||
// If there's existing conversation, just show it
|
||||
if (existingMessages.length > 0) {
|
||||
setMessages(existingMessages);
|
||||
setIsLoading(false);
|
||||
setIsInitialized(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, trigger AI to generate the first message
|
||||
setIsLoading(false);
|
||||
setIsInitialized(true);
|
||||
|
||||
// Automatically send a greeting to get AI's welcome message
|
||||
setTimeout(() => {
|
||||
sendChatMessage("Hello");
|
||||
}, 500);
|
||||
} catch (error) {
|
||||
console.error('Error initializing chat:', error);
|
||||
// Show error state but don't send automatic message
|
||||
setMessages([{
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant',
|
||||
content: "Welcome! There was an issue loading your chat history, but let's get started. What would you like to work on?",
|
||||
timestamp: new Date(),
|
||||
}]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setIsInitialized(true);
|
||||
}
|
||||
};
|
||||
|
||||
initialize();
|
||||
}
|
||||
}, [projectId, isInitialized, sessionStatus]);
|
||||
|
||||
const sendChatMessage = async (messageContent: string) => {
|
||||
const content = messageContent.trim();
|
||||
if (!content) return;
|
||||
|
||||
const userMessage: Message = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'user',
|
||||
content,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
setMessages((prev) => [...prev, userMessage]);
|
||||
setIsSending(true);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/ai/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ projectId, message: content }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Chat API error: ${response.status} ${errorText}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as ChatApiResponse;
|
||||
const aiMessage: Message = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant',
|
||||
content: data.reply || 'No response generated.',
|
||||
timestamp: new Date(),
|
||||
meta: {
|
||||
mode: data.mode,
|
||||
projectPhase: data.projectPhase,
|
||||
artifactsUsed: data.artifactsUsed,
|
||||
},
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, aiMessage]);
|
||||
setRouterMode(data.mode ?? null);
|
||||
setRouterPhase(data.projectPhase ?? null);
|
||||
setRouterArtifacts(data.artifactsUsed ?? []);
|
||||
} catch (error) {
|
||||
console.error('Chat send failed', error);
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant',
|
||||
content: 'Sorry, something went wrong talking to the AI.',
|
||||
timestamp: new Date(),
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
setIsSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSend = () => {
|
||||
if ((!input.trim() && attachedFiles.length === 0) || isSending) return;
|
||||
|
||||
let messageContent = input.trim();
|
||||
if (attachedFiles.length > 0) {
|
||||
messageContent += '\n\n**Attached Files:**\n';
|
||||
attachedFiles.forEach(file => {
|
||||
messageContent += `\n--- ${file.name} ---\n${file.content}\n`;
|
||||
});
|
||||
}
|
||||
|
||||
setInput("");
|
||||
setAttachedFiles([]);
|
||||
sendChatMessage(messageContent);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileUpload = async (files: FileList | null) => {
|
||||
if (!files) return;
|
||||
|
||||
const newFiles: Array<{name: string, content: string, type: string}> = [];
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
|
||||
// Check file size (max 100MB for large exports like ChatGPT conversations)
|
||||
if (file.size > 100 * 1024 * 1024) {
|
||||
toast.error(`File ${file.name} is too large (max 100MB)`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Read file content
|
||||
const content = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => resolve(e.target?.result as string);
|
||||
reader.onerror = reject;
|
||||
reader.readAsText(file);
|
||||
});
|
||||
|
||||
newFiles.push({
|
||||
name: file.name,
|
||||
content,
|
||||
type: file.type,
|
||||
});
|
||||
}
|
||||
|
||||
setAttachedFiles([...attachedFiles, ...newFiles]);
|
||||
toast.success(`Added ${newFiles.length} file(s)`);
|
||||
};
|
||||
|
||||
const handlePaste = async (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
|
||||
if (item.kind === 'file') {
|
||||
e.preventDefault();
|
||||
const file = item.getAsFile();
|
||||
if (file) {
|
||||
await handleFileUpload([file] as unknown as FileList);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
setAttachedFiles(attachedFiles.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleResetChat = async () => {
|
||||
if (!confirm('Are you sure you want to reset this conversation? This will delete all messages and start fresh.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/ai/conversation?projectId=${projectId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
toast.success('Chat reset! Reloading...');
|
||||
// Reload the page to start fresh
|
||||
setTimeout(() => window.location.reload(), 500);
|
||||
} else {
|
||||
toast.error('Failed to reset chat');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error resetting chat:', error);
|
||||
toast.error('Failed to reset chat');
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportAndExtract = async () => {
|
||||
if (!extractForm.transcript.trim()) {
|
||||
toast.error("Please paste a transcript first");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsImporting(true);
|
||||
setExtractionStatus("importing");
|
||||
setExtractionError(null);
|
||||
|
||||
const importResponse = await fetch(`/api/projects/${projectId}/knowledge/import-ai-chat`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
title: extractForm.title || "Imported AI chat",
|
||||
provider: extractForm.provider,
|
||||
transcript: extractForm.transcript,
|
||||
sourceLink: extractForm.sourceLink || null,
|
||||
createdAtOriginal: new Date().toISOString(),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!importResponse.ok) {
|
||||
const errorData = await importResponse.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || "Failed to import transcript");
|
||||
}
|
||||
|
||||
const { knowledgeItem } = await importResponse.json();
|
||||
setExtractionStatus("extracting");
|
||||
|
||||
const extractResponse = await fetch(`/api/projects/${projectId}/extract-from-chat`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ knowledgeItemId: knowledgeItem.id }),
|
||||
});
|
||||
|
||||
if (!extractResponse.ok) {
|
||||
const errorData = await extractResponse.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || "Failed to extract signals");
|
||||
}
|
||||
|
||||
const { extraction } = await extractResponse.json();
|
||||
setLastExtraction(extraction.data);
|
||||
setExtractionStatus("done");
|
||||
toast.success("Signals extracted");
|
||||
} catch (error) {
|
||||
console.error("[chat extraction] failed", error);
|
||||
setExtractionStatus("error");
|
||||
setExtractionError(error instanceof Error ? error.message : "Unknown error");
|
||||
toast.error("Could not extract signals");
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Show vision form if no answers yet
|
||||
if (checkingVision) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasVisionAnswers) {
|
||||
return (
|
||||
<div className="relative h-full w-full bg-background overflow-hidden flex">
|
||||
{/* Left Sidebar */}
|
||||
<CollapsibleSidebar>
|
||||
<PhaseSidebar projectId={projectId} />
|
||||
</CollapsibleSidebar>
|
||||
|
||||
{/* Vision Form */}
|
||||
<div className="flex-1 flex flex-col overflow-auto">
|
||||
<div className="border-b bg-background/95 backdrop-blur-sm">
|
||||
<div className="max-w-3xl mx-auto px-4 py-3">
|
||||
<h2 className="text-lg font-semibold">Let's Start with Your Vision</h2>
|
||||
<p className="text-xs text-muted-foreground">Answer 3 quick questions to generate your MVP plan</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<VisionForm
|
||||
projectId={projectId}
|
||||
workspace={workspace}
|
||||
onComplete={() => {
|
||||
setHasVisionAnswers(true);
|
||||
toast.success('Vision saved! MVP plan generated.');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full bg-background overflow-hidden flex">
|
||||
{/* Left Sidebar - Phase-based content */}
|
||||
<CollapsibleSidebar>
|
||||
<PhaseSidebar projectId={projectId} />
|
||||
</CollapsibleSidebar>
|
||||
|
||||
{/* Main Chat Area */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden relative">
|
||||
{/* Header with Reset Button */}
|
||||
<div className="border-b bg-background/95 backdrop-blur-sm">
|
||||
<div className="max-w-3xl mx-auto px-4 py-3 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">AI Assistant</h2>
|
||||
<p className="text-xs text-muted-foreground">Building your project step-by-step</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<ModeBadge mode={routerMode} phase={routerPhase} artifacts={routerArtifacts} />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleResetChat}
|
||||
disabled={isLoading || isSending}
|
||||
className="gap-2"
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
Reset Chat
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages Container - Scrollable */}
|
||||
<div className="flex-1 overflow-y-auto pb-[200px]">
|
||||
<div className="max-w-3xl mx-auto px-4 pt-8 pb-8">
|
||||
<div className="space-y-6">
|
||||
{messages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className={cn(
|
||||
"flex gap-3 animate-in fade-in slide-in-from-bottom-4 duration-500",
|
||||
message.role === 'user' ? "flex-row-reverse" : ""
|
||||
)}
|
||||
>
|
||||
{/* Avatar */}
|
||||
{message.role === 'assistant' ? (
|
||||
<div className="h-8 w-8 rounded-full shrink-0 overflow-hidden bg-white">
|
||||
<img
|
||||
src="/vibn-logo-circle.png"
|
||||
alt="AI"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-8 w-8 rounded-full bg-primary flex items-center justify-center text-xs font-medium text-primary-foreground shrink-0">
|
||||
You
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Message Bubble */}
|
||||
<div className="flex-1 space-y-2 max-w-[85%]">
|
||||
<div
|
||||
className={cn(
|
||||
"text-[15px] leading-relaxed rounded-2xl px-4 py-3 shadow-sm whitespace-pre-wrap",
|
||||
message.role === 'assistant'
|
||||
? "bg-muted/50"
|
||||
: "bg-primary text-primary-foreground"
|
||||
)}
|
||||
>
|
||||
{message.content}
|
||||
</div>
|
||||
|
||||
{/* GitHub Repo Picker (if AI requested it) */}
|
||||
{message.role === 'assistant' && message.showGitHubPicker && (
|
||||
<div className="mt-2">
|
||||
<GitHubRepoPicker
|
||||
projectId={projectId}
|
||||
onRepoSelected={(repo) => {
|
||||
const confirmMessage = `Yes, I connected ${repo.full_name}`;
|
||||
sendChatMessage(confirmMessage);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className={cn(
|
||||
"text-xs text-muted-foreground px-2",
|
||||
message.role === 'user' ? "text-right" : ""
|
||||
)}>
|
||||
{message.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{isSending && (
|
||||
<div className="flex gap-3 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||
<div className="h-8 w-8 rounded-full shrink-0 overflow-hidden bg-white">
|
||||
<img
|
||||
src="/vibn-logo-circle.png"
|
||||
alt="AI thinking"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 space-y-1">
|
||||
<div className="text-sm bg-muted rounded-2xl px-5 py-3 w-fit">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<span className="text-xs font-medium">Assistant is thinking…</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show Extraction Results at bottom if in extraction_review phase */}
|
||||
{(currentPhase === "extraction_review" || currentPhase === "analyzed") && (
|
||||
<div className="mt-8">
|
||||
<ExtractionResultsEditable projectId={projectId} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Floating Chat Input - Fixed at Bottom */}
|
||||
<div className="absolute bottom-0 left-0 right-0 border-t bg-background/95 backdrop-blur-sm shadow-lg z-10">
|
||||
<div className="max-w-3xl mx-auto px-4 py-3 space-y-3">
|
||||
{false && showExtractor && (
|
||||
<Card className="border-primary/30 bg-primary/5">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-primary" />
|
||||
Paste AI chat transcript → extract signals
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs text-muted-foreground uppercase">Title</label>
|
||||
<Input
|
||||
placeholder="ChatGPT brainstorm"
|
||||
value={extractForm.title}
|
||||
onChange={(e) => setExtractForm((prev) => ({ ...prev, title: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs text-muted-foreground uppercase">Provider</label>
|
||||
<select
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
value={extractForm.provider}
|
||||
onChange={(e) => setExtractForm((prev) => ({ ...prev, provider: e.target.value }))}
|
||||
>
|
||||
{['chatgpt', 'gemini', 'claude', 'cursor', 'vibn', 'other'].map((provider) => (
|
||||
<option key={provider} value={provider}>
|
||||
{provider.toUpperCase()}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5 text-sm">
|
||||
<label className="text-xs text-muted-foreground uppercase">Transcript</label>
|
||||
<Textarea
|
||||
placeholder="Paste the AI conversation here..."
|
||||
className="min-h-[120px]"
|
||||
value={extractForm.transcript}
|
||||
onChange={(e) => setExtractForm((prev) => ({ ...prev, transcript: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm">
|
||||
<Button onClick={handleImportAndExtract} disabled={isImporting}>
|
||||
{isImporting ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
Processing…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
Import & Extract
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{extractionStatus === "done" && lastExtraction && (
|
||||
<span className="text-emerald-600 text-xs flex items-center gap-1">
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
Signals captured below
|
||||
</span>
|
||||
)}
|
||||
{extractionStatus === "error" && extractionError && (
|
||||
<span className="text-destructive text-xs flex items-center gap-1">
|
||||
<AlertTriangle className="h-3.5 w-3.5" />
|
||||
{extractionError}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{lastExtraction && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
|
||||
<div className="rounded-lg border bg-background p-3">
|
||||
<p className="text-xs text-muted-foreground uppercase">Working title</p>
|
||||
<p className="font-medium">
|
||||
{lastExtraction?.project_summary?.working_title || "Not captured"}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground uppercase mt-2">One-liner</p>
|
||||
<p>{lastExtraction?.project_summary?.one_liner || "—"}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-background p-3">
|
||||
<p className="text-xs text-muted-foreground uppercase">Primary problem</p>
|
||||
<p>{lastExtraction?.product_vision?.problem_statement?.description || "Not detected"}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Attached Files Display */}
|
||||
{attachedFiles.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{attachedFiles.map((file, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center gap-2 bg-muted px-3 py-2 rounded-lg text-sm border"
|
||||
>
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">{file.name}</span>
|
||||
<button
|
||||
onClick={() => removeFile(index)}
|
||||
className="ml-1 hover:text-destructive"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 items-end">
|
||||
<div className="flex-1">
|
||||
<Textarea
|
||||
placeholder="Message..."
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
className="min-h-[48px] max-h-[120px] resize-none bg-background shadow-sm text-[15px]"
|
||||
disabled={isLoading || isSending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Hidden file input */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept=".txt,.md,.json,.js,.ts,.tsx,.jsx,.py,.java,.cpp,.c,.html,.css,.xml,.yaml,.yml"
|
||||
className="hidden"
|
||||
onChange={(e) => handleFileUpload(e.target.files)}
|
||||
/>
|
||||
|
||||
{/* File Upload Button */}
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isLoading || isSending}
|
||||
className="h-[48px] w-[48px] shrink-0"
|
||||
title="Attach files"
|
||||
>
|
||||
<Paperclip className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{/* Send Button */}
|
||||
<Button
|
||||
size="icon"
|
||||
onClick={handleSend}
|
||||
disabled={(!input.trim() && attachedFiles.length === 0) || isLoading || isSending}
|
||||
className="h-[48px] w-[48px] shrink-0"
|
||||
>
|
||||
{isSending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1.5 px-1">
|
||||
Press <kbd className="px-1 py-0.5 bg-muted rounded text-[10px] border">Enter</kbd> to send • <kbd className="px-1 py-0.5 bg-muted rounded text-[10px] border">Shift+Enter</kbd> for new line
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,21 +18,8 @@ export function AppShell({ children, workspace, projectId, projectName }: AppShe
|
||||
|
||||
// Derive active section from pathname
|
||||
const derivedSection = useMemo(() => {
|
||||
if (pathname.includes('/v_ai_chat')) {
|
||||
return 'ai-chat';
|
||||
} else if (pathname.includes('/overview')) {
|
||||
return 'home';
|
||||
} else if (pathname.includes('/docs')) {
|
||||
return 'docs';
|
||||
} else if (pathname.includes('/plan') || pathname.includes('/timeline-plan')) {
|
||||
return 'plan';
|
||||
} else if (pathname.includes('/design')) {
|
||||
return 'design';
|
||||
} else if (pathname.includes('/tech')) {
|
||||
return 'tech';
|
||||
} else if (pathname.includes('/journey')) {
|
||||
return 'journey';
|
||||
}
|
||||
if (pathname.includes('/overview')) return 'home';
|
||||
if (pathname.includes('/design')) return 'design';
|
||||
return activeSection;
|
||||
}, [pathname, activeSection]);
|
||||
|
||||
|
||||
@@ -2,26 +2,12 @@
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
LayoutGrid,
|
||||
Inbox,
|
||||
Users,
|
||||
Receipt,
|
||||
Globe,
|
||||
FileText,
|
||||
MessageCircle,
|
||||
Settings,
|
||||
HelpCircle,
|
||||
User,
|
||||
DollarSign,
|
||||
Key,
|
||||
Palette,
|
||||
Target,
|
||||
Megaphone,
|
||||
ClipboardList,
|
||||
Code2,
|
||||
Sparkles,
|
||||
Cog,
|
||||
GitBranch,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
@@ -31,11 +17,8 @@ import { toast } from "sonner";
|
||||
type NavSection = "home" | "ai-chat" | "docs" | "plan" | "design" | "tech" | "journey" | "settings";
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ id: "plan" as NavSection, icon: ClipboardList, label: "Plan", href: "/project/{projectId}/plan" },
|
||||
{ id: "docs" as NavSection, icon: FileText, label: "Docs", href: "/project/{projectId}/docs" },
|
||||
{ id: "home" as NavSection, icon: Sparkles, label: "AI Chat", href: "/project/{projectId}/overview" },
|
||||
{ id: "design" as NavSection, icon: Palette, label: "Design", href: "/project/{projectId}/design" },
|
||||
{ id: "tech" as NavSection, icon: Cog, label: "Tech", href: "/project/{projectId}/tech" },
|
||||
{ id: "journey" as NavSection, icon: GitBranch, label: "Journey", href: "/project/{projectId}/journey" },
|
||||
];
|
||||
|
||||
interface LeftRailProps {
|
||||
@@ -61,27 +44,6 @@ export function LeftRail({ activeSection, onSectionChange, projectName, projectI
|
||||
<div className="w-full flex flex-col items-center">
|
||||
{/* Main Navigation Items */}
|
||||
<div className="flex flex-col gap-2 w-full items-center">
|
||||
{/* AI Chat - Always visible */}
|
||||
{projectName && projectId && (
|
||||
<Link
|
||||
href={`/${workspace}/project/${projectId}/v_ai_chat`}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-1 w-full py-2 px-2 transition-all relative",
|
||||
activeSection === "ai-chat"
|
||||
? "text-primary bg-primary/5"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
)}
|
||||
title="AI Chat"
|
||||
>
|
||||
<Sparkles className="h-5 w-5" />
|
||||
<span className="text-[10px] font-medium">AI Chat</span>
|
||||
{activeSection === "ai-chat" && (
|
||||
<div className="absolute left-0 top-1/2 -translate-y-1/2 h-10 w-1 bg-primary" />
|
||||
)}
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Other Nav Items */}
|
||||
{NAV_ITEMS.map((item) => {
|
||||
if (!projectId) return null;
|
||||
const fullHref = `/${workspace}${item.href.replace('{projectId}', projectId)}`;
|
||||
|
||||
Reference in New Issue
Block a user