antigravity-chat-proxy 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +362 -0
  2. package/app/api/v1/artifacts/[convId]/[filename]/route.ts +75 -0
  3. package/app/api/v1/artifacts/[convId]/route.ts +47 -0
  4. package/app/api/v1/artifacts/active/[filename]/route.ts +50 -0
  5. package/app/api/v1/artifacts/active/route.ts +89 -0
  6. package/app/api/v1/artifacts/route.ts +43 -0
  7. package/app/api/v1/chat/action/route.ts +30 -0
  8. package/app/api/v1/chat/approve/route.ts +21 -0
  9. package/app/api/v1/chat/history/route.ts +23 -0
  10. package/app/api/v1/chat/mode/route.ts +59 -0
  11. package/app/api/v1/chat/new/route.ts +21 -0
  12. package/app/api/v1/chat/reject/route.ts +21 -0
  13. package/app/api/v1/chat/route.ts +105 -0
  14. package/app/api/v1/chat/state/route.ts +23 -0
  15. package/app/api/v1/chat/stream/route.ts +258 -0
  16. package/app/api/v1/conversations/active/route.ts +117 -0
  17. package/app/api/v1/conversations/route.ts +189 -0
  18. package/app/api/v1/conversations/select/route.ts +114 -0
  19. package/app/api/v1/debug/dom/route.ts +30 -0
  20. package/app/api/v1/debug/scrape/route.ts +56 -0
  21. package/app/api/v1/health/route.ts +13 -0
  22. package/app/api/v1/windows/cdp-start/route.ts +32 -0
  23. package/app/api/v1/windows/cdp-status/route.ts +32 -0
  24. package/app/api/v1/windows/close/route.ts +67 -0
  25. package/app/api/v1/windows/open/route.ts +49 -0
  26. package/app/api/v1/windows/recent/route.ts +25 -0
  27. package/app/api/v1/windows/route.ts +27 -0
  28. package/app/api/v1/windows/select/route.ts +35 -0
  29. package/app/debug/page.tsx +228 -0
  30. package/app/favicon.ico +0 -0
  31. package/app/globals.css +1234 -0
  32. package/app/layout.tsx +42 -0
  33. package/app/page.tsx +10 -0
  34. package/bin/cli.js +601 -0
  35. package/components/agent-message.tsx +63 -0
  36. package/components/artifact-panel.tsx +133 -0
  37. package/components/chat-container.tsx +82 -0
  38. package/components/chat-input.tsx +92 -0
  39. package/components/conversation-selector.tsx +97 -0
  40. package/components/header.tsx +302 -0
  41. package/components/hitl-dialog.tsx +23 -0
  42. package/components/message-list.tsx +41 -0
  43. package/components/thinking-block.tsx +14 -0
  44. package/components/tool-call-card.tsx +75 -0
  45. package/components/typing-indicator.tsx +11 -0
  46. package/components/user-message.tsx +13 -0
  47. package/components/welcome-screen.tsx +38 -0
  48. package/hooks/use-artifacts.ts +85 -0
  49. package/hooks/use-chat.ts +278 -0
  50. package/hooks/use-conversations.ts +190 -0
  51. package/lib/actions/hitl.ts +113 -0
  52. package/lib/actions/new-chat.ts +116 -0
  53. package/lib/actions/send-message.ts +31 -0
  54. package/lib/actions/switch-conversation.ts +92 -0
  55. package/lib/cdp/connection.ts +95 -0
  56. package/lib/cdp/process-manager.ts +327 -0
  57. package/lib/cdp/recent-projects.ts +137 -0
  58. package/lib/cdp/selectors.ts +11 -0
  59. package/lib/context.ts +38 -0
  60. package/lib/init.ts +48 -0
  61. package/lib/logger.ts +32 -0
  62. package/lib/scraper/agent-mode.ts +122 -0
  63. package/lib/scraper/agent-state.ts +756 -0
  64. package/lib/scraper/chat-history.ts +138 -0
  65. package/lib/scraper/ide-conversations.ts +124 -0
  66. package/lib/sse/diff-states.ts +141 -0
  67. package/lib/types.ts +146 -0
  68. package/lib/utils.ts +7 -0
  69. package/next.config.ts +7 -0
  70. package/package.json +50 -0
  71. package/public/file.svg +1 -0
  72. package/public/globe.svg +1 -0
  73. package/public/next.svg +1 -0
  74. package/public/vercel.svg +1 -0
  75. package/public/window.svg +1 -0
  76. package/tsconfig.json +34 -0
