Yassine Mhirsi commited on
Commit
9b2d092
·
1 Parent(s): 5193b92
Files changed (1) hide show
  1. src/app/components/chat/ChatInput.tsx +234 -21
src/app/components/chat/ChatInput.tsx CHANGED
@@ -1,5 +1,5 @@
1
  import React, { useState, useRef, useEffect } from 'react';
2
- import { Plus, ArrowUp, Settings2, Mic, X, Check, Loader2 } from 'lucide-react';
3
  import { useMCPTools } from '../../hooks/useMCPTools.ts';
4
  import type { MCPTool } from '../../types/index.ts';
5
 
@@ -18,7 +18,13 @@ const ChatInput = ({ onSubmit, placeholder = 'Ask a follow-up...' }: ChatInputPr
18
  const [isRecording, setIsRecording] = useState(false);
19
  const [showToolsDropdown, setShowToolsDropdown] = useState(false);
20
  const [selectedTool, setSelectedTool] = useState<string | null>(null);
 
 
 
 
21
  const dropdownRef = useRef<HTMLDivElement>(null);
 
 
22
  const { tools, loading, error, refetch } = useMCPTools();
23
 
24
  const handleSubmit = (e: any) => {
@@ -90,6 +96,7 @@ const ChatInput = ({ onSubmit, placeholder = 'Ask a follow-up...' }: ChatInputPr
90
  const handleClickOutside = (event: MouseEvent) => {
91
  if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
92
  setShowToolsDropdown(false);
 
93
  }
94
  };
95
 
@@ -99,9 +106,109 @@ const ChatInput = ({ onSubmit, placeholder = 'Ask a follow-up...' }: ChatInputPr
99
  };
100
  }, []);
