Skip to content

Commit 9d41ac5

Browse files
committed
feat: polish React UI — theme toggle, model selector, WebSocket fixes
- Add ThemeToggle component (dark/light with localStorage persistence) - Add ModelSelector dropdown (GPT-5.2, GPT-4.1, o3, Gemini 3.1 Pro) - Backend: set_provider WS handler + Vertex AI Gemini support - Backend: handle Gemini list-format content blocks - Fix duplicate messages: harden WebSocket (connectingRef guard), rewrite complete handler to only finalize existing messages - Fix Vite proxy port 8010 → 8000 - Remove React.StrictMode (caused double WS connections) - Production-safe pages.py: serve React build or fallback to dev - Recursion limit 35 → 20 to save tokens - CSS: full dark/light theming via CSS variables - Update example queries for scientific accuracy - Add clear conversation confirmation dialog - Rename VOSTOK → EURUS banner
1 parent b4b1df5 commit 9d41ac5

19 files changed

+563
-89
lines changed

frontend/src/App.css

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
overflow: hidden;
66
position: relative;
77
z-index: 1;
8+
padding: 0.5rem;
9+
gap: 0.5rem;
810
}
911

1012
@media (max-width: 768px) {

frontend/src/components/ApiKeysPanel.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@
4545
.keys-field input {
4646
width: 100%;
4747
padding: 0.5rem 0.7rem;
48-
background: rgba(255, 255, 255, 0.03);
48+
background: var(--input-bg);
4949
border: 1px solid var(--glass-border);
5050
border-radius: var(--radius-sm);
5151
color: var(--text-1);

frontend/src/components/CachePanel.css

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@
4141

4242
.cache-refresh:hover {
4343
color: var(--text-2);
44-
background: rgba(255, 255, 255, 0.04);
44+
background: var(--hover-bg);
4545
}
4646

4747
.cache-summary {
@@ -52,7 +52,7 @@
5252
font-size: 0.72rem;
5353
font-weight: 500;
5454
color: var(--text-3);
55-
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
55+
border-bottom: 1px solid var(--subtle-border);
5656
text-transform: uppercase;
5757
letter-spacing: 0.05em;
5858
}
@@ -67,13 +67,13 @@
6767

6868
.cache-item {
6969
padding: 0.55rem 1.15rem;
70-
border-bottom: 1px solid rgba(255, 255, 255, 0.025);
70+
border-bottom: 1px solid var(--subtle-border);
7171
transition: background 0.2s;
7272
cursor: default;
7373
}
7474

7575
.cache-item:hover {
76-
background: rgba(255, 255, 255, 0.025);
76+
background: var(--hover-bg);
7777
}
7878

7979
.cache-item-row {

frontend/src/components/ChatPanel.css

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -66,9 +66,15 @@
6666
}
6767

6868
.icon-btn:hover {
69-
background: rgba(255, 255, 255, 0.06);
69+
background: var(--hover-bg);
7070
color: var(--text-2);
71-
border-color: rgba(255, 255, 255, 0.1);
71+
border-color: var(--hover-border);
72+
}
73+
74+
.danger-btn:hover {
75+
background: rgba(248, 113, 113, 0.1);
76+
color: var(--danger);
77+
border-color: rgba(248, 113, 113, 0.2);
7278
}
7379

7480
.status-badge {
@@ -95,6 +101,9 @@
95101
flex: 1;
96102
overflow-y: auto;
97103
padding: 1.5rem 2rem 1rem;
104+
max-width: 960px;
105+
margin: 0 auto;
106+
width: 100%;
98107
}
99108

100109
/* ── Empty state ── */
@@ -210,19 +219,22 @@
210219
display: flex;
211220
align-items: flex-end;
212221
gap: 0.6rem;
213-
padding: 0.85rem 1.5rem 1.1rem;
222+
padding: 0.85rem 2rem 1.1rem;
214223
background: var(--glass);
215224
border-top: 1px solid var(--glass-border);
216225
backdrop-filter: blur(20px);
217226
-webkit-backdrop-filter: blur(20px);
227+
max-width: 960px;
228+
margin: 0 auto;
229+
width: 100%;
218230
}
219231

220232
.input-bar textarea {
221233
flex: 1;
222234
resize: none;
223235
border: 1px solid var(--glass-border);
224236
border-radius: var(--radius);
225-
background: rgba(255, 255, 255, 0.03);
237+
background: var(--input-bg);
226238
color: var(--text-1);
227239
padding: 0.7rem 1rem;
228240
font-size: 0.9rem;
@@ -235,7 +247,7 @@
235247
.input-bar textarea:focus {
236248
border-color: rgba(109, 92, 255, 0.4);
237249
box-shadow: 0 0 0 3px rgba(109, 92, 255, 0.08);
238-
background: rgba(255, 255, 255, 0.04);
250+
background: var(--input-focus-bg);
239251
}
240252

241253
.input-bar textarea::placeholder {
@@ -277,4 +289,11 @@
277289
opacity: 0.3;
278290
cursor: not-allowed;
279291
box-shadow: none;
280-
}.empty-warning { font-size: 0.78rem !important; color: var(--text-3) !important; opacity: 0.8; margin-top: -0.5rem !important; }
292+
}
293+
294+
.empty-warning {
295+
font-size: 0.78rem !important;
296+
color: var(--text-3) !important;
297+
opacity: 0.8;
298+
margin-top: -0.5rem !important;
299+
}

frontend/src/components/ChatPanel.tsx

Lines changed: 35 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { useCallback, useEffect, useRef, useState, ReactNode } from 'react';
22
import { Send, Wifi, WifiOff, Loader2, Trash2 } from 'lucide-react';
3+
import ThemeToggle from './ThemeToggle';
4+
import ModelSelector from './ModelSelector';
35
import { useWebSocket, WSEvent } from '../hooks/useWebSocket';
46
import MessageBubble, { ChatMessage, MediaItem } from './MessageBubble';
57
import ApiKeysPanel from './ApiKeysPanel';
@@ -137,21 +139,28 @@ export default function ChatPanel({ cacheToggle }: ChatPanelProps) {
137139
case 'complete':
138140
setIsThinking(false);
139141
setStatusMsg('');
142+
// Only finalize the existing streaming message — never create a new one.
143+
// Snapshot refs into locals BEFORE the state setter runs.
140144
if (streamId.current) {
141-
const finalContent = ev.content ?? streamBuf.current;
142-
setMessages(prev => {
143-
const exists = prev.find(m => m.id === streamId.current);
144-
if (exists) {
145-
return prev.map(m =>
146-
m.id === streamId.current
147-
? { ...m, content: finalContent, media: [...streamMedia.current], arraylakeSnippets: [...streamSnippets.current], isStreaming: false, toolLabel: undefined, statusText: undefined }
148-
: m
149-
);
150-
}
151-
return [...prev, { id: streamId.current!, role: 'assistant', content: finalContent, media: [...streamMedia.current], arraylakeSnippets: [...streamSnippets.current] }];
152-
});
153-
} else {
154-
setMessages(prev => [...prev, { id: uid(), role: 'assistant', content: ev.content ?? '' }]);
145+
const capturedId = streamId.current;
146+
const capturedContent = ev.content ?? streamBuf.current;
147+
const capturedMedia = [...streamMedia.current];
148+
const capturedSnippets = [...streamSnippets.current];
149+
setMessages(prev =>
150+
prev.map(m => {
151+
if (m.id !== capturedId) return m;
152+
return {
153+
...m,
154+
content: capturedContent || m.content,
155+
// Preserve media/snippets already on the message if our refs are empty
156+
media: capturedMedia.length > 0 ? capturedMedia : (m.media || []),
157+
arraylakeSnippets: capturedSnippets.length > 0 ? capturedSnippets : (m.arraylakeSnippets || []),
158+
isStreaming: false,
159+
toolLabel: undefined,
160+
statusText: undefined,
161+
};
162+
})
163+
);
155164
}
156165
streamBuf.current = '';
157166
streamMedia.current = [];
@@ -190,7 +199,7 @@ export default function ChatPanel({ cacheToggle }: ChatPanelProps) {
190199
}
191200
}, []);
192201