@@ -0,0 +1,63 @@
1
+ 'use client';
2
+
3
+ import type { SSEStep } from '@/lib/types';
4
+ import ToolCallCard from './tool-call-card';
5
+ import ThinkingBlock from './thinking-block';
6
+ import HITLDialog from './hitl-dialog';
7
+ import TypingIndicator from './typing-indicator';
8
+
9
+ interface AgentMessageProps {
10
+ content: string;
11
+ steps: SSEStep[];
12
+ isStreaming: boolean;
13
+ onApprove: () => void;
14
+ onReject: () => void;
15
+ onRetry?: () => void;
16
+ }
17
+
18
+ export default function AgentMessage({ content, steps, isStreaming, onApprove, onReject, onRetry }: AgentMessageProps) {
19
+ return (
20
+ <div className={`agent-message ${isStreaming ? 'streaming' : ''}`}>
21
+ <div className="agent-steps">
22
+ {steps.map((step, i) => {
23
+ switch (step.type) {
24
+ case 'thinking':
25
+ return <ThinkingBlock key={`step-${i}`} time={step.data.time} />;
26
+ case 'tool_call':
27
+ return <ToolCallCard key={`step-${i}`} data={step.data} />;
28
+ case 'hitl':
29
+ return <HITLDialog key={`step-${i}`} onApprove={onApprove} onReject={onReject} />;
30
+ case 'file_change':
31
+ return (
32
+ <div key={`step-${i}`} className="file-change-indicator">
33
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
34
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
35
+ <polyline points="14 2 14 8 20 8" />
36
+ </svg>
37
+ <span className="file-change-name">{step.data.fileName}</span>
38
+ </div>
39
+ );
40
+ case 'error':
41
+ return (
42
+ <div key={`step-${i}`} className="error-banner">
43
+ <div style={{ marginBottom: !isStreaming && onRetry ? '8px' : '0' }}>⚠️ {String(step.data.message)}</div>
44
+ {!isStreaming && onRetry && (
45
+ <button className="px-3 py-1 bg-red-100 text-red-800 rounded hover:bg-red-200 transition-colors text-sm font-medium" onClick={onRetry}>Try Again</button>
46
+ )}
47
+ </div>
48
+ );
49
+ case 'notification':
50
+ return null;
51
+ default:
52
+ return null;
53
+ }
54
+ })}
55
+ {isStreaming && <TypingIndicator />}
56
+ </div>
57
+
58
+ {content && (
59
+ <div className="agent-response" dangerouslySetInnerHTML={{ __html: content }} />
60
+ )}
61
+ </div>
62
+ );
63
+ }
@@ -0,0 +1,133 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import type { ConversationInfo, ArtifactFile } from '@/lib/types';
5
+
6
+ interface ArtifactPanelProps {
7
+ open: boolean;
8
+ onClose: () => void;
9
+ activeConversation: ConversationInfo | null;
10
+ files: ArtifactFile[];
11
+ }
12
+
13
+ export default function ArtifactPanel({ open, onClose, activeConversation, files }: ArtifactPanelProps) {
14
+ const [viewingFile, setViewingFile] = useState<string | null>(null);
15
+ const [fileContent, setFileContent] = useState('');
16
+ const [loading, setLoading] = useState(false);
17
+
18
+ const formatSize = (bytes: number) => {
19
+ if (bytes < 1024) return `${bytes}B`;
20
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
21
+ return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
22
+ };
23
+
24
+ const formatTime = (mtime: string) => {
25
+ const d = new Date(mtime);
26
+ return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + ' · ' + d.toLocaleDateString();
27
+ };
28
+
29
+ const openFile = async (fileName: string) => {
30
+ if (!activeConversation) return;
31
+ setLoading(true);
32
+ setViewingFile(fileName);
33
+ try {
34
+ const res = await fetch(`/api/v1/artifacts/active/${encodeURIComponent(fileName)}`);
35
+ const text = await res.text();
36
+ setFileContent(text);
37
+ } catch (e: any) {
38
+ setFileContent(`Error loading file: ${e.message}`);
39
+ } finally {
40
+ setLoading(false);
41
+ }
42
+ };
43
+
44
+ const fileIcon = (name: string) => {
45
+ if (name.endsWith('.md')) return '📄';
46
+ if (name.endsWith('.json')) return '📋';
47
+ if (name.endsWith('.ts') || name.endsWith('.tsx')) return '📘';
48
+ if (name.endsWith('.css')) return '🎨';
49
+ return '📁';
50
+ };
51
+
52
+ return (
53
+ <div className={`artifact-panel ${open ? 'open' : ''}`}>
54
+ {/* Header */}
55
+ <div className="artifact-panel-header">
56
+ <button className="icon-btn" onClick={onClose} title="Close panel">
57
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
58
+ <line x1="18" y1="6" x2="6" y2="18" />
59
+ <line x1="6" y1="6" x2="18" y2="18" />
60
+ </svg>
61
+ </button>
62
+ <h3>Artifacts</h3>
63
+ </div>
64
+
65
+ {/* Active conversation banner */}
66
+ {activeConversation ? (
67
+ <div style={{ padding: '12px 16px', borderBottom: '1px solid var(--border-subtle)', background: 'rgba(99,102,241,0.04)' }}>
68
+ <div style={{ fontSize: '13px', fontWeight: 600, color: 'var(--text-primary)', marginBottom: '2px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
69
+ {activeConversation.title || 'Untitled'}
70
+ </div>
71
+ <div style={{ fontSize: '10px', fontFamily: "'JetBrains Mono', monospace", color: 'var(--text-muted)' }}>
72
+ {files.length} file{files.length !== 1 ? 's' : ''}
73
+ </div>
74
+ </div>
75
+ ) : (
76
+ <div style={{ padding: '24px 16px', textAlign: 'center', color: 'var(--text-muted)', fontSize: '13px' }}>
77
+ No active conversation
78
+ </div>
79
+ )}
80
+
81
+ {/* File viewer or file list */}
82
+ {viewingFile ? (
83
+ <div className="artifact-viewer">
84
+ <div className="artifact-viewer-header">
85
+ <button className="artifact-back-btn" onClick={() => { setViewingFile(null); setFileContent(''); }}>
86
+ ← Back
87
+ </button>
88
+ <span className="artifact-viewer-title">{viewingFile}</span>
89
+ </div>
90
+ <div className="artifact-viewer-body">
91
+ {loading ? (
92
+ <div style={{ color: 'var(--text-muted)', padding: '16px' }}>Loading...</div>
93
+ ) : viewingFile.endsWith('.md') ? (
94
+ <div className="agent-response" dangerouslySetInnerHTML={{
95
+ __html: fileContent
96
+ .replace(/^### (.*$)/gim, '<h3>$1</h3>')
97
+ .replace(/^## (.*$)/gim, '<h2>$1</h2>')
98
+ .replace(/^# (.*$)/gim, '<h1>$1</h1>')
99
+ .replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
100
+ .replace(/`([^`]+)`/g, '<code>$1</code>')
101
+ .replace(/^- (.*$)/gim, '<li>$1</li>')
102
+ .replace(/\n\n/g, '</p><p>')
103
+ .replace(/\n/g, '<br/>')
104
+ }} />
105
+ ) : (
106
+ <pre style={{ margin: 0, fontSize: '12px', lineHeight: 1.6, color: 'var(--text-secondary)', fontFamily: "'JetBrains Mono', monospace", whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
107
+ {fileContent}
108
+ </pre>
109
+ )}
110
+ </div>
111
+ </div>
112
+ ) : (
113
+ <div className="artifact-file-list">
114
+ {files.map(f => (
115
+ <button key={f.name} className="artifact-file-item" onClick={() => openFile(f.name)}>
116
+ <span className="artifact-file-icon">{fileIcon(f.name)}</span>
117
+ <div style={{ flex: 1, overflow: 'hidden' }}>
118
+ <div className="artifact-file-name">{f.name}</div>
119
+ <div style={{ fontSize: '10px', color: 'var(--text-muted)' }}>{formatTime(f.mtime)}</div>
120
+ </div>
121
+ <span className="artifact-file-size">{formatSize(f.size)}</span>
122
+ </button>
123
+ ))}
124
+ {files.length === 0 && activeConversation && (
125
+ <div style={{ textAlign: 'center', padding: '24px', color: 'var(--text-muted)', fontSize: '13px' }}>
126
+ No artifacts in this conversation
127
+ </div>
128
+ )}
129
+ </div>
130
+ )}
131
+ </div>
132
+ );
133
+ }
@@ -0,0 +1,82 @@
1
+ 'use client';
2
+
3
+ import { useChat } from '@/hooks/use-chat';
4
+ import Header from '@/components/header';
5
+ import WelcomeScreen from '@/components/welcome-screen';
6
+ import MessageList from '@/components/message-list';
7
+ import ChatInput from '@/components/chat-input';
8
+ import ArtifactPanel from '@/components/artifact-panel';
9
+ import { useEffect } from 'react';
10
+
11
+ export default function ChatContainer() {
12
+ const chat = useChat();
13
+
14
+ useEffect(() => {
15
+ const handleGlobalKeyDown = (e: KeyboardEvent) => {
16
+ if (e.key === 'n' && (e.ctrlKey || e.metaKey)) {
17
+ e.preventDefault();
18
+ chat.startNewChat();
19
+ }
20
+ };
21
+ window.addEventListener('keydown', handleGlobalKeyDown);
22
+ return () => window.removeEventListener('keydown', handleGlobalKeyDown);
23
+ }, [chat]);
24
+
25
+ return (
26
+ <div className="app-container">
27
+ <Header
28
+ statusState={chat.statusState}
29
+ statusText={chat.statusText}
30
+ windows={chat.windows}
31
+ conversations={chat.conversations}
32
+ activeConversation={chat.activeConversation}
33
+ cdpStatus={chat.cdpStatus}
34
+ recentProjects={chat.recentProjects}
35
+ onSelectWindow={chat.selectWindow}
36
+ onSelectConversation={chat.selectConversation}
37
+ onNewChat={chat.startNewChat}
38
+ onToggleArtifacts={chat.toggleArtifactPanel}
39
+ onStartCdp={chat.startCdpServer}
40
+ onOpenWindow={chat.openNewWindow}
41
+ onCloseWindow={chat.closeWindowByIndex}
42
+ />
43
+
44
+ <main className="messages-area" role="log" aria-live="polite">
45
+ {chat.showWelcome ? (
46
+ <WelcomeScreen onQuickPrompt={chat.sendMessage} />
47
+ ) : (
48
+ <MessageList
49
+ messages={chat.messages}
50
+ currentSteps={chat.currentSteps}
51
+ currentResponse={chat.currentResponse}
52
+ isStreaming={chat.isStreaming}
53
+ onApprove={chat.approve}
54
+ onReject={chat.reject}
55
+ onRetry={async () => {
56
+ try {
57
+ // To retry, we ping health and maybe trigger a status update
58
+ await fetch('/api/v1/health');
59
+ window.location.reload();
60
+ } catch { /* ignore */ }
61
+ }}
62
+ />
63
+ )}
64
+ <div ref={chat.messagesEndRef} />
65
+ </main>
66
+
67
+ <ChatInput
68
+ onSend={chat.sendMessage}
69
+ isStreaming={chat.isStreaming}
70
+ currentMode={chat.currentMode}
71
+ onToggleMode={chat.toggleMode}
72
+ />
73
+
74
+ <ArtifactPanel
75
+ open={chat.artifactPanelOpen}
76
+ onClose={chat.toggleArtifactPanel}
77
+ activeConversation={chat.activeConversation}
78
+ files={chat.artifactFiles}
79
+ />
80
+ </div>
81
+ );
82
+ }
@@ -0,0 +1,92 @@
1
+ 'use client';
2
+
3
+ import { useState, useRef, useEffect } from 'react';
4
+
5
+ interface ChatInputProps {
6
+ onSend: (text: string) => void;
7
+ isStreaming: boolean;
8
+ currentMode: 'planning' | 'fast';
9
+ onToggleMode: () => void;
10
+ }
11
+
12
+ export default function ChatInput({ onSend, isStreaming, currentMode, onToggleMode }: ChatInputProps) {
13
+ const [value, setValue] = useState('');
14
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
15
+
16
+ const handleInput = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
17
+ setValue(e.target.value);
18
+ const el = e.target;
19
+ el.style.height = 'auto';
20
+ el.style.height = Math.min(el.scrollHeight, 150) + 'px';
21
+ };
22
+
23
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
24
+ if (e.key === 'Enter' && (!e.shiftKey || e.ctrlKey || e.metaKey)) {
25
+ e.preventDefault();
26
+ if (!isStreaming && value.trim()) {
27
+ onSend(value);
28
+ setValue('');
29
+ if (textareaRef.current) {
30
+ textareaRef.current.style.height = 'auto';
31
+ }
32
+ }
33
+ }
34
+ };
35
+
36
+ const handleSend = () => {
37
+ if (!isStreaming && value.trim()) {
38
+ onSend(value);
39
+ setValue('');
40
+ if (textareaRef.current) {
41
+ textareaRef.current.style.height = 'auto';
42
+ }
43
+ }
44
+ };
45
+
46
+ useEffect(() => {
47
+ textareaRef.current?.focus();
48
+ }, []);
49
+
50
+ return (
51
+ <footer className="input-area">
52
+ <div className="input-wrapper">
53
+ <button
54
+ className={`mode-toggle ${currentMode}`}
55
+ onClick={onToggleMode}
56
+ title={`Mode: ${currentMode === 'planning' ? 'Planning' : 'Fast'} — Click to switch`}
57
+ aria-label={`Switch mode (currently ${currentMode})`}
58
+ type="button"
59
+ >
60
+ <span className="mode-icon">{currentMode === 'planning' ? '📋' : '⚡'}</span>
61
+ <span className="mode-label">{currentMode === 'planning' ? 'Plan' : 'Fast'}</span>
62
+ </button>
63
+ <textarea
64
+ ref={textareaRef}
65
+ value={value}
66
+ onChange={handleInput}
67
+ onKeyDown={handleKeyDown}
68
+ placeholder="Ask the Antigravity agent..."
69
+ rows={1}
70
+ aria-label="Chat message input"
71
+ enterKeyHint="send"
72
+ autoComplete="off"
73
+ />
74
+ <button
75
+ className="send-btn"
76
+ onClick={handleSend}
77
+ disabled={isStreaming || !value.trim()}
78
+ aria-label="Send message"
79
+ >
80
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
81
+ <line x1="22" y1="2" x2="11" y2="13" />
82
+ <polygon points="22 2 15 22 11 13 2 9 22 2" />
83
+ </svg>
84
+ </button>
85
+ </div>
86
+ <div className="input-hint">
87
+ <span>Enter to send · Shift+Enter for new line · Ctrl+N for new chat</span>
88
+ <span id="model-info"></span>
89
+ </div>
90
+ </footer>
91
+ );
92
+ }
@@ -0,0 +1,97 @@
1
+ 'use client';
2
+
3
+ import { useState, useRef, useEffect } from 'react';
4
+ import type { ConversationInfo } from '@/lib/types';
5
+
6
+ interface ConversationSelectorProps {
7
+ conversations: ConversationInfo[];
8
+ activeConversation: ConversationInfo | null;
9
+ onSelect: (title: string) => void;
10
+ }
11
+
12
+ function formatRelativeTime(dateStr?: string) {
13
+ if (!dateStr) return '';
14
+ const date = new Date(dateStr);
15
+ const diff = Date.now() - date.getTime();
16
+ const minutes = Math.floor(diff / 60000);
17
+ if (minutes < 1) return 'Just now';
18
+ if (minutes < 60) return `${minutes}m ago`;
19
+ const hours = Math.floor(minutes / 60);
20
+ if (hours < 24) return `${hours}h ago`;
21
+ const days = Math.floor(hours / 24);
22
+ return `${days}d ago`;
23
+ }
24
+
25
+ export default function ConversationSelector({ conversations, activeConversation, onSelect }: ConversationSelectorProps) {
26
+ const [open, setOpen] = useState(false);
27
+ const wrapperRef = useRef<HTMLDivElement>(null);
28
+
29
+ useEffect(() => {
30
+ const handler = (e: MouseEvent) => {
31
+ if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
32
+ setOpen(false);
33
+ }
34
+ };
35
+ document.addEventListener('mousedown', handler);
36
+ return () => document.removeEventListener('mousedown', handler);
37
+ }, []);
38
+
39
+ return (
40
+ <div ref={wrapperRef} className={`conv-selector-wrapper ${open ? 'open' : ''}`}>
41
+ <button className="conv-selector-btn" onClick={() => setOpen(!open)} title="Switch conversation">
42
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
43
+ <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
44
+ </svg>
45
+ <span>{activeConversation?.title?.substring(0, 22) || 'No conversation'}</span>
46
+ <svg className="chevron" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
47
+ <polyline points="6 9 12 15 18 9" />
48
+ </svg>
49
+ </button>
50
+
51
+ <div className={`conv-dropdown ${open ? 'open' : ''}`}>
52
+ <div className="conv-dropdown-header">Conversations</div>
53
+ <div className="conv-dropdown-list">
54
+ {conversations.map((c, i) => {
55
+ const displayTitle = c.title || c.id.substring(0, 20) + '…';
56
+ const displayId = c.id && c.id.length > 5 ? c.id.substring(0, 8) : '';
57
+ const topFiles = c.files?.slice(0, 3) || [];
58
+ const remainingCount = c.files ? c.files.length - 3 : 0;
59
+
60
+ return (
61
+ <button
62
+ key={`conv-${i}`}
63
+ className={`conv-item ${c.title === activeConversation?.title ? 'active' : ''}`}
64
+ onClick={() => {
65
+ if (c.title !== activeConversation?.title) {
66
+ onSelect(c.title);
67
+ }
68
+ setOpen(false);
69
+ }}
70
+ >
71
+ <div className="conv-item-header">
72
+ <span className={`conv-item-dot ${c.active ? 'active' : ''}`} />
73
+ <span className="conv-item-title">{displayTitle}</span>
74
+ {displayId && <span className="conv-item-id">{displayId}</span>}
75
+ </div>
76
+ {c.mtime && <span className="conv-item-time">{formatRelativeTime(c.mtime)}</span>}
77
+ {c.files && c.files.length > 0 && (
78
+ <div className="conv-item-files">
79
+ {topFiles.map((f, fi) => (
80
+ <span key={fi} className="conv-item-file-badge">{f.name}</span>
81
+ ))}
82
+ {remainingCount > 0 && (
83
+ <span className="conv-item-file-badge">+{remainingCount}</span>
84
+ )}
85
+ </div>
86
+ )}
87
+ </button>
88
+ );
89
+ })}
90
+ {conversations.length === 0 && (
91
+ <div className="conv-dropdown-empty">No conversations found</div>
92
+ )}
93
+ </div>
94
+ </div>
95
+ </div>
96
+ );
97
+ }