101
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102
  const toggleToolsDropdown = () => {
103
  const nextState = !showToolsDropdown;
104
  setShowToolsDropdown(nextState);
 
 
105
 
106
  if (nextState) {
107
  refetch();
@@ -162,49 +269,155 @@ const ChatInput = ({ onSubmit, placeholder = 'Ask a follow-up...' }: ChatInputPr
162
  <button
163
  type="button"
164
  onClick={toggleToolsDropdown}
165
- className={`h-8 w-8 p-0 rounded-lg transition-all duration-200 hover:scale-110 flex items-center justify-center ${selectedTool ? 'bg-teal-500/15 text-teal-600 dark:bg-teal-500/20 dark:text-teal-300 hover:bg-teal-500/25 dark:hover:bg-teal-500/30' : 'text-zinc-800 dark:text-white hover:text-zinc-900 dark:hover:text-white hover:bg-zinc-200 dark:hover:bg-zinc-700'}`}
166
  aria-expanded={showToolsDropdown}
167
  aria-haspopup="true"
168
  >
169
  <Settings2 className="h-5 w-5" />
 
 
 
170
  </button>
171
 
172
  {showToolsDropdown && (
173
- <div className="absolute top-[calc(100%+0.6rem)] left-0 w-fit min-w-[9rem] max-w-[20rem] rounded-2xl border border-zinc-200/70 dark:border-zinc-700/60 bg-white/95 dark:bg-zinc-900/90 backdrop-blur-xl shadow-[0_20px_45px_-20px_rgba(12,12,12,0.75)] z-50 overflow-hidden">
174
- <div className="px-3 py-3 max-h-64 overflow-y-auto scrollbar-hide">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
175
  {loading ? (
176
- <div className="flex items-center justify-center gap-3 rounded-xl bg-zinc-100/80 dark:bg-zinc-800/70 px-4 py-3 text-sm text-zinc-500 dark:text-zinc-300">
177
- <Loader2 className="h-4 w-4 animate-spin" />
178
- Loading tools
179
  </div>
180
  ) : error ? (
181
- <div className="rounded-xl border border-red-400/50 bg-red-50/80 px-4 py-3 text-sm text-red-600 dark:border-red-500/40 dark:bg-red-900/20 dark:text-red-300">
182
- Failed to load tools. Please try again.
 
 
 
 
 
 
 
 
183
  </div>
184
- ) : tools.length === 0 ? (
185
- <div className="rounded-xl bg-zinc-100/70 px-4 py-3 text-sm text-zinc-500 dark:bg-zinc-800/70 dark:text-zinc-300">
186
- No tools available yet.
 
 
 
 
 
 
 
 
187
  </div>
188
  ) : (
189
- <ul className="space-y-2">
190
- {tools.slice(0, 6).filter(isMCPTool).map((tool, index) => (
191
- <li key={tool.name || index}>
 
 
 
 
 
192
  <button
 
193
  type="button"
194
- className={`w-full rounded-xl border border-transparent bg-gradient-to-r from-zinc-100/70 via-white to-zinc-100/70 px-4 py-3 text-left text-sm font-semibold text-zinc-800 transition-all duration-200 hover:-translate-y-0.5 hover:border-teal-300/80 hover:bg-white/90 hover:shadow-lg dark:from-zinc-800/50 dark:via-zinc-900/60 dark:to-zinc-800/50 dark:text-zinc-50 dark:hover:border-teal-400/70 dark:hover:bg-zinc-700/70 flex items-center justify-between gap-3 whitespace-nowrap ${selectedTool === tool.name ? 'border-teal-300/70 dark:border-teal-400/70' : ''}`}
 
 
 
 
 
 
195
  onClick={() => {
196
  setSelectedTool((current) => (current === tool.name ? null : tool.name));
197
  setShowToolsDropdown(false);
 
198
  }}
 
199
  >
200
- <span className="truncate">{tool.name}</span>
201
- {selectedTool === tool.name && <Check className="h-4 w-4 text-teal-400 dark:text-teal-300" />}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
202
  </button>
203
- </li>
204
- ))}
205
- </ul>
206
  )}
207
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
208
  </div>
209
  )}
210
  </div>
 
1
  import React, { useState, useRef, useEffect } from 'react';
2
+ import { Plus, ArrowUp, Settings2, Mic, X, Check, Loader2, Search, Sparkles } from 'lucide-react';
3
  import { useMCPTools } from '../../hooks/useMCPTools.ts';
4
  import type { MCPTool } from '../../types/index.ts';
5
 
 
18
  const [isRecording, setIsRecording] = useState(false);
19
  const [showToolsDropdown, setShowToolsDropdown] = useState(false);
20
  const [selectedTool, setSelectedTool] = useState<string | null>(null);
21
+ const [searchQuery, setSearchQuery] = useState('');
22
+ const [focusedIndex, setFocusedIndex] = useState(-1);
23
+ const [dropdownPosition, setDropdownPosition] = useState<'below' | 'above'>('below');
24
+ const [dropdownMaxHeight, setDropdownMaxHeight] = useState(320);
25
  const dropdownRef = useRef<HTMLDivElement>(null);
26
+ const dropdownContentRef = useRef<HTMLDivElement>(null);
27
+ const searchInputRef = useRef<HTMLInputElement>(null);
28
  const { tools, loading, error, refetch } = useMCPTools();
29
 
30
  const handleSubmit = (e: any) => {
 
96
  const handleClickOutside = (event: MouseEvent) => {
97
  if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
98
  setShowToolsDropdown(false);
99
+ setFocusedIndex(-1);
100
  }
101
  };
102
 
 
106
  };
107
  }, []);
108
 