193-
const { status, sendMessage, configureKeys } = useWebSocket(handleEvent);
202+
const { status, send, sendMessage, configureKeys } = useWebSocket(handleEvent);
194203

195204
/* ── check if server has keys ── */
196205
useEffect(() => {
@@ -222,6 +231,7 @@ export default function ChatPanel({ cacheToggle }: ChatPanelProps) {
222231

223232
/* ── clear conversation ── */
224233
const handleClear = async () => {
234+
if (!confirm('Clear conversation history?')) return;
225235
try {
226236
await fetch('/api/conversation', { method: 'DELETE' });
227237
setMessages([]);
@@ -256,14 +266,16 @@ export default function ChatPanel({ cacheToggle }: ChatPanelProps) {
256266
<h1>Eurus Climate Agent</h1>
257267
</div>
258268
<div className="chat-header-actions">
259-
{cacheToggle}
260-
<button className="icon-btn" onClick={handleClear} title="Clear conversation">
261-
<Trash2 size={16} />
262-
</button>
263269
<div className={statusClass} style={{ color: statusColor }}>
264270
<StatusIcon size={12} />
265271
<span>{status}</span>
266272
</div>
273+
{cacheToggle}
274+
<ModelSelector send={send} />
275+
<ThemeToggle />
276+
<button className="icon-btn danger-btn" onClick={handleClear} title="Clear conversation">
277+
<Trash2 size={16} />
278+
</button>
267279
</div>
268280
</header>
269281

@@ -281,11 +293,11 @@ export default function ChatPanel({ cacheToggle }: ChatPanelProps) {
281293
⚠️ <strong>Experimental</strong> — research prototype. Avoid very large datasets. Use 📦 Arraylake Code for heavy workloads.
282294
</p>
283295
<div className="example-queries">
284-
<button onClick={() => { setInput('Show SST for California coast, Jan 2024'); }}>
285-
🌡 SST — California coast
296+
<button onClick={() => { setInput('Show SST map for the North Atlantic, Jan 2024'); }}>
297+
🌡 SST — North Atlantic
286298
</button>
287-
<button onClick={() => { setInput('Compare wind speed Berlin vs Tokyo, March 2023'); }}>
288-
💨 Wind — Berlin vs Tokyo
299+
<button onClick={() => { setInput('Compare 2m temperature Berlin vs Tokyo, March 2023'); }}>
300+
💨 Temperature — Berlin vs Tokyo
289301
</button>
290302
<button onClick={() => { setInput('Precipitation anomalies over Amazon, 2023'); }}>
291303
🌧 Rain — Amazon basin

frontend/src/components/MessageBubble.css

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
}
2727

2828
.bubble {
29-
max-width: 75%;
29+
max-width: 80%;
3030
padding: 0.9rem 1.15rem;
3131
border-radius: var(--radius);
3232
line-height: 1.6;
@@ -83,7 +83,7 @@
8383
}
8484

8585
.bubble pre {
86-
background: rgba(0, 0, 0, 0.4);
86+
background: var(--code-bg);
8787
border: 1px solid var(--glass-border);
8888
border-radius: var(--radius-sm);
8989
padding: 0.8rem;
@@ -119,7 +119,7 @@
119119
}
120120

121121
.bubble th {
122-
background: rgba(255, 255, 255, 0.04);
122+
background: var(--subtle-bg);
123123
font-weight: 600;
124124
color: var(--text-2);
125125
}
@@ -137,22 +137,30 @@
137137
/* ── Plot / Video figure ── */
138138
.plot-figure {
139139
margin: 0.65rem 0;
140+
max-width: 560px;
140141
border-radius: var(--radius-sm);
141142
overflow: hidden;
142143
border: 1px solid var(--glass-border);
143-
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3);
144+
box-shadow: 0 4px 24px rgba(0, 0, 0, var(--shadow-strength));
144145
}
145146

146147
.plot-img {
147148
width: 100%;
148149
display: block;
150+
cursor: zoom-in;
151+
transition: filter 0.2s ease, transform 0.2s ease;
152+
}
153+
154+
.plot-img:hover {
155+
filter: brightness(1.08);
156+
transform: scale(1.01);
149157
}
150158

151159
.plot-actions {
152160
display: flex;
153161
gap: 0.35rem;
154162
padding: 0.45rem 0.6rem;
155-
background: rgba(0, 0, 0, 0.3);
163+
background: var(--plot-actions-bg);
156164
border-top: 1px solid var(--glass-border);
157165
flex-wrap: wrap;
158166
}
@@ -191,7 +199,7 @@
191199
/* ── Code block under plot ── */
192200
.plot-code-block {
193201
position: relative;
194-
background: rgba(0, 0, 0, 0.5);
202+
background: var(--code-block-bg);
195203
border-top: 1px solid var(--glass-border);
196204
padding: 0.7rem;
197205
max-height: 300px;
@@ -229,7 +237,7 @@
229237

230238
.copy-btn:hover {
231239
color: var(--text-1);
232-
background: rgba(255, 255, 255, 0.06);
240+
background: var(--hover-bg);
233241
}
234242

235243
/* ── Arraylake section ── */
@@ -241,7 +249,7 @@
241249
.image-modal-overlay {
242250
position: fixed;
243251
inset: 0;
244-
background: rgba(0, 0, 0, 0.85);
252+
background: var(--overlay-bg);
245253
display: flex;
246254
align-items: center;
247255
justify-content: center;
@@ -301,11 +309,11 @@
301309
}
302310

303311
.modal-btn.modal-close {
304-
background: rgba(255, 255, 255, 0.08);
312+
background: var(--modal-close-bg);
305313
}
306314

307315
.modal-btn.modal-close:hover {
308-
background: rgba(255, 255, 255, 0.15);
316+
background: var(--modal-close-hover);
309317
}
310318

311319
/* streaming cursor */

frontend/src/components/MessageBubble.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,7 @@ export default function MessageBubble({ msg }: { msg: ChatMessage }) {
209209
{/* Arraylake snippets */}
210210
{msg.arraylakeSnippets?.map((s, i) => <ArraylakeSnippet key={`al-${i}`} code={s} />)}
211211

212-
{msg.isStreaming && <span className="cursor-blink"></span>}
212+
{msg.isStreaming && !msg.media?.length && !msg.arraylakeSnippets?.length && <span className="cursor-blink"></span>}
213213
</div>
214214
</div>
215215

0 commit comments

Comments
 (0)