Manager
View Site
Name
Type
React (.jsx)
HTML (.html)
Icon
Description
Code Editor
Revision History (0)
import React, { useState, useEffect, useRef, useMemo } from 'react'; import { GitBranch, Send, Settings, CornerDownRight, GitCommit, ZoomOut, Plus, Trash2, MessageSquare, Edit2, Check, Key, PanelLeftClose, PanelLeftOpen, X, Pencil } from 'lucide-react'; /** * UTILITY: Generate a unique ID */ const generateId = () => Math.random().toString(36).substr(2, 9); /** * COMPONENT: Enhanced Markdown Renderer */ const MarkdownRenderer = ({ content }) => { if (!content) return null; const codeBlockRegex = /```(\w*)\n([\s\S]*?)```/g; const blocks = []; let lastIndex = 0; let match; while ((match = codeBlockRegex.exec(content)) !== null) { if (match.index > lastIndex) { blocks.push({ type: 'text', content: content.slice(lastIndex, match.index) }); } blocks.push({ type: 'code', lang: match[1], content: match[2] }); lastIndex = codeBlockRegex.lastIndex; } if (lastIndex < content.length) { blocks.push({ type: 'text', content: content.slice(lastIndex) }); } const parseInline = (text) => { const parts = text.split(/(`[^`]+`|\*\*[^*]+\*\*|\*[^*]+\*)/g); return parts.map((part, i) => { if (part.startsWith('`') && part.endsWith('`')) { return <code key={i} className="bg-slate-950/50 px-1.5 rounded text-amber-200 font-mono text-[0.9em] border border-slate-700/50">{part.slice(1, -1)}</code>; } if (part.startsWith('**') && part.endsWith('**')) { return <strong key={i} className="font-bold text-blue-200">{part.slice(2, -2)}</strong>; } if (part.startsWith('*') && part.endsWith('*')) { return <em key={i} className="italic text-slate-300">{part.slice(1, -1)}</em>; } return part; }); }; return ( <div className="markdown-body space-y-4 text-sm leading-relaxed"> {blocks.map((block, i) => { if (block.type === 'code') { return ( <div key={i} className="my-3 rounded-lg overflow-hidden border border-slate-700/50 bg-slate-950"> {block.lang && ( <div className="px-3 py-1.5 text-[10px] uppercase tracking-wider text-slate-500 bg-slate-900 border-b border-slate-800 font-mono"> {block.lang} </div> )} <div className="p-3 overflow-x-auto custom-scrollbar"> <pre className="font-mono text-emerald-300"> <code>{block.content.trim()}</code> </pre> </div> </div> ); } const lines = block.content.split('\n'); const elements = []; let currentList = null; lines.forEach((line, idx) => { const trimmed = line.trim(); if (trimmed.startsWith('#')) { const level = trimmed.match(/^#+/)[0].length; const text = trimmed.slice(level).trim(); const sizes = ['text-xl', 'text-lg', 'text-base', 'text-sm font-bold']; elements.push( <div key={`h-${idx}`} className={`${sizes[level-1] || 'text-base'} font-bold text-slate-100 mt-4 mb-2`}> {parseInline(text)} </div> ); return; } if (trimmed.match(/^[-*]\s/)) { if (!currentList) { currentList = []; elements.push(<ul key={`ul-${idx}`} className="list-disc list-inside space-y-1 ml-2">{currentList}</ul>); } currentList.push(<li key={`li-${idx}`}>{parseInline(trimmed.slice(2))}</li>); return; } else { currentList = null; } if (trimmed.startsWith('|') && trimmed.endsWith('|')) { if (trimmed.includes('---')) return; const cols = trimmed.split('|').slice(1, -1); elements.push( <div key={`row-${idx}`} className="grid grid-flow-col auto-cols-auto gap-4 py-1 border-b border-slate-800 text-slate-400"> {cols.map((c, ci) => <div key={ci}>{parseInline(c.trim())}</div>)} </div> ) return; } if (trimmed) { elements.push(<p key={`p-${idx}`} className="mb-2 last:mb-0">{parseInline(line)}</p>); } }); return <div key={i}>{elements}</div>; })} </div> ); }; /** * COMPONENT: Node Visualization (The "Git Graph") */ const TreeVisualizer = ({ nodes, activeNodeId, onNodeClick }) => { const [hoveredNode, setHoveredNode] = useState(null); const layout = useMemo(() => { const nodeMap = nodes; const rootId = Object.keys(nodeMap).find(id => nodeMap[id].parentId === null); if (!rootId) return { positions: [], edges: [], maxX: 0, maxY: 0 }; const positions = {}; const edges = []; let maxY = 0; const traverse = (nodeId, depth, lane) => { positions[nodeId] = { x: lane * 40 + 20, y: depth * 60 + 20 }; if (depth > maxY) maxY = depth; const node = nodeMap[nodeId]; if (!node.children || node.children.length === 0) return lane; let currentLane = lane; node.children.forEach((childId, index) => { if (index > 0) currentLane += 1; edges.push({ from: nodeId, to: childId }); if (nodeMap[childId]) { const maxLaneInBranch = traverse(childId, depth + 1, currentLane); if (index < node.children.length - 1) currentLane = maxLaneInBranch; } }); return currentLane; }; traverse(rootId, 0, 0); const maxX = Math.max(...Object.values(positions).map(p => p.x)); return { positions, edges, maxX, maxY }; }, [nodes]); const { positions, edges, maxX, maxY } = layout; return ( <div className="overflow-auto w-full h-full bg-slate-900 custom-scrollbar relative"> <svg width={Math.max(maxX + 60, 300)} height={Math.max(maxY * 60 + 100, 500)} className="block" > {edges.map((edge, i) => { const start = positions[edge.from]; const end = positions[edge.to]; if (!start || !end) return null; const path = `M ${start.x} ${start.y} C ${start.x} ${start.y + 30}, ${end.x} ${end.y - 30}, ${end.x} ${end.y}`; return <path key={`edge-${i}`} d={path} stroke="#475569" strokeWidth="2" fill="none" />; })} {Object.keys(positions).map((nodeId) => { const pos = positions[nodeId]; const node = nodes[nodeId]; const isActive = nodeId === activeNodeId; const isModel = node.role === 'model'; const isSystem = node.role === 'system'; return ( <g key={nodeId} onClick={() => onNodeClick(nodeId)} onMouseEnter={() => setHoveredNode({ ...node, x: pos.x, y: pos.y })} onMouseLeave={() => setHoveredNode(null)} className="cursor-pointer transition-opacity hover:opacity-80" > <circle cx={pos.x} cy={pos.y} r={isActive ? 8 : 6} fill={isActive ? '#3b82f6' : (isSystem ? '#a855f7' : (isModel ? '#10b981' : '#f43f5e'))} stroke={isActive ? '#fff' : '#1e293b'} strokeWidth="2" /> {isActive && ( <circle cx={pos.x} cy={pos.y} r={12} fill="none" stroke="#3b82f6" strokeWidth="1" opacity="0.5" /> )} </g> ); })} </svg> {/* Tooltip Overlay */} {hoveredNode && ( <div className="absolute z-[100] bg-slate-800 text-slate-200 text-xs p-3 rounded-lg shadow-2xl border border-slate-700 pointer-events-none w-64 md:w-80 whitespace-pre-wrap" style={{ top: hoveredNode.y + 10, left: hoveredNode.x + 10 }} > <div className="font-bold mb-2 uppercase text-[10px] text-slate-500 flex justify-between"> <span>{hoveredNode.role}</span> <span className="font-mono opacity-50">{hoveredNode.id.substr(0,4)}</span> </div> <div className="line-clamp-6 opacity-90 leading-relaxed text-slate-300">{hoveredNode.text}</div> </div> )} </div> ); }; /** * MAIN COMPONENT */ const App = () => { // --- STATE --- // Data const [conversations, setConversations] = useState([]); const [activeConvId, setActiveConvId] = useState(null); const [nodes, setNodes] = useState({}); const [activeNodeId, setActiveNodeId] = useState('root'); // UI State const [input, setInput] = useState(''); const [apiKey, setApiKey] = useState(''); const [showSettings, setShowSettings] = useState(false); const [isLoading, setIsLoading] = useState(false); const [editingNodeId, setEditingNodeId] = useState(null); const [editText, setEditText] = useState(''); // Renaming State const [renamingId, setRenamingId] = useState(null); const [renameText, setRenameText] = useState(''); // Layout State const [showChatsPanel, setShowChatsPanel] = useState(true); const [showTreePanel, setShowTreePanel] = useState(true); const messagesEndRef = useRef(null); const inputRef = useRef(null); // --- INITIALIZATION --- useEffect(() => { // 1. Load API Key const storedKey = localStorage.getItem('branchchat_api_key'); if (storedKey) setApiKey(storedKey); // 2. Load Conversations const storedConvs = JSON.parse(localStorage.getItem('branchchat_conversations') || '[]'); if (storedConvs.length > 0) { // Sort by last accessed or created const sorted = storedConvs.sort((a,b) => b.createdAt - a.createdAt); setConversations(sorted); setActiveConvId(sorted[0].id); } else { createNewConversation(); } }, []); // --- PERSISTENCE HELPERS --- const saveNodes = (convId, nodesObj) => { localStorage.setItem(`branchchat_nodes_${convId}`, JSON.stringify(nodesObj)); setNodes(nodesObj); }; const saveConversations = (updatedConvs) => { localStorage.setItem('branchchat_conversations', JSON.stringify(updatedConvs)); setConversations(updatedConvs); }; const updateConversationMeta = (convId, updates) => { const updated = conversations.map(c => c.id === convId ? { ...c, ...updates } : c); saveConversations(updated); }; // --- EFFECTS --- // Load Nodes when active conversation changes useEffect(() => { if (!activeConvId) return; const storedNodes = JSON.parse(localStorage.getItem(`branchchat_nodes_${activeConvId}`) || 'null'); if (storedNodes) { setNodes(storedNodes); const currentConv = conversations.find(c => c.id === activeConvId); // Logic fix: explicitly default to root if the saved ID is missing, but ensure 'root' actually exists in data let targetNodeId = currentConv?.lastActiveNodeId || 'root'; if (!storedNodes[targetNodeId]) { targetNodeId = 'root'; } setActiveNodeId(targetNodeId); } else { // Fallback for corruption or sync error: recreate root const rootNode = { id: 'root', role: 'system', text: 'You are a helpful AI assistant. Edit this message to change system behavior.', parentId: null, children: [], timestamp: Date.now() }; const initialNodes = { 'root': rootNode }; saveNodes(activeConvId, initialNodes); setActiveNodeId('root'); } // Mobile: Auto close panel on selection if (window.innerWidth < 768) setShowChatsPanel(false); }, [activeConvId]); // Persist Active Node ID useEffect(() => { if (activeConvId && activeNodeId && conversations.length > 0) { // Only write if changed to avoid loop const current = conversations.find(c => c.id === activeConvId); if (current && current.lastActiveNodeId !== activeNodeId) { updateConversationMeta(activeConvId, { lastActiveNodeId: activeNodeId }); } } }, [activeNodeId]); // --- RESPONSIVE INIT --- useEffect(() => { const handleResize = () => { if (window.innerWidth < 768) { setShowChatsPanel(false); setShowTreePanel(false); } else { setShowChatsPanel(true); setShowTreePanel(true); } }; handleResize(); window.addEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize); }, []); // --- ACTIONS: CONVERSATIONS --- const createNewConversation = () => { const newId = generateId(); const newConv = { id: newId, title: 'New Conversation', createdAt: Date.now(), lastActiveNodeId: 'root' }; const rootNode = { id: 'root', role: 'system', text: 'You are a helpful AI assistant. Edit this message to change system behavior.', parentId: null, children: [], timestamp: Date.now() }; // Save new data localStorage.setItem(`branchchat_nodes_${newId}`, JSON.stringify({ 'root': rootNode })); const updatedConvs = [newConv, ...conversations]; saveConversations(updatedConvs); // Switch context setActiveConvId(newId); setNodes({ 'root': rootNode }); setActiveNodeId('root'); // Explicitly set to root for new chat if (window.innerWidth < 768) setShowChatsPanel(false); setTimeout(() => inputRef.current?.focus(), 100); }; const deleteConversation = (convId, e) => { e.stopPropagation(); if (confirm('Delete entire conversation tree?')) { const updated = conversations.filter(c => c.id !== convId); saveConversations(updated); localStorage.removeItem(`branchchat_nodes_${convId}`); if (activeConvId === convId) { if (updated.length > 0) setActiveConvId(updated[0].id); else createNewConversation(); } } }; const startRenaming = (convId, currentTitle, e) => { e.stopPropagation(); setRenamingId(convId); setRenameText(currentTitle); }; const saveRename = () => { if (renamingId && renameText.trim()) { updateConversationMeta(renamingId, { title: renameText.trim() }); } setRenamingId(null); setRenameText(''); }; const saveApiKey = (val) => { setApiKey(val); localStorage.setItem('branchchat_api_key', val); }; // --- ACTIONS: NODES --- const deleteNode = (nodeId) => { if (!activeConvId || !nodes[nodeId]) return; const toDelete = new Set([nodeId]); const findDescendants = (id) => { const node = nodes[id]; if (node && node.children) { node.children.forEach(childId => { toDelete.add(childId); findDescendants(childId); }); } }; findDescendants(nodeId); const newNodes = { ...nodes }; // Update parent const node = nodes[nodeId]; if (node.parentId && newNodes[node.parentId]) { newNodes[node.parentId] = { ...newNodes[node.parentId], children: newNodes[node.parentId].children.filter(id => id !== nodeId) }; } // Delete targets toDelete.forEach(id => delete newNodes[id]); saveNodes(activeConvId, newNodes); if (toDelete.has(activeNodeId)) { setActiveNodeId(node.parentId || 'root'); } }; const startEditing = (node) => { setEditingNodeId(node.id); setEditText(node.text); }; const cancelEditing = () => { setEditingNodeId(null); setEditText(''); }; const saveEdit = (node, mode = 'update') => { if (!editText.trim()) return; const newNodes = { ...nodes }; if (mode === 'update') { newNodes[node.id] = { ...node, text: editText }; saveNodes(activeConvId, newNodes); setEditingNodeId(null); } else if (mode === 'branch') { if (!node.parentId) return; const newNodeId = generateId(); const newNode = { ...node, id: newNodeId, text: editText, children: [], timestamp: Date.now() }; newNodes[newNodeId] = newNode; newNodes[node.parentId] = { ...newNodes[node.parentId], children: [...newNodes[node.parentId].children, newNodeId] }; saveNodes(activeConvId, newNodes); setEditingNodeId(null); setActiveNodeId(newNodeId); inputRef.current?.focus(); if (newNode.role === 'user') { triggerAIResponse(newNodeId, newNodes); // Pass updated nodes } } }; const handleBranchFromNode = (nodeId) => { setActiveNodeId(nodeId); messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); setTimeout(() => inputRef.current?.focus(), 50); }; const triggerAIResponse = async (userNodeId, currentNodesSnapshot) => { if (!apiKey) { setShowSettings(true); return; } setIsLoading(true); const path = []; let currNode = currentNodesSnapshot[userNodeId]; // Safety if (!currNode) { setIsLoading(false); return; } while(currNode) { path.unshift(currNode); if (!currNode.parentId) break; currNode = currentNodesSnapshot[currNode.parentId]; } // System Prompt is the root node const rootNode = path.find(n => n.id === 'root'); const systemInstruction = rootNode ? { parts: [{ text: rootNode.text }] } : undefined; const historyForApi = path .filter(n => n.id !== 'root') .map(n => ({ role: n.role === 'user' ? 'user' : 'model', parts: [{ text: n.text }] })); try { const response = await fetch( `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-preview-09-2025:generateContent?key=${apiKey}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ contents: historyForApi, systemInstruction: systemInstruction }) } ); const data = await response.json(); if (data.error) throw new Error(data.error.message); const modelText = data.candidates?.[0]?.content?.parts?.[0]?.text || "No response."; const modelNodeId = generateId(); const newModelNode = { id: modelNodeId, role: 'model', text: modelText, parentId: userNodeId, children: [], timestamp: Date.now() }; // We need to read fresh from state/ref if user was fast, // but here we are in async. We should take currentNodesSnapshot and append // HOWEVER, if user deleted something while waiting, we have conflict. // For local storage simple app, re-reading from 'nodes' state might be stale in closure? // Actually 'nodes' is a closure const here. // Best practice for async state update: use functional setState or ref. // We will perform a fresh read from localStorage to be safe. const freshNodes = JSON.parse(localStorage.getItem(`branchchat_nodes_${activeConvId}`)); freshNodes[modelNodeId] = newModelNode; freshNodes[userNodeId] = { ...freshNodes[userNodeId], children: [...freshNodes[userNodeId].children, modelNodeId] }; saveNodes(activeConvId, freshNodes); setActiveNodeId(modelNodeId); } catch (error) { console.error(error); const errorNodeId = generateId(); const errorNode = { id: errorNodeId, role: 'model', text: `Error: ${error.message}`, parentId: userNodeId, children: [], timestamp: Date.now(), isError: true }; const freshNodes = JSON.parse(localStorage.getItem(`branchchat_nodes_${activeConvId}`)); freshNodes[errorNodeId] = errorNode; freshNodes[userNodeId] = { ...freshNodes[userNodeId], children: [...freshNodes[userNodeId].children, errorNodeId] }; saveNodes(activeConvId, freshNodes); setActiveNodeId(errorNodeId); } finally { setIsLoading(false); } }; const handleSendMessage = async () => { if (!input.trim() || !activeConvId) return; if (!apiKey) { setShowSettings(true); return; } const userText = input; setInput(''); setIsLoading(true); const userNodeId = generateId(); const newUserNode = { id: userNodeId, role: 'user', text: userText, parentId: activeNodeId, children: [], timestamp: Date.now() }; const newNodes = { ...nodes }; newNodes[userNodeId] = newUserNode; if (newNodes[activeNodeId]) { newNodes[activeNodeId] = { ...newNodes[activeNodeId], children: [...newNodes[activeNodeId].children, userNodeId] }; } saveNodes(activeConvId, newNodes); setActiveNodeId(userNodeId); triggerAIResponse(userNodeId, newNodes); }; // --- DERIVED STATE --- const conversationPath = useMemo(() => { const path = []; let currentId = activeNodeId; if (Object.keys(nodes).length === 0) return []; // Loop guard let depth = 0; while (currentId && depth < 1000) { const node = nodes[currentId]; if (node) { path.unshift(node); currentId = node.parentId; } else { break; } depth++; } return path; }, [nodes, activeNodeId]); useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [conversationPath, isLoading, editingNodeId]); // Helper for rendering action buttons const ActionButtons = ({ node }) => ( <div className={`flex items-center gap-1 bg-slate-800 border border-slate-700 rounded-md shadow-lg p-0.5 z-[20] transition-opacity opacity-0 group-hover:opacity-100`}> {node.id !== 'root' && ( <button onClick={() => deleteNode(node.id)} className="p-1.5 hover:bg-red-900/30 text-slate-400 hover:text-red-400 rounded" title="Delete Branch"> <Trash2 size={12} /> </button> )} {node.role === 'model' && ( <button onClick={() => handleBranchFromNode(node.id)} className="p-1.5 hover:bg-slate-700 text-slate-400 hover:text-blue-400 rounded" title="Branch/Reply from here"> <CornerDownRight size={12} /> </button> )} {(node.role === 'user' || node.role === 'system') && ( <button onClick={() => startEditing(node)} className="p-1.5 hover:bg-slate-700 text-slate-400 hover:text-amber-400 rounded" title="Edit"> <Edit2 size={12} /> </button> )} </div> ); return ( <div className="flex h-screen bg-slate-950 text-slate-200 font-sans overflow-hidden relative"> {/* 1. FAR LEFT: CONVERSATION LIST */} {(showChatsPanel) && ( <div className="md:hidden absolute inset-0 bg-black/50 z-30" onClick={() => setShowChatsPanel(false)}></div> )} <div className={`${showChatsPanel ? 'translate-x-0' : '-translate-x-full md:-ml-64'} transition-all duration-300 ease-in-out w-64 border-r border-slate-800 flex flex-col bg-slate-950 flex-shrink-0 absolute md:static z-40 h-full`} > <div className="p-4 border-b border-slate-800 font-bold text-slate-100 flex justify-between items-center"> <span>Chats</span> <div className="flex items-center gap-2"> <button onClick={createNewConversation} className="p-1 hover:bg-slate-800 rounded text-blue-400" title="New Chat"> <Plus size={20} /> </button> <button onClick={() => setShowChatsPanel(false)} className="md:hidden p-1 text-slate-500"> <X size={20} /> </button> </div> </div> <div className="flex-1 overflow-y-auto p-2 space-y-1"> {conversations.map(conv => ( <div key={conv.id} onClick={() => setActiveConvId(conv.id)} className={`group flex items-center justify-between px-3 py-2 rounded-lg cursor-pointer text-sm transition-colors ${ activeConvId === conv.id ? 'bg-slate-800 text-white' : 'text-slate-400 hover:bg-slate-900 hover:text-slate-200' }`} > <div className="flex items-center gap-2 truncate flex-1"> <MessageSquare size={14} className="flex-shrink-0" /> {renamingId === conv.id ? ( <input type="text" value={renameText} onChange={(e) => setRenameText(e.target.value)} onBlur={saveRename} onKeyDown={(e) => e.key === 'Enter' && saveRename()} autoFocus onClick={(e) => e.stopPropagation()} className="bg-slate-700 text-white text-xs px-1 py-0.5 rounded w-full border-none focus:outline-none" /> ) : ( <span className="truncate">{conv.title || 'Untitled Chat'}</span> )} </div> <div className="flex items-center opacity-0 group-hover:opacity-100 transition-opacity"> <button onClick={(e) => startRenaming(conv.id, conv.title, e)} className="p-1 hover:text-blue-400 text-slate-600" title="Rename" > <Pencil size={12} /> </button> <button onClick={(e) => deleteConversation(conv.id, e)} className="p-1 hover:text-red-400 text-slate-600" title="Delete" > <Trash2 size={12} /> </button> </div> </div> ))} </div> </div> {/* 2. MIDDLE LEFT: TREE VIEW */} {(showTreePanel) && ( <div className="md:hidden absolute inset-0 bg-black/50 z-30" onClick={() => setShowTreePanel(false)}></div> )} <div className={`${showTreePanel ? 'translate-x-0' : '-translate-x-full md:translate-x-0 md:-ml-[20%]'} transition-all duration-300 ease-in-out w-64 md:w-1/5 min-w-[200px] border-r border-slate-800 flex flex-col bg-slate-900 flex-shrink-0 absolute md:static z-40 h-full`} > <div className="p-4 border-b border-slate-800 flex justify-between items-center shadow-sm z-10 bg-slate-900"> <h2 className="text-xs font-bold text-slate-400 uppercase tracking-wider flex items-center gap-2"> <GitBranch size={14} /> Tree </h2> <div className="flex items-center gap-1"> <button onClick={() => setActiveNodeId('root')} className="p-1 hover:bg-slate-800 rounded text-slate-500 hover:text-white" title="Reset to Root" > <ZoomOut size={16} /> </button> <button onClick={() => setShowTreePanel(false)} className="md:hidden p-1 text-slate-500"> <X size={20} /> </button> </div> </div> <div className="flex-1 relative"> <TreeVisualizer nodes={nodes} activeNodeId={activeNodeId} onNodeClick={setActiveNodeId} /> </div> </div> {/* 3. RIGHT MAIN: CHAT AREA */} <div className="flex-1 flex flex-col h-full relative min-w-0 bg-slate-950"> {/* Header */} <div className="h-14 border-b border-slate-800 flex items-center justify-between px-4 md:px-6 bg-slate-950 flex-shrink-0 z-20"> <div className="flex items-center gap-3"> {/* Toggle Buttons */} <div className="flex items-center gap-1 mr-2 text-slate-500"> <button onClick={() => setShowChatsPanel(!showChatsPanel)} className="hover:text-blue-400 p-1" title="Toggle Chats" > {showChatsPanel ? <PanelLeftClose size={20} /> : <PanelLeftOpen size={20} />} </button> <button onClick={() => setShowTreePanel(!showTreePanel)} className="hover:text-blue-400 p-1" title="Toggle Tree" > {showTreePanel ? <GitBranch size={20} /> : <GitBranch size={20} className="opacity-50" />} </button> </div> <div className="bg-blue-600/20 p-2 rounded-lg hidden md:block"> <GitCommit className="text-blue-500" size={20} /> </div> <div> <h1 className="font-semibold text-white hidden md:block">BranchGPT</h1> <div className="text-xs text-slate-500 truncate max-w-[150px] md:max-w-none"> {activeConvId ? (conversations.find(c => c.id === activeConvId)?.title || 'Active Session') : 'Select a Chat'} </div> </div> </div> <button onClick={() => setShowSettings(!showSettings)} className={`p-2 rounded-lg transition-colors ${showSettings ? 'bg-slate-800 text-blue-400' : 'hover:bg-slate-900 text-slate-400'}`} > <Settings size={20} /> </button> </div> {/* Settings Modal */} {showSettings && ( <div className="absolute top-16 right-4 md:right-6 w-80 md:w-96 bg-slate-900 border border-slate-700 rounded-xl shadow-2xl z-50 animate-in fade-in slide-in-from-top-4 flex flex-col overflow-hidden"> <div className="p-4 border-b border-slate-800 bg-slate-800/50"> <h3 className="font-semibold text-white flex items-center gap-2"> <Key size={16} /> Global Settings </h3> </div> <div className="p-4"> <p className="text-xs text-slate-400 mb-3"> Paste your Google AI Studio API Key. It is stored in local storage and shared across all conversations. </p> <input type="password" value={apiKey} onChange={(e) => saveApiKey(e.target.value)} placeholder="Paste AI Studio Key..." className="w-full bg-slate-950 border border-slate-800 rounded px-3 py-2 text-sm text-white mb-3 focus:border-blue-500 focus:outline-none" /> <div className="flex justify-end mt-2"> <button onClick={() => setShowSettings(false)} className="text-xs bg-blue-600 hover:bg-blue-700 text-white px-3 py-1.5 rounded">Done</button> </div> </div> </div> )} {/* Messages */} <div className="flex-1 overflow-y-auto p-4 md:p-6 space-y-8 scroll-smooth"> {conversationPath.map((node, index) => { const parent = node.parentId ? nodes[node.parentId] : null; const hasSiblings = parent && parent.children.length > 1; const isBranchPoint = hasSiblings && parent.children[0] !== node.id; const isEditing = editingNodeId === node.id; const isLongMessage = node.text.length > 300; const isSystem = node.role === 'system'; return ( <div key={node.id} className={`group flex flex-col ${node.role === 'user' ? 'items-end' : 'items-start'} relative`}> {isBranchPoint && ( <div className="text-xs text-slate-500 mb-1 flex items-center gap-1 opacity-75"> <CornerDownRight size={12} /> Branched here </div> )} {/* Top Action Bar */} {!isEditing && ( <div className={`absolute -top-3 ${node.role === 'user' ? 'right-0' : 'left-0'} z-20`}> <ActionButtons node={node} /> </div> )} <div className={`max-w-[95%] lg:max-w-[85%] rounded-2xl px-5 py-3.5 text-sm shadow-sm relative z-10 ${ node.role === 'user' ? 'bg-blue-600 text-white rounded-br-none' : isSystem ? 'bg-slate-900 border-2 border-dashed border-slate-700 text-slate-400 w-full' : node.isError ? 'bg-red-900/50 border border-red-800 text-red-200' : 'bg-slate-800 text-slate-200 rounded-bl-none border border-slate-700' }`} > {/* EDIT MODE */} {isEditing ? ( <div className="min-w-[300px] w-full"> <div className="text-xs text-amber-500 mb-2 font-bold uppercase tracking-wider"> {isSystem ? 'Editing System Prompt' : 'Editing Message'} </div> <textarea value={editText} onChange={(e) => setEditText(e.target.value)} className="w-full bg-slate-950 text-white p-2 rounded border border-white/20 text-sm mb-2 focus:outline-none font-mono leading-relaxed" rows={isSystem ? 2 : 4} autoFocus /> <div className="flex items-center gap-2 justify-end"> <button onClick={cancelEditing} className="text-xs px-2 py-1 hover:bg-white/10 rounded">Cancel</button> <button onClick={() => saveEdit(node, 'update')} className="text-xs px-2 py-1 bg-white/10 hover:bg-white/20 rounded flex items-center gap-1"> <Check size={12} /> Update </button> {!isSystem && ( <button onClick={() => saveEdit(node, 'branch')} className="text-xs px-2 py-1 bg-amber-600 hover:bg-amber-500 text-white rounded flex items-center gap-1"> <GitBranch size={12} /> Branch & Retry </button> )} </div> </div> ) : ( <> <div className="flex items-center justify-between gap-4 mb-3 opacity-50 text-[10px] font-mono uppercase tracking-widest border-b border-white/10 pb-1"> <span className={isSystem ? "text-purple-400 font-bold" : ""}> {isSystem ? "SYSTEM INSTRUCTION" : node.role} </span> <span>{new Date(node.timestamp).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}</span> </div> <MarkdownRenderer content={node.text} /> </> )} </div> {/* Bottom Action Bar (Only for long messages) */} {!isEditing && isLongMessage && ( <div className={`absolute -bottom-4 ${node.role === 'user' ? 'right-0' : 'left-0'} z-20`}> <ActionButtons node={node} /> </div> )} {index === conversationPath.length - 1 && node.children.length > 0 && ( <div className="mt-2 text-xs text-slate-500 flex items-center gap-2"> <GitBranch size={12} /> <span>Has replies. Check tree.</span> </div> )} </div> ); })} {isLoading && ( <div className="flex items-start"> <div className="bg-slate-800 rounded-2xl rounded-bl-none px-4 py-3 border border-slate-700 flex items-center gap-2"> <div className="w-2 h-2 bg-slate-400 rounded-full animate-bounce"></div> <div className="w-2 h-2 bg-slate-400 rounded-full animate-bounce delay-75"></div> <div className="w-2 h-2 bg-slate-400 rounded-full animate-bounce delay-150"></div> </div> </div> )} <div ref={messagesEndRef} /> </div> {/* Input Area */} <div className="p-4 bg-slate-950 border-t border-slate-800 flex-shrink-0 z-30"> {nodes[activeNodeId]?.children?.length > 0 && ( <div className="mb-2 text-xs text-amber-500 flex items-center gap-2 px-2"> <GitBranch size={14} /> <span>Writing here creates a new branch.</span> </div> )} <div className="flex gap-2 max-w-4xl mx-auto"> <textarea ref={inputRef} value={input} onChange={(e) => setInput(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSendMessage(); } }} placeholder={nodes[activeNodeId]?.children?.length > 0 ? "Type to branch off..." : "Type a message..."} className="flex-1 bg-slate-900 text-slate-200 rounded-xl px-4 py-3 border border-slate-800 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500/50 resize-none h-[50px] max-h-[120px] scrollbar-hide" /> <button onClick={handleSendMessage} disabled={isLoading || !input.trim()} className="bg-blue-600 hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-xl px-4 flex items-center justify-center transition-all shadow-lg shadow-blue-900/20" > <Send size={20} /> </button> </div> </div> </div> </div> ); }; export default App;
No revisions found. Save the file to create a backup.
Delete
Update App