109
+ // Handle keyboard navigation
110
+ useEffect(() => {
111
+ const handleKeyDown = (event: KeyboardEvent) => {
112
+ if (!showToolsDropdown) return;
113
+
114
+ const filteredTools = tools.filter(isMCPTool).filter(tool =>
115
+ tool.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
116
+ (tool.description && tool.description.toLowerCase().includes(searchQuery.toLowerCase()))
117
+ );
118
+
119
+ switch (event.key) {
120
+ case 'ArrowDown':
121
+ event.preventDefault();
122
+ setFocusedIndex(prev => (prev + 1) % filteredTools.length);
123
+ break;
124
+ case 'ArrowUp':
125
+ event.preventDefault();
126
+ setFocusedIndex(prev => prev <= 0 ? filteredTools.length - 1 : prev - 1);
127
+ break;
128
+ case 'Enter':
129
+ event.preventDefault();
130
+ if (focusedIndex >= 0 && filteredTools[focusedIndex]) {
131
+ setSelectedTool(filteredTools[focusedIndex].name);
132
+ setShowToolsDropdown(false);
133
+ setFocusedIndex(-1);
134
+ }
135
+ break;
136
+ case 'Escape':
137
+ event.preventDefault();
138
+ setShowToolsDropdown(false);
139
+ setFocusedIndex(-1);
140
+ break;
141
+ }
142
+ };
143
+
144
+ document.addEventListener('keydown', handleKeyDown);
145
+ return () => {
146
+ document.removeEventListener('keydown', handleKeyDown);
147
+ };
148
+ }, [showToolsDropdown, focusedIndex, tools, searchQuery]);
149
+
150
+ // Calculate dropdown position and max height based on viewport
151
+ useEffect(() => {
152
+ if (showToolsDropdown && dropdownRef.current) {
153
+ const calculatePosition = () => {
154
+ const buttonElement = dropdownRef.current?.querySelector('button');
155
+ if (!buttonElement) return;
156
+
157
+ const buttonRect = buttonElement.getBoundingClientRect();
158
+ const viewportHeight = window.innerHeight;
159
+ const viewportWidth = window.innerWidth;
160
+ const spaceBelow = viewportHeight - buttonRect.bottom;
161
+ const spaceAbove = buttonRect.top;
162
+ const dropdownHeight = 400; // Approximate max height
163
+ const minSpace = 20; // Minimum space from viewport edge
164
+
165
+ // Determine if dropdown should be above or below
166
+ if (spaceBelow < dropdownHeight + minSpace && spaceAbove > spaceBelow) {
167
+ setDropdownPosition('above');
168
+ // Calculate max height based on available space above
169
+ const maxHeight = Math.min(320, spaceAbove - minSpace - 60); // 60px for padding/margins
170
+ setDropdownMaxHeight(Math.max(200, maxHeight));
171
+ } else {
172
+ setDropdownPosition('below');
173
+ // Calculate max height based on available space below
174
+ const maxHeight = Math.min(320, spaceBelow - minSpace - 60);
175
+ setDropdownMaxHeight(Math.max(200, maxHeight));
176
+ }
177
+
178
+ // Adjust horizontal position if dropdown would overflow
179
+ const dropdownWidth = 320; // w-80 = 320px
180
+ const dropdownElement = dropdownRef.current?.querySelector('[data-dropdown-content]') as HTMLElement;
181
+ if (dropdownElement) {
182
+ if (buttonRect.left + dropdownWidth > viewportWidth - minSpace) {
183
+ // Would overflow on the right, align to right edge
184
+ dropdownElement.style.right = '0';
185
+ dropdownElement.style.left = 'auto';
186
+ } else {
187
+ // Reset to left alignment
188
+ dropdownElement.style.right = 'auto';
189
+ dropdownElement.style.left = '0';
190
+ }
191
+ }
192
+ };
193
+
194
+ calculatePosition();
195
+
196
+ // Recalculate on window resize or scroll
197
+ window.addEventListener('resize', calculatePosition);
198
+ window.addEventListener('scroll', calculatePosition, true);
199
+
200
+ return () => {
201
+ window.removeEventListener('resize', calculatePosition);
202
+ window.removeEventListener('scroll', calculatePosition, true);
203
+ };
204
+ }
205
+ }, [showToolsDropdown]);
206
+
207
  const toggleToolsDropdown = () => {
208
  const nextState = !showToolsDropdown;
209
  setShowToolsDropdown(nextState);
210
+ setSearchQuery('');
211
+ setFocusedIndex(-1);
212
 
213
  if (nextState) {
214
  refetch();
 
269
  <button
270
  type="button"
271
  onClick={toggleToolsDropdown}
272
+ className={`h-8 w-8 p-0 rounded-lg transition-all duration-200 hover:scale-110 flex items-center justify-center relative ${selectedTool ? 'bg-teal-500/15 text-teal-600 dark:bg-teal-500/20 dark:text-teal-300 hover:bg-teal-500/25 dark:hover:bg-teal-500/30' : 'text-zinc-800 dark:text-white hover:text-zinc-900 dark:hover:text-white hover:bg-zinc-200 dark:hover:bg-zinc-700'}`}
273
  aria-expanded={showToolsDropdown}
274
  aria-haspopup="true"
275
  >
276
  <Settings2 className="h-5 w-5" />
277
+ {selectedTool && (
278
+ <div className="absolute -top-1 -right-1 h-2 w-2 bg-teal-500 rounded-full animate-pulse"></div>
279
+ )}
280
  </button>
281
 
282
  {showToolsDropdown && (
283
+ <div
284
+ ref={dropdownContentRef}
285
+ data-dropdown-content
286
+ className={`absolute left-0 w-80 rounded-2xl border border-zinc-200/70 dark:border-zinc-700/60 bg-white/95 dark:bg-zinc-900/95 backdrop-blur-xl shadow-[0_20px_45px_-20px_rgba(12,12,12,0.75)] z-50 overflow-hidden animate-fade-in flex flex-col ${
287
+ dropdownPosition === 'above'
288
+ ? 'bottom-[calc(100%+0.6rem)]'
289
+ : 'top-[calc(100%+0.6rem)]'
290
+ }`}
291
+ style={{ maxHeight: `${dropdownMaxHeight}px` }}
292
+ >
293
+ {/* Header */}
294
+ <div className="px-4 py-3 border-b border-zinc-200/50 dark:border-zinc-700/50 flex-shrink-0">
295
+ <div className="flex items-center gap-2 mb-3">
296
+ <Sparkles className="h-4 w-4 text-teal-500 dark:text-teal-400" />
297
+ <h3 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">Available Tools</h3>
298
+ <div className="ml-auto">
299
+ <span className="text-xs text-zinc-500 dark:text-zinc-400 bg-zinc-100 dark:bg-zinc-800 px-2 py-1 rounded-full">
300
+ {tools.filter(isMCPTool).length} tools
301
+ </span>
302
+ </div>
303
+ </div>
304
+
305
+ {/* Search input */}
306
+ <div className="relative">
307
+ <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-zinc-400 dark:text-zinc-500" />
308
+ <input
309
+ ref={searchInputRef}
310
+ type="text"
311
+ placeholder="Search tools..."
312
+ value={searchQuery}
313
+ onChange={(e) => {
314
+ setSearchQuery(e.target.value);
315
+ setFocusedIndex(-1);
316
+ }}
317
+ className="w-full pl-10 pr-4 py-2 text-sm bg-zinc-100 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg text-zinc-900 dark:text-zinc-100 placeholder-zinc-500 dark:placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-teal-500/50 focus:border-teal-500/50 transition-all duration-200"
318
+ />
319
+ </div>
320
+ </div>
321
+
322
+ {/* Tools list */}
323
+ <div
324
+ className="overflow-y-auto scrollbar-hide flex-1"
325
+ style={{ maxHeight: `${dropdownMaxHeight - 140}px` }}
326
+ >
327
  {loading ? (
328
+ <div className="flex items-center justify-center gap-3 px-4 py-8">
329
+ <Loader2 className="h-5 w-5 animate-spin text-teal-500 dark:text-teal-400" />
330
+ <span className="text-sm text-zinc-600 dark:text-zinc-300">Loading tools…</span>
331
  </div>
332
  ) : error ? (
333
+ <div className="px-4 py-6">
334
+ <div className="flex flex-col items-center gap-3 text-center">
335
+ <div className="h-12 w-12 rounded-full bg-red-100 dark:bg-red-900/20 flex items-center justify-center">
336
+ <X className="h-6 w-6 text-red-500 dark:text-red-400" />
337
+ </div>
338
+ <div>
339
+ <p className="text-sm font-medium text-red-600 dark:text-red-400">Failed to load tools</p>
340
+ <p className="text-xs text-red-500 dark:text-red-500 mt-1">Please try again later</p>
341
+ </div>
342
+ </div>
343
  </div>
344
+ ) : tools.filter(isMCPTool).length === 0 ? (
345
+ <div className="px-4 py-6">
346
+ <div className="flex flex-col items-center gap-3 text-center">
347
+ <div className="h-12 w-12 rounded-full bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center">
348
+ <Settings2 className="h-6 w-6 text-zinc-400 dark:text-zinc-500" />
349
+ </div>
350
+ <div>
351
+ <p className="text-sm font-medium text-zinc-600 dark:text-zinc-300">No tools available</p>
352
+ <p className="text-xs text-zinc-500 dark:text-zinc-400 mt-1">Check back later for updates</p>
353
+ </div>
354
+ </div>
355
  </div>
356
  ) : (
357
+ <div className="p-2">
358
+ {tools
359
+ .filter(isMCPTool)
360
+ .filter(tool =>
361
+ tool.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
362
+ (tool.description && tool.description.toLowerCase().includes(searchQuery.toLowerCase()))
363
+ )
364
+ .map((tool, index) => (
365
  <button
366
+ key={tool.name || index}
367
  type="button"
368
+ className={`w-full rounded-xl px-4 py-3 text-left transition-all duration-200 group ${
369
+ focusedIndex === index
370
+ ? 'bg-teal-50 dark:bg-teal-900/20 border border-teal-300/80 dark:border-teal-400/60'
371
+ : selectedTool === tool.name
372
+ ? 'bg-teal-50/50 dark:bg-teal-900/10 border border-teal-200/60 dark:border-teal-400/40'
373
+ : 'hover:bg-zinc-50 dark:hover:bg-zinc-800/50 border border-transparent'
374
+ }`}
375
  onClick={() => {
376
  setSelectedTool((current) => (current === tool.name ? null : tool.name));
377
  setShowToolsDropdown(false);
378
+ setFocusedIndex(-1);
379
  }}
380
+ onMouseEnter={() => setFocusedIndex(index)}
381
  >
382
+ <div className="flex items-start justify-between gap-3">
383
+ <div className="flex-1 min-w-0">
384
+ <div className="flex items-center gap-2 mb-1">
385
+ <h4 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 truncate">
386
+ {tool.name}
387
+ </h4>
388
+ {selectedTool === tool.name && (
389
+ <Check className="h-3.5 w-3.5 text-teal-500 dark:text-teal-400 flex-shrink-0" />
390
+ )}
391
+ </div>
392
+ {tool.description && (
393
+ <p className="text-xs text-zinc-600 dark:text-zinc-400 line-clamp-2 leading-relaxed">
394
+ {tool.description}
395
+ </p>
396
+ )}
397
+ </div>
398
+ </div>
399
  </button>
400
+ ))}
401
+ </div>
 
402
  )}
403
  </div>
404
+
405
+ {/* Footer */}
406
+ <div className="px-4 py-2 border-t border-zinc-200/50 dark:border-zinc-700/50 bg-zinc-50/50 dark:bg-zinc-800/30 flex-shrink-0">
407
+ <div className="flex items-center justify-end">
408
+ <button
409
+ type="button"
410
+ onClick={() => {
411
+ setSelectedTool(null);
412
+ setShowToolsDropdown(false);
413
+ setFocusedIndex(-1);
414
+ }}
415
+ className="text-xs text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200 transition-colors duration-200"
416
+ >
417
+ Clear selection
418
+ </button>
419
+ </div>
420
+ </div>
421
  </div>
422
  )}
423
  </div>