Daniel McKnight NeonClary commited on
Commit
d764c8d
·
unverified ·
1 Parent(s): 9bed513

Refactor chat UI layout (#19)

Browse files

* feat: add advisor carousel, avatar images in messages, visual improvements

Add AdvisorCarousel for welcome state, inline avatar display in MessageBubble, LemonSliceAvatar component, AgentStatusDropdown, and wider floating input area with sidebar-aware positioning.

Made-with: Cursor

* Resolve default value bug in `AdvisorCarousel.js`

* Remove unused `AgentDrobdownStatus.js`

* Remove unrelated `LemonSliceAvatar.js`

* Remove LemonSlice references

* Implement Cursor fix to carousel UI

* Add check to resolve error observed in test deployment

* Replace `all` with `and` checks per review

---------

Co-authored-by: NeonClary <askclary@gmail.com>

multi_llm_chatbot_backend/app/api/routes/chat.py CHANGED
@@ -195,11 +195,11 @@ async def chat_sequential_enhanced(
195
  logger.info(f"Session {session_id} has {rag_stats.get('total_documents', 0)} documents available")
196
 
197
  # Warn if a repeated input message is received
198
- if all((
199
- session.messages,
200
- session.messages[-1].get('role') == 'user',
201
  session.messages[-1].get('content') == message.user_input
202
- )):
203
  # TODO: This should be handled in the front-end input
204
  logger.warning(f"Repeated user input: {message.user_input}")
205
  session.append_message("user", message.user_input)
 
195
  logger.info(f"Session {session_id} has {rag_stats.get('total_documents', 0)} documents available")
196
 
197
  # Warn if a repeated input message is received
198
+ if (
199
+ session.messages and
200
+ session.messages[-1].get('role') == 'user' and
201
  session.messages[-1].get('content') == message.user_input
202
+ ):
203
  # TODO: This should be handled in the front-end input
204
  logger.warning(f"Repeated user input: {message.user_input}")
205
  session.append_message("user", message.user_input)
phd-advisor-frontend/src/components/AdvisorCarousel.js ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useRef, useEffect, useCallback } from 'react';
2
+ import { ChevronLeft, ChevronRight } from 'lucide-react';
3
+ import MessageBubble from './MessageBubble';
4
+
5
+ const CAROUSEL_BREAKPOINT = 700;
6
+
7
+ const AdvisorCarousel = ({ messages = [], onReply, onExpand, onClick, onSearchReferences, userAvatarId, userAvatarOptions }) => {
8
+ const [activeIndex, setActiveIndex] = useState(0);
9
+ const [isCarouselMode, setIsCarouselMode] = useState(false);
10
+ const containerRef = useRef(null);
11
+
12
+ useEffect(() => {
13
+ const el = containerRef.current;
14
+ if (!el) return;
15
+
16
+ const observer = new ResizeObserver(entries => {
17
+ const width = entries[0].contentRect.width;
18
+ setIsCarouselMode(width < CAROUSEL_BREAKPOINT);
19
+ });
20
+ observer.observe(el);
21
+ return () => observer.disconnect();
22
+ }, []);
23
+
24
+ useEffect(() => {
25
+ setActiveIndex(0);
26
+ }, [messages.length]);
27
+
28
+ const goPrev = useCallback(() => {
29
+ setActiveIndex(i => Math.max(0, i - 1));
30
+ }, []);
31
+
32
+ const goNext = useCallback(() => {
33
+ setActiveIndex(i => Math.min(messages.length - 1, i + 1));
34
+ }, [messages.length]);
35
+
36
+ if (messages.length === 1) {
37
+ return (
38
+ <div className="single-response-wide">
39
+ <MessageBubble
40
+ message={messages[0]}
41
+ onReply={onReply}
42
+ onExpand={onExpand}
43
+ onClick={onClick}
44
+ onSearchReferences={onSearchReferences}
45
+ showReplyButton={true}
46
+ userAvatarId={userAvatarId}
47
+ userAvatarOptions={userAvatarOptions}
48
+ />
49
+ </div>
50
+ );
51
+ }
52
+
53
+ return (
54
+ <div
55
+ className={`advisor-carousel ${isCarouselMode ? 'carousel-mode' : 'grid-mode'}`}
56
+ ref={containerRef}
57
+ >
58
+ {isCarouselMode && (
59
+ <button
60
+ className="carousel-arrow carousel-prev"
61
+ onClick={goPrev}
62
+ disabled={activeIndex === 0}
63
+ aria-label="Previous advisor"
64
+ >
65
+ <ChevronLeft size={20} />
66
+ </button>
67
+ )}
68
+
69
+ <div className="carousel-viewport">
70
+ <div
71
+ className="carousel-track"
72
+ style={isCarouselMode ? { width: `${messages.length * 100}%`, transform: `translateX(-${activeIndex * (100 / messages.length)}%)` } : undefined}
73
+ >
74
+ {messages.map(message => (
75
+ <div key={message.id} className="carousel-slide" style={isCarouselMode ? { width: `${100 / messages.length}%` } : undefined}>
76
+ <MessageBubble
77
+ message={message}
78
+ onReply={onReply}
79
+ onExpand={onExpand}
80
+ onClick={onClick}
81
+ onSearchReferences={onSearchReferences}
82
+ showReplyButton={true}
83
+ inlineAvatar={true}
84
+ userAvatarId={userAvatarId}
85
+ userAvatarOptions={userAvatarOptions}
86
+ />
87
+ </div>
88
+ ))}
89
+ </div>
90
+ </div>
91
+
92
+ {isCarouselMode && (
93
+ <>
94
+ <button
95
+ className="carousel-arrow carousel-next"
96
+ onClick={goNext}
97
+ disabled={activeIndex === messages.length - 1}
98
+ aria-label="Next advisor"
99
+ >
100
+ <ChevronRight size={20} />
101
+ </button>
102
+
103
+ <div className="carousel-dots">
104
+ {messages.map((m, i) => (
105
+ <button
106
+ key={m.id}
107
+ className={`carousel-dot ${i === activeIndex ? 'active' : ''}`}
108
+ onClick={() => setActiveIndex(i)}
109
+ aria-label={`Go to advisor ${i + 1}`}
110
+ />
111
+ ))}
112
+ </div>
113
+ </>
114
+ )}
115
+ </div>
116
+ );
117
+ };
118
+
119
+ export default AdvisorCarousel;
phd-advisor-frontend/src/components/MessageBubble.js CHANGED
@@ -1,23 +1,92 @@
1
- import React, { useState, useRef, useEffect } from 'react';
2
  import ReactMarkdown from 'react-markdown';
3
  import remarkGfm from 'remark-gfm';
4
- import { Reply, Copy, Check, Maximize2, Info, FileText, Hash, Target } from 'lucide-react';
 
5
  import { useAppConfig } from '../contexts/AppConfigContext';
6
  import { useTheme } from '../contexts/ThemeContext';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
 
8
  const MessageBubble = ({
9
  message,
10
  onReply,
11
  onCopy,
12
  onExpand,
13
- showReplyButton = false
 
 
 
 
14
  }) => {
15
  const { isDark } = useTheme();
16
- const { advisors, getAdvisorColors } = useAppConfig();
 
17
  const [showTooltip, setShowTooltip] = useState(null);
18
  const [copiedStates, setCopiedStates] = useState({});
19
- const [showInfoOverlay, setShowInfoOverlay] = useState(false);
 
 
 
 
 
20
  const overlayRef = useRef(null);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
 
22
  const handleCopy = async (messageId, content) => {
23
  try {
@@ -36,31 +105,39 @@ const MessageBubble = ({
36
  if (onExpand) onExpand(messageId, persona_id);
37
  };
38
 
39
- const handleInfoToggle = () => {
40
- setShowInfoOverlay(!showInfoOverlay);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
  };
42
 
43
  const showTooltipWithDelay = (tooltipType) => {
44
- setTimeout(() => setShowTooltip(tooltipType), 500);
 
45
  };
46
 
47
- const hideTooltip = () => setShowTooltip(null);
48
-
49
- // Close overlay when clicking outside
50
- useEffect(() => {
51
- const handleClickOutside = (event) => {
52
- if (overlayRef.current && !overlayRef.current.contains(event.target)) {
53
- setShowInfoOverlay(false);
54
- }
55
- };
56
-
57
- if (showInfoOverlay) {
58
- document.addEventListener('mousedown', handleClickOutside);
59
- return () => {
60
- document.removeEventListener('mousedown', handleClickOutside);
61
- };
62
- }
63
- }, [showInfoOverlay]);
64
 
65
  // Minimal, safe preprocessing (keep Markdown structure intact)
66
  const preprocessMarkdown = (content) => {
@@ -186,6 +263,11 @@ const MessageBubble = ({
186
 
187
  // USER MESSAGE
188
  if (message.type === 'user') {
 
 
 
 
 
189
  return (
190
  <div className="user-message-container">
191
  <div className="user-message">
@@ -197,6 +279,15 @@ const MessageBubble = ({
197
  )}
198
  <p>{message.content}</p>
199
  </div>
 
 
 
 
 
 
 
 
 
200
  </div>
201
  );
202
  }
@@ -216,14 +307,44 @@ const MessageBubble = ({
216
  const colors = getAdvisorColors(personaId, isDark);
217
  const isCopied = copiedStates[message.id];
218
 
219
- return (
220
- <div className="advisor-message-container">
221
- <div
222
- className="advisor-avatar"
223
- style={{ backgroundColor: colors.bgColor || 'var(--bg-muted)' }}
 
 
 
 
 
 
 
 
224
  >
225
- {Icon ? <Icon style={{ color: colors.color || 'var(--text-secondary)' }} /> : (advisor.name ? advisor.name.charAt(0) : 'A')}
226
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
227
 
228
  <div
229
  className="advisor-message-bubble"
@@ -234,6 +355,7 @@ const MessageBubble = ({
234
  }}
235
  >
236
  <div className="advisor-message-header">
 
237
  <h4
238
  className="advisor-message-name"
239
  style={{ color: colors.color || 'var(--text-primary)' }}
@@ -324,36 +446,93 @@ const MessageBubble = ({
324
  <Maximize2 size={14} />
325
  </button>
326
  {showTooltip === 'expand' && (
327
- <div className="tooltip">Expand</div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
328
  )}
329
  </div>
330
 
331
  <div className="tooltip-container">
332
  <button
333
  className="message-action-button"
334
- onClick={handleInfoToggle}
335
- onMouseEnter={() => showTooltipWithDelay('info')}
336
  onMouseLeave={hideTooltip}
337
  style={{
338
  color: colors.color || 'var(--text-secondary)',
339
  borderColor: (colors.color ? colors.color + '40' : 'var(--border-muted)')
340
  }}
341
  >
342
- <Info size={14} />
343
  </button>
344
- {showTooltip === 'info' && (
345
- <div className="tooltip">Info</div>
346
  )}
347
  </div>
 
348
  </div>
349
  </div>
350
  )}
351
 
352
- {showInfoOverlay && (
353
- <RagInfoOverlay
354
- ragMetadata={message.ragMetadata}
355
- colors={colors}
356
- />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
357
  )}
358
  </div>
359
  </div>
 
1
+ import React, { useState, useRef, useEffect, useCallback } from 'react';
2
  import ReactMarkdown from 'react-markdown';
3
  import remarkGfm from 'remark-gfm';
4
+ import { Reply, Copy, Check, Maximize2, FileText, Hash, Target, Volume2, VolumeX, Search, X, Loader2 } from 'lucide-react';
5
+ import * as LucideIcons from 'lucide-react';
6
  import { useAppConfig } from '../contexts/AppConfigContext';
7
  import { useTheme } from '../contexts/ThemeContext';
8
+ import { useVoiceStatus } from '../contexts/VoiceStatusContext';
9
+
10
+ const stripMarkdown = (md) => {
11
+ if (!md) return '';
12
+ return md
13
+ .replace(/```[\s\S]*?```/g, '')
14
+ .replace(/`([^`]+)`/g, '$1')
15
+ .replace(/#{1,6}\s?/g, '')
16
+ .replace(/[*_~]{1,3}([^*_~]+)[*_~]{1,3}/g, '$1')
17
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
18
+ .replace(/!\[([^\]]*)\]\([^)]+\)/g, '$1')
19
+ .replace(/[-*+]\s/g, '')
20
+ .replace(/\n{2,}/g, '. ')
21
+ .replace(/\n/g, ' ')
22
+ .trim();
23
+ };
24
 
25
  const MessageBubble = ({
26
  message,
27
  onReply,
28
  onCopy,
29
  onExpand,
30
+ onSearchReferences,
31
+ showReplyButton = false,
32
+ inlineAvatar = false,
33
+ userAvatarId,
34
+ userAvatarOptions
35
  }) => {
36
  const { isDark } = useTheme();
37
+ const { allPersonas: advisors, getAllPersonaColors: getAdvisorColors } = useAppConfig();
38
+ const voiceStatus = useVoiceStatus();
39
  const [showTooltip, setShowTooltip] = useState(null);
40
  const [copiedStates, setCopiedStates] = useState({});
41
+ const [isSpeaking, setIsSpeaking] = useState(false);
42
+ const [isLoadingTTS, setIsLoadingTTS] = useState(false);
43
+ const [searchPopover, setSearchPopover] = useState(false);
44
+ const [searchQuery, setSearchQuery] = useState('');
45
+ const [searchLoading, setSearchLoading] = useState(false);
46
+ const [promptCopied, setPromptCopied] = useState(false);
47
  const overlayRef = useRef(null);
48
+ const tooltipTimer = useRef(null);
49
+ const audioRef = useRef(null);
50
+
51
+ const handleSpeak = useCallback(async (content) => {
52
+ if (isSpeaking || isLoadingTTS) {
53
+ if (audioRef.current) { audioRef.current.pause(); audioRef.current = null; }
54
+ setIsSpeaking(false);
55
+ setIsLoadingTTS(false);
56
+ return;
57
+ }
58
+ if (voiceStatus && !voiceStatus.ensureReady('tts')) return;
59
+
60
+ const text = (content || '').trim();
61
+ if (!text) return;
62
+ setIsLoadingTTS(true);
63
+ try {
64
+ const token = localStorage.getItem('authToken');
65
+ const resp = await fetch(`${process.env.REACT_APP_API_URL}/api/tts`, {
66
+ method: 'POST',
67
+ headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
68
+ body: JSON.stringify({ text }),
69
+ });
70
+ if (!resp.ok) throw new Error('TTS failed');
71
+ const blob = await resp.blob();
72
+ const url = URL.createObjectURL(blob);
73
+ const audio = new Audio(url);
74
+ audioRef.current = audio;
75
+ setIsLoadingTTS(false);
76
+ setIsSpeaking(true);
77
+ audio.onended = () => { setIsSpeaking(false); URL.revokeObjectURL(url); audioRef.current = null; };
78
+ audio.onerror = () => { setIsSpeaking(false); URL.revokeObjectURL(url); audioRef.current = null; };
79
+ audio.play();
80
+ } catch (e) {
81
+ console.error('TTS error:', e);
82
+ setIsLoadingTTS(false);
83
+ setIsSpeaking(false);
84
+ }
85
+ }, [isSpeaking, isLoadingTTS, voiceStatus]);
86
+
87
+ useEffect(() => {
88
+ return () => { if (audioRef.current) { audioRef.current.pause(); audioRef.current = null; } };
89
+ }, []);
90
 
91
  const handleCopy = async (messageId, content) => {
92
  try {
 
105
  if (onExpand) onExpand(messageId, persona_id);
106
  };
107
 
108
+ const handleSearch = async () => {
109
+ setSearchPopover(true);
110
+ setSearchLoading(true);
111
+ const content = message?.compact_markdown || message?.content || '';
112
+ try {
113
+ const token = localStorage.getItem('authToken');
114
+ const resp = await fetch(`${process.env.REACT_APP_API_URL}/api/search-references`, {
115
+ method: 'POST',
116
+ headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
117
+ body: JSON.stringify({ statement: content.substring(0, 500) }),
118
+ });
119
+ if (resp.ok) {
120
+ const data = await resp.json();
121
+ setSearchQuery(data.search_query || content.substring(0, 100));
122
+ } else {
123
+ setSearchQuery(content.substring(0, 100));
124
+ }
125
+ } catch {
126
+ setSearchQuery(content.substring(0, 100));
127
+ } finally {
128
+ setSearchLoading(false);
129
+ }
130
  };
131
 
132
  const showTooltipWithDelay = (tooltipType) => {
133
+ clearTimeout(tooltipTimer.current);
134
+ tooltipTimer.current = setTimeout(() => setShowTooltip(tooltipType), 500);
135
  };
136
 
137
+ const hideTooltip = () => {
138
+ clearTimeout(tooltipTimer.current);
139
+ setShowTooltip(null);
140
+ };
 
 
 
 
 
 
 
 
 
 
 
 
 
141
 
142
  // Minimal, safe preprocessing (keep Markdown structure intact)
143
  const preprocessMarkdown = (content) => {
 
263
 
264
  // USER MESSAGE
265
  if (message.type === 'user') {
266
+ const uAvatar = userAvatarOptions?.find(a => a.id === userAvatarId);
267
+ const UserIcon = uAvatar
268
+ ? (LucideIcons[uAvatar.icon] || LucideIcons.User)
269
+ : null;
270
+
271
  return (
272
  <div className="user-message-container">
273
  <div className="user-message">
 
279
  )}
280
  <p>{message.content}</p>
281
  </div>
282
+ {UserIcon && (
283
+ <div style={{
284
+ width: 32, height: 32, borderRadius: '50%', flexShrink: 0, marginLeft: 8,
285
+ backgroundColor: uAvatar.bg, color: uAvatar.color,
286
+ display: 'flex', alignItems: 'center', justifyContent: 'center'
287
+ }}>
288
+ <UserIcon size={16} />
289
+ </div>
290
+ )}
291
  </div>
292
  );
293
  }
 
307
  const colors = getAdvisorColors(personaId, isDark);
308
  const isCopied = copiedStates[message.id];
309
 
310
+ const avatarElement = (size = 40) => (
311
+ advisor.avatarUrl ? (
312
+ <img
313
+ src={advisor.avatarUrl}
314
+ alt={advisor.name || 'Advisor'}
315
+ style={{ width: size, height: size, borderRadius: '50%', objectFit: 'cover', flexShrink: 0 }}
316
+ />
317
+ ) : Icon ? (
318
+ <div
319
+ style={{
320
+ width: size, height: size, borderRadius: '50%', backgroundColor: colors.bgColor || 'var(--bg-muted)',
321
+ display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, overflow: 'hidden'
322
+ }}
323
  >
324
+ <Icon style={{ color: colors.color || 'var(--text-secondary)', width: size * 0.5, height: size * 0.5 }} />
325
  </div>
326
+ ) : (
327
+ <div
328
+ style={{
329
+ width: size, height: size, borderRadius: '50%', backgroundColor: colors.bgColor || 'var(--bg-muted)',
330
+ display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
331
+ color: colors.color || 'var(--text-secondary)', fontWeight: 600, fontSize: size * 0.4
332
+ }}
333
+ >
334
+ {advisor.name ? advisor.name.charAt(0) : 'A'}
335
+ </div>
336
+ )
337
+ );
338
+
339
+ return (
340
+ <div className={`advisor-message-container ${inlineAvatar ? 'inline-avatar-mode' : ''}`}>
341
+ {!inlineAvatar && (
342
+ <div
343
+ className="advisor-avatar"
344
+ style={{ backgroundColor: colors.bgColor || 'var(--bg-muted)', overflow: 'hidden' }}
345
+ >
346
+ </div>
347
+ )}
348
 
349
  <div
350
  className="advisor-message-bubble"
 
355
  }}
356
  >
357
  <div className="advisor-message-header">
358
+ {inlineAvatar && avatarElement(32)}
359
  <h4
360
  className="advisor-message-name"
361
  style={{ color: colors.color || 'var(--text-primary)' }}
 
446
  <Maximize2 size={14} />
447
  </button>
448
  {showTooltip === 'expand' && (
449
+ <div className="tooltip">More</div>
450
+ )}
451
+ </div>
452
+
453
+ <div className="tooltip-container">
454
+ <button
455
+ className="message-action-button"
456
+ onClick={() => handleSpeak(message?.compact_markdown || message?.content || '')}
457
+ onMouseEnter={() => showTooltipWithDelay('speak')}
458
+ onMouseLeave={hideTooltip}
459
+ style={{
460
+ color: isLoadingTTS ? (colors.color || 'var(--text-secondary)') : isSpeaking ? '#EF4444' : (colors.color || 'var(--text-secondary)'),
461
+ borderColor: isSpeaking ? '#EF444440' : (colors.color ? colors.color + '40' : 'var(--border-muted)'),
462
+ opacity: isLoadingTTS ? 0.7 : 1,
463
+ }}
464
+ >
465
+ {isLoadingTTS ? <Loader2 size={14} style={{ animation: 'spin 1s linear infinite' }} /> : isSpeaking ? <VolumeX size={14} /> : <Volume2 size={14} />}
466
+ </button>
467
+ {showTooltip === 'speak' && (
468
+ <div className="tooltip">{isLoadingTTS ? 'Loading audio...' : isSpeaking ? 'Stop speaking' : 'Speak it'}</div>
469
  )}
470
  </div>
471
 
472
  <div className="tooltip-container">
473
  <button
474
  className="message-action-button"
475
+ onClick={handleSearch}
476
+ onMouseEnter={() => showTooltipWithDelay('search')}
477
  onMouseLeave={hideTooltip}
478
  style={{
479
  color: colors.color || 'var(--text-secondary)',
480
  borderColor: (colors.color ? colors.color + '40' : 'var(--border-muted)')
481
  }}
482
  >
483
+ <Search size={14} />
484
  </button>
485
+ {showTooltip === 'search' && (
486
+ <div className="tooltip">Search for references</div>
487
  )}
488
  </div>
489
+
490
  </div>
491
  </div>
492
  )}
493
 
494
+ {searchPopover && (
495
+ <div style={{
496
+ marginTop: 8, background: 'var(--bg-primary)',
497
+ border: '1px solid var(--border-primary)', borderRadius: 12,
498
+ padding: 14, boxShadow: '0 8px 32px rgba(0,0,0,0.12)',
499
+ }}>
500
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
501
+ <span style={{ fontWeight: 600, fontSize: 13, color: 'var(--text-primary)' }}>Search for References</span>
502
+ <button onClick={() => setSearchPopover(false)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-secondary)' }}>
503
+ <X size={14} />
504
+ </button>
505
+ </div>
506
+ {searchLoading ? (
507
+ <div style={{ color: 'var(--text-secondary)', fontSize: 12 }}>Generating search query...</div>
508
+ ) : (
509
+ <>
510
+ <div style={{
511
+ background: 'var(--bg-secondary)', borderRadius: 8, padding: '8px 10px',
512
+ fontSize: 12, color: 'var(--text-primary)', marginBottom: 8, lineHeight: 1.4,
513
+ }}>{searchQuery}</div>
514
+ <div style={{ display: 'flex', gap: 6 }}>
515
+ <button onClick={() => window.open(`https://www.perplexity.ai/?q=${encodeURIComponent(searchQuery)}`, '_blank')} style={{
516
+ padding: '6px 12px', borderRadius: 8, fontSize: 11, fontWeight: 600,
517
+ background: 'var(--accent-primary)', color: '#fff', border: 'none', cursor: 'pointer',
518
+ }}>Open in Perplexity</button>
519
+ <button onClick={() => {
520
+ navigator.clipboard.writeText(searchQuery).then(() => {
521
+ setPromptCopied(true);
522
+ setTimeout(() => setPromptCopied(false), 2000);
523
+ }).catch(() => {});
524
+ }} style={{
525
+ padding: '6px 12px', borderRadius: 8, fontSize: 11, fontWeight: 600,
526
+ background: promptCopied ? '#10B98120' : 'var(--bg-secondary)',
527
+ color: promptCopied ? '#10B981' : 'var(--text-primary)',
528
+ border: `1px solid ${promptCopied ? '#10B98140' : 'var(--border-primary)'}`,
529
+ cursor: 'pointer',
530
+ transition: 'all 0.2s ease',
531
+ }}>{promptCopied ? '✓ Copied!' : 'Copy Prompt'}</button>
532
+ </div>
533
+ </>
534
+ )}
535
+ </div>
536
  )}
537
  </div>
538
  </div>
phd-advisor-frontend/src/contexts/AppConfigContext.js CHANGED
@@ -99,11 +99,15 @@ export const AppConfigProvider = ({ children }) => {
99
  }, [config]);
100
 
101
  const getAdvisorColors = buildGetAdvisorColors(advisors);
 
 
102
 
103
  const value = {
104
- config, // raw config object from /api/config
105
- advisors, // { methodologist: { name, role, icon, color, ... }, ... }
 
106
  getAdvisorColors,
 
107
  resolveIcon,
108
  loading,
109
  error,
 
99
  }, [config]);
100
 
101
  const getAdvisorColors = buildGetAdvisorColors(advisors);
102
+ const allPersonas = advisors;
103
+ const getAllPersonaColors = getAdvisorColors;
104
 
105
  const value = {
106
+ config,
107
+ advisors,
108
+ allPersonas,
109
  getAdvisorColors,
110
+ getAllPersonaColors,
111
  resolveIcon,
112
  loading,
113
  error,
phd-advisor-frontend/src/contexts/VoiceStatusContext.js ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { createContext, useContext } from 'react';
2
+
3
+ const VoiceStatusContext = createContext(null);
4
+
5
+ export const useVoiceStatus = () => useContext(VoiceStatusContext);
6
+
7
+ export const VoiceStatusProvider = ({ children }) => (
8
+ <VoiceStatusContext.Provider value={null}>
9
+ {children}
10
+ </VoiceStatusContext.Provider>
11
+ );
12
+
13
+ export default VoiceStatusContext;
phd-advisor-frontend/src/pages/ChatPage.js CHANGED
@@ -1,4 +1,4 @@
1
- import React, { useState, useEffect, useRef } from 'react';
2
  import { Home, MessageCircle, Reply, X, Sparkles, Users, Settings2, FileText , LogOut, Menu} from 'lucide-react';
3
  import EnhancedChatInput from '../components/EnhancedChatInput';
4
  import MessageBubble from '../components/MessageBubble';
@@ -13,6 +13,7 @@ import { useTheme } from '../contexts/ThemeContext';
13
  import '../styles/ChatPage.css';
14
  import '../styles/EnhancedChatInput.css';
15
  import AdvisorStatusDropdown from '../components/AdvisorStatusDropdown';
 
16
 
17
  const ChatPage = ({ user, authToken, onNavigateToHome, onNavigateToCanvas, onSignOut }) => {
18
  const { config, advisors, getAdvisorColors } = useAppConfig();
@@ -661,6 +662,26 @@ const handleNewChat = async (sessionId = null) => {
661
  }
662
  };
663
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
664
  const handleInputSubmit = async (inputMessage) => {
665
  if (replyingTo) {
666
  // This is a reply to a specific message
@@ -776,6 +797,7 @@ const handleNewChat = async (sessionId = null) => {
776
  <div className="chat-content">
777
  {!hasMessages ? (
778
  <div className="welcome-state">
 
779
  <SuggestionsPanel onSuggestionClick={handleSendMessage} />
780
  </div>
781
  ) : (
@@ -790,71 +812,70 @@ const handleNewChat = async (sessionId = null) => {
790
 
791
  <div className="messages-list">
792
  <div className="messages-scroll">
793
- {messages.map((message) => (
794
- <div key={message.id}>
795
- {message.type === 'user' && (
 
 
 
 
 
 
 
 
 
796
  <div className="user-message-container">
797
  <div className="user-message">
798
- {message.replyTo && (
799
  <div className="reply-indicator">
800
  <Reply size={12} />
801
- <span>Reply to {message.replyTo.advisorName}</span>
802
  </div>
803
  )}
804
- <p>{message.content}</p>
805
  </div>
806
  </div>
807
  )}
808
 
809
- {message.type === 'advisor' && (
810
- <MessageBubble
811
- message={message}
812
- onReply={handleReplyToMessage}
813
- onExpand={handleExpandMessage}
814
- onClick={handleMessageClick}
815
- showReplyButton={true}
816
- />
817
- )}
818
-
819
- {message.type === 'error' && (
820
  <div className="error-message-container">
821
  <div className="error-message">
822
- <p>{message.content}</p>
823
  </div>
824
  </div>
825
  )}
826
 
827
- {message.type === 'system' && (
828
  <div className="system-message-container">
829
  <div className="system-message">
830
- <p>{message.content}</p>
831
  </div>
832
  </div>
833
  )}
834
 
835
- {message.type === 'document_upload' && (
836
  <div className="system-message-container">
837
  <div className="system-message document-upload">
838
  <FileText size={16} />
839
- <p>{message.content}</p>
840
  </div>
841
  </div>
842
  )}
843
 
844
- {message.type === 'clarification' && (
845
  <div className="clarification-message-container">
846
  <div className="clarification-message">
847
  <div className="clarification-header">
848
  <MessageCircle size={16} />
849
  <span>I need a bit more information</span>
850
  </div>
851
- <p>{message.content}</p>
852
 
853
- {message.suggestions && message.suggestions.length > 0 && (
854
  <div className="clarification-suggestions">
855
  <p className="suggestions-label">Here are some ways you could be more specific:</p>
856
  <div className="suggestions-list">
857
- {message.suggestions.map((suggestion, index) => (
858
  <button
859
  key={index}
860
  className="suggestion-button"
@@ -869,9 +890,8 @@ const handleNewChat = async (sessionId = null) => {
869
  </div>
870
  </div>
871
  )}
872
-
873
-
874
  </div>
 
875
  ))}
876
 
877
  {thinkingAdvisors.includes('system') && (
 
1
+ import React, { useState, useEffect, useRef, useMemo } from 'react';
2
  import { Home, MessageCircle, Reply, X, Sparkles, Users, Settings2, FileText , LogOut, Menu} from 'lucide-react';
3
  import EnhancedChatInput from '../components/EnhancedChatInput';
4
  import MessageBubble from '../components/MessageBubble';
 
13
  import '../styles/ChatPage.css';
14
  import '../styles/EnhancedChatInput.css';
15
  import AdvisorStatusDropdown from '../components/AdvisorStatusDropdown';
16
+ import AdvisorCarousel from '../components/AdvisorCarousel';
17
 
18
  const ChatPage = ({ user, authToken, onNavigateToHome, onNavigateToCanvas, onSignOut }) => {
19
  const { config, advisors, getAdvisorColors } = useAppConfig();
 
662
  }
663
  };
664
 
665
+ /** Group consecutive advisor messages so we can render them in a horizontal carousel */
666
+ const messageGroups = useMemo(() => {
667
+ const groups = [];
668
+ let i = 0;
669
+ while (i < messages.length) {
670
+ if (messages[i].type === 'advisor') {
671
+ const advisorGroup = [];
672
+ while (i < messages.length && messages[i].type === 'advisor') {
673
+ advisorGroup.push(messages[i]);
674
+ i++;
675
+ }
676
+ groups.push({ type: 'advisor_group', messages: advisorGroup });
677
+ } else {
678
+ groups.push({ type: 'single', message: messages[i] });
679
+ i++;
680
+ }
681
+ }
682
+ return groups;
683
+ }, [messages]);
684
+
685
  const handleInputSubmit = async (inputMessage) => {
686
  if (replyingTo) {
687
  // This is a reply to a specific message
 
797
  <div className="chat-content">
798
  {!hasMessages ? (
799
  <div className="welcome-state">
800
+ <AdvisorCarousel />
801
  <SuggestionsPanel onSuggestionClick={handleSendMessage} />
802
  </div>
803
  ) : (
 
812
 
813
  <div className="messages-list">
814
  <div className="messages-scroll">
815
+ {messageGroups.map((group) => (
816
+ group.type === 'advisor_group' ? (
817
+ <AdvisorCarousel
818
+ key={group.messages.map(m => m.id).join('-')}
819
+ messages={group.messages}
820
+ onReply={handleReplyToMessage}
821
+ onExpand={handleExpandMessage}
822
+ onClick={handleMessageClick}
823
+ />
824
+ ) : (
825
+ <div key={group.message.id}>
826
+ {group.message.type === 'user' && (
827
  <div className="user-message-container">
828
  <div className="user-message">
829
+ {group.message.replyTo && (
830
  <div className="reply-indicator">
831
  <Reply size={12} />
832
+ <span>Reply to {group.message.replyTo.advisorName}</span>
833
  </div>
834
  )}
835
+ <p>{group.message.content}</p>
836
  </div>
837
  </div>
838
  )}
839
 
840
+ {group.message.type === 'error' && (
 
 
 
 
 
 
 
 
 
 
841
  <div className="error-message-container">
842
  <div className="error-message">
843
+ <p>{group.message.content}</p>
844
  </div>
845
  </div>
846
  )}
847
 
848
+ {group.message.type === 'system' && (
849
  <div className="system-message-container">
850
  <div className="system-message">
851
+ <p>{group.message.content}</p>
852
  </div>
853
  </div>
854
  )}
855
 
856
+ {group.message.type === 'document_upload' && (
857
  <div className="system-message-container">
858
  <div className="system-message document-upload">
859
  <FileText size={16} />
860
+ <p>{group.message.content}</p>
861
  </div>
862
  </div>
863
  )}
864
 
865
+ {group.message.type === 'clarification' && (
866
  <div className="clarification-message-container">
867
  <div className="clarification-message">
868
  <div className="clarification-header">
869
  <MessageCircle size={16} />
870
  <span>I need a bit more information</span>
871
  </div>
872
+ <p>{group.message.content}</p>
873
 
874
+ {group.message.suggestions && group.message.suggestions.length > 0 && (
875
  <div className="clarification-suggestions">
876
  <p className="suggestions-label">Here are some ways you could be more specific:</p>
877
  <div className="suggestions-list">
878
+ {group.message.suggestions.map((suggestion, index) => (
879
  <button
880
  key={index}
881
  className="suggestion-button"
 
890
  </div>
891
  </div>
892
  )}
 
 
893
  </div>
894
+ )
895
  ))}
896
 
897
  {thinkingAdvisors.includes('system') && (
phd-advisor-frontend/src/styles/ChatPage.css CHANGED
@@ -221,6 +221,123 @@
221
  height: 100%;
222
  }
223
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
224
 
225
  [data-theme="dark"] .messages-scroll,
226
  [data-theme="dark"] .messages-container,
@@ -415,6 +532,21 @@
415
  margin-bottom: 24px;
416
  }
417
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
418
  .thinking-content {
419
  display: flex;
420
  align-items: center;
@@ -457,7 +589,7 @@
457
  .floating-input-area {
458
  position: fixed;
459
  bottom: 20px;
460
- left: 0;
461
  right: 0;
462
  z-index: 50;
463
  background: transparent;
@@ -465,8 +597,13 @@
465
  display: flex;
466
  flex-direction: column;
467
  align-items: center;
468
- max-width: 800px;
469
  margin: 0 auto;
 
 
 
 
 
470
  }
471
 
472
  /* Reply Banner in ChatPage - matches input width and fixes dark mode */
@@ -1050,8 +1187,10 @@
1050
  padding-bottom: 30px;
1051
  }
1052
 
1053
- .floating-input-area {
 
1054
  bottom: 10px;
 
1055
  }
1056
 
1057
  .floating-input-area .reply-banner {
 
221
  height: 100%;
222
  }
223
 
224
+ /* Advisor carousel: horizontal layout for multiple advisor responses */
225
+ .advisor-carousel {
226
+ display: flex;
227
+ align-items: stretch;
228
+ gap: 12px;
229
+ margin-bottom: 24px;
230
+ min-height: 0;
231
+ width: 100%;
232
+ }
233
+
234
+ .advisor-carousel .carousel-viewport {
235
+ flex: 1;
236
+ min-width: 0;
237
+ overflow: hidden;
238
+ }
239
+
240
+ .advisor-carousel .carousel-track {
241
+ display: flex;
242
+ height: 100%;
243
+ transition: transform 0.25s ease-out;
244
+ }
245
+
246
+ /* Grid mode: wide layout, all advisor cards side by side */
247
+ .advisor-carousel.grid-mode .carousel-track {
248
+ transform: none;
249
+ gap: 16px;
250
+ }
251
+
252
+ .advisor-carousel.grid-mode .carousel-slide {
253
+ flex: 1 1 0;
254
+ min-width: 240px;
255
+ max-width: 400px;
256
+ }
257
+
258
+ /* Carousel mode: narrow layout, one card at a time with arrows */
259
+ .advisor-carousel.carousel-mode {
260
+ position: relative;
261
+ padding: 0 40px;
262
+ }
263
+
264
+ .advisor-carousel.carousel-mode .carousel-track {
265
+ width: 100%;
266
+ }
267
+
268
+ .advisor-carousel.carousel-mode .carousel-slide {
269
+ width: 100%;
270
+ flex-shrink: 0;
271
+ }
272
+
273
+ .advisor-carousel .carousel-arrow {
274
+ position: absolute;
275
+ top: 50%;
276
+ transform: translateY(-50%);
277
+ width: 36px;
278
+ height: 36px;
279
+ border-radius: 50%;
280
+ border: 1px solid var(--border-primary);
281
+ background: var(--bg-secondary);
282
+ color: var(--text-primary);
283
+ display: flex;
284
+ align-items: center;
285
+ justify-content: center;
286
+ cursor: pointer;
287
+ transition: background 0.2s, color 0.2s;
288
+ flex-shrink: 0;
289
+ z-index: 2;
290
+ }
291
+
292
+ .advisor-carousel .carousel-arrow:hover:not(:disabled) {
293
+ background: var(--accent-primary);
294
+ color: white;
295
+ border-color: var(--accent-primary);
296
+ }
297
+
298
+ .advisor-carousel .carousel-arrow:disabled {
299
+ opacity: 0.4;
300
+ cursor: not-allowed;
301
+ }
302
+
303
+ .advisor-carousel .carousel-prev {
304
+ left: 0;
305
+ }
306
+
307
+ .advisor-carousel .carousel-next {
308
+ right: 0;
309
+ }
310
+
311
+ .advisor-carousel .carousel-dots {
312
+ display: flex;
313
+ justify-content: center;
314
+ gap: 8px;
315
+ margin-top: 12px;
316
+ flex-shrink: 0;
317
+ }
318
+
319
+ .advisor-carousel .carousel-dot {
320
+ width: 8px;
321
+ height: 8px;
322
+ border-radius: 50%;
323
+ border: none;
324
+ background: var(--border-primary);
325
+ cursor: pointer;
326
+ transition: background 0.2s;
327
+ padding: 0;
328
+ }
329
+
330
+ .advisor-carousel .carousel-dot.active,
331
+ .advisor-carousel .carousel-dot:hover {
332
+ background: var(--accent-primary);
333
+ }
334
+
335
+ .single-response-wide {
336
+ margin-bottom: 24px;
337
+ width: 100%;
338
+ max-width: 512px;
339
+ }
340
+
341
 
342
  [data-theme="dark"] .messages-scroll,
343
  [data-theme="dark"] .messages-container,
 
532
  margin-bottom: 24px;
533
  }
534
 
535
+ .orchestrator-thinking .thinking-bubble {
536
+ width: 40px;
537
+ height: 40px;
538
+ min-width: 40px;
539
+ min-height: 40px;
540
+ border-radius: 50%;
541
+ display: flex;
542
+ align-items: center;
543
+ justify-content: center;
544
+ background: var(--accent-gradient, var(--accent-primary));
545
+ color: white;
546
+ border: none;
547
+ box-shadow: var(--shadow-sm);
548
+ }
549
+
550
  .thinking-content {
551
  display: flex;
552
  align-items: center;
 
589
  .floating-input-area {
590
  position: fixed;
591
  bottom: 20px;
592
+ left: 300px;
593
  right: 0;
594
  z-index: 50;
595
  background: transparent;
 
597
  display: flex;
598
  flex-direction: column;
599
  align-items: center;
600
+ max-width: 860px;
601
  margin: 0 auto;
602
+ transition: left 0.3s ease;
603
+ }
604
+
605
+ .floating-input-area.sidebar-collapsed {
606
+ left: 70px;
607
  }
608
 
609
  /* Reply Banner in ChatPage - matches input width and fixes dark mode */
 
1187
  padding-bottom: 30px;
1188
  }
1189
 
1190
+ .floating-input-area,
1191
+ .floating-input-area.sidebar-collapsed {
1192
  bottom: 10px;
1193
+ left: 0;
1194
  }
1195
 
1196
  .floating-input-area .reply-banner {