fluxy-bot 0.1.16 → 0.1.17

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.
@@ -29,15 +29,28 @@ function DashboardError() {
29
29
  export default function App() {
30
30
  const [chatOpen, setChatOpen] = useState(false);
31
31
  const [showOnboard, setShowOnboard] = useState(false);
32
+ const [botName, setBotName] = useState('Fluxy');
32
33
  const { ws, connected } = useWebSocket();
33
34
 
34
35
  useEffect(() => {
35
36
  fetch('/api/settings')
36
37
  .then((r) => r.json())
37
- .then((s) => { if (s.onboard_complete !== 'true') setShowOnboard(true); })
38
+ .then((s) => {
39
+ if (s.onboard_complete !== 'true') setShowOnboard(true);
40
+ if (s.agent_name) setBotName(s.agent_name);
41
+ })
38
42
  .catch(() => setShowOnboard(true));
39
43
  }, []);
40
44
 
45
+ // Refresh bot name after onboarding completes
46
+ const handleOnboardComplete = () => {
47
+ setShowOnboard(false);
48
+ fetch('/api/settings')
49
+ .then((r) => r.json())
50
+ .then((s) => { if (s.agent_name) setBotName(s.agent_name); })
51
+ .catch(() => {});
52
+ };
53
+
41
54
  return (
42
55
  <>
43
56
  <ErrorBoundary fallback={<DashboardError />}>
@@ -55,13 +68,13 @@ export default function App() {
55
68
  >
56
69
  {/* Header — pr-12 to avoid overlap with built-in X button */}
57
70
  <div className="flex items-center gap-3 px-4 pr-12 py-3 border-b border-border shrink-0">
58
- <img src="/fluxy.png" alt="Fluxy" className="h-5 w-auto" />
59
- <SheetTitle className="text-sm font-semibold">Fluxy</SheetTitle>
71
+ <img src="/fluxy.png" alt={botName} className="h-5 w-auto" />
72
+ <SheetTitle className="text-sm font-semibold">{botName}</SheetTitle>
60
73
  <div
61
74
  className={`h-2 w-2 rounded-full ${connected ? 'bg-green-500' : 'bg-red-500'}`}
62
75
  />
63
76
  <SheetDescription className="sr-only">
64
- Chat with your Fluxy AI assistant
77
+ Chat with your {botName} AI assistant
65
78
  </SheetDescription>
66
79
  </div>
67
80
 
@@ -75,7 +88,7 @@ export default function App() {
75
88
  <FluxyFab onClick={() => setChatOpen((o) => !o)} />
76
89
 
77
90
  {/* Onboarding wizard overlay */}
78
- {showOnboard && <OnboardWizard onComplete={() => setShowOnboard(false)} />}
91
+ {showOnboard && <OnboardWizard onComplete={handleOnboardComplete} />}
79
92
  </>
80
93
  );
81
94
  }
@@ -1,9 +1,10 @@
1
- import { useState, useRef, useCallback, useEffect, type KeyboardEvent, type PointerEvent as RPointerEvent } from 'react';
2
- import { SendHorizontal, Mic, Square, Trash2, Paperclip, Camera } from 'lucide-react';
1
+ import { useState, useRef, useCallback, useEffect, type KeyboardEvent, type PointerEvent as RPointerEvent, type ChangeEvent } from 'react';
2
+ import { SendHorizontal, Mic, Square, Trash2, Paperclip, Camera, X } from 'lucide-react';
3
3
  import { motion, AnimatePresence } from 'framer-motion';
4
+ import type { Attachment } from '../../hooks/useChat';
4
5
 
5
6
  interface Props {
6
- onSend: (msg: string) => void;
7
+ onSend: (msg: string, attachments?: Attachment[]) => void;
7
8
  onStop: () => void;
8
9
  streaming: boolean;
9
10
  }
@@ -16,9 +17,11 @@ function formatTime(s: number) {
16
17
 
17
18
  export default function InputBar({ onSend, onStop, streaming }: Props) {
18
19
  const [text, setText] = useState('');
20
+ const [attachments, setAttachments] = useState<Attachment[]>([]);
19
21
  const [isRecording, setIsRecording] = useState(false);
20
22
  const [recordingTime, setRecordingTime] = useState(0);
21
23
  const hasText = text.trim().length > 0;
24
+ const hasContent = hasText || attachments.length > 0;
22
25
 
23
26
  const textareaRef = useRef<HTMLTextAreaElement>(null);
24
27
  const fileRef = useRef<HTMLInputElement>(null);
@@ -51,11 +54,61 @@ export default function InputBar({ onSend, onStop, streaming }: Props) {
51
54
  dragRef.current = 0;
52
55
  }, []);
53
56
 
57
+ // ── File handling ──
58
+
59
+ const addFile = useCallback((file: File) => {
60
+ const isImage = file.type.startsWith('image/');
61
+ const isPdf = file.type === 'application/pdf';
62
+ if (!isImage && !isPdf) return; // Only images and PDFs allowed
63
+
64
+ const reader = new FileReader();
65
+ reader.onload = (e) => {
66
+ const preview = e.target?.result as string;
67
+ setAttachments((prev) => [
68
+ ...prev,
69
+ {
70
+ id: Math.random().toString(36).slice(2),
71
+ type: isImage ? 'image' : 'file',
72
+ name: file.name,
73
+ preview,
74
+ },
75
+ ]);
76
+ };
77
+ reader.readAsDataURL(file);
78
+ }, []);
79
+
80
+ const handleFileChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
81
+ const files = e.target.files;
82
+ if (!files) return;
83
+ for (let i = 0; i < files.length; i++) addFile(files[i]);
84
+ e.target.value = ''; // Reset so same file can be re-selected
85
+ }, [addFile]);
86
+
87
+ const removeAttachment = useCallback((id: string) => {
88
+ setAttachments((prev) => prev.filter((a) => a.id !== id));
89
+ }, []);
90
+
91
+ // Handle paste for images
92
+ useEffect(() => {
93
+ const handlePaste = (e: ClipboardEvent) => {
94
+ if (!e.clipboardData?.items) return;
95
+ for (const item of e.clipboardData.items) {
96
+ if (item.type.startsWith('image/')) {
97
+ e.preventDefault();
98
+ const file = item.getAsFile();
99
+ if (file) addFile(file);
100
+ }
101
+ }
102
+ };
103
+ document.addEventListener('paste', handlePaste);
104
+ return () => document.removeEventListener('paste', handlePaste);
105
+ }, [addFile]);
106
+
54
107
  const handleSend = () => {
55
- if (!hasText) return;
56
- onSend(text);
108
+ if (!hasContent) return;
109
+ onSend(text, attachments.length > 0 ? attachments : undefined);
57
110
  setText('');
58
- // Keep keyboard open after sending
111
+ setAttachments([]);
59
112
  requestAnimationFrame(() => textareaRef.current?.focus());
60
113
  };
61
114
 
@@ -66,14 +119,13 @@ export default function InputBar({ onSend, onStop, streaming }: Props) {
66
119
  }
67
120
  };
68
121
 
69
- // ── Mic pointer handlers ──────────────────────────
122
+ // ── Mic pointer handlers ──
70
123
  const handleMicDown = useCallback((e: RPointerEvent) => {
71
- e.preventDefault(); // prevent textarea blur → keeps keyboard up
124
+ e.preventDefault();
72
125
  startXRef.current = e.clientX;
73
126
  dragRef.current = 0;
74
127
  (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
75
128
 
76
- // Hold delay — 200ms to distinguish click vs hold
77
129
  holdTimerRef.current = setTimeout(() => {
78
130
  isHolding.current = true;
79
131
  setIsRecording(true);
@@ -83,12 +135,10 @@ export default function InputBar({ onSend, onStop, streaming }: Props) {
83
135
 
84
136
  const handleMicMove = useCallback((e: RPointerEvent) => {
85
137
  if (!isHolding.current) return;
86
- // Only track horizontal movement
87
138
  const dx = Math.min(0, e.clientX - startXRef.current);
88
139
  dragRef.current = dx;
89
140
  if (micRef.current) micRef.current.style.transform = `translateX(${dx}px)`;
90
141
 
91
- // Auto-cancel when pointer nears trash icon
92
142
  if (trashRef.current) {
93
143
  const trashRect = trashRef.current.getBoundingClientRect();
94
144
  const trashCenterX = trashRect.left + trashRect.width / 2;
@@ -100,8 +150,8 @@ export default function InputBar({ onSend, onStop, streaming }: Props) {
100
150
 
101
151
  const handleMicUp = useCallback(() => {
102
152
  if (holdTimerRef.current) { clearTimeout(holdTimerRef.current); holdTimerRef.current = null; }
103
- if (!isHolding.current) return; // was just a click — do nothing
104
- stopRecording(false); // send audio
153
+ if (!isHolding.current) return;
154
+ stopRecording(false);
105
155
  }, [stopRecording]);
106
156
 
107
157
  const handleMicCancel = useCallback(() => {
@@ -113,63 +163,105 @@ export default function InputBar({ onSend, onStop, streaming }: Props) {
113
163
  <div className="shrink-0 p-3 relative">
114
164
  {/* ── Normal input (always mounted to keep keyboard alive) ── */}
115
165
  <div
116
- className={`flex items-center gap-2 transition-opacity duration-100 ${
166
+ className={`flex flex-col transition-opacity duration-100 ${
117
167
  isRecording ? 'opacity-0 pointer-events-none' : 'opacity-100'
118
168
  }`}
119
169
  >
120
- <div className="flex-1 flex items-center bg-white rounded-full px-4">
121
- <textarea
122
- ref={textareaRef}
123
- value={text}
124
- onChange={(e) => setText(e.target.value)}
125
- onKeyDown={handleKeyDown}
126
- placeholder="Type a message..."
127
- rows={1}
128
- className="flex-1 resize-none bg-transparent text-gray-900 py-3 text-sm outline-none placeholder:text-gray-400"
129
- />
130
- {/* Hidden file inputs for native PWA pickers */}
131
- <input ref={fileRef} type="file" className="hidden" accept="*/*" />
132
- <input ref={cameraRef} type="file" className="hidden" accept="image/*" capture="environment" />
133
- <button
134
- type="button"
135
- onClick={() => fileRef.current?.click()}
136
- className="shrink-0 p-1.5 text-gray-400 hover:text-gray-600 transition-colors"
137
- >
138
- <Paperclip className="h-4 w-4" />
139
- </button>
140
- <button
141
- type="button"
142
- onClick={() => cameraRef.current?.click()}
143
- className="shrink-0 p-1.5 text-gray-400 hover:text-gray-600 transition-colors"
144
- >
145
- <Camera className="h-4 w-4" />
146
- </button>
147
- </div>
148
- {streaming ? (
149
- <button
150
- onClick={onStop}
151
- className="flex items-center justify-center h-12 w-12 shrink-0 rounded-full bg-destructive text-destructive-foreground transition-colors"
152
- >
153
- <Square className="h-4 w-4" />
154
- </button>
155
- ) : hasText ? (
156
- <button
157
- onClick={handleSend}
158
- className="flex items-center justify-center h-12 w-12 shrink-0 rounded-full bg-white text-gray-900 transition-colors hover:bg-gray-100"
159
- >
160
- <SendHorizontal className="h-4 w-4" />
161
- </button>
162
- ) : (
163
- <div
164
- className="flex items-center justify-center h-12 w-12 shrink-0 rounded-full bg-white text-gray-900 transition-colors hover:bg-gray-100 cursor-pointer touch-none select-none"
165
- onPointerDown={handleMicDown}
166
- onPointerMove={handleMicMove}
167
- onPointerUp={handleMicUp}
168
- onPointerCancel={handleMicCancel}
169
- >
170
- <Mic className="h-4 w-4" />
170
+ {/* ── Attachment previews ── */}
171
+ <AnimatePresence>
172
+ {attachments.length > 0 && (
173
+ <motion.div
174
+ initial={{ height: 0, opacity: 0 }}
175
+ animate={{ height: 'auto', opacity: 1 }}
176
+ exit={{ height: 0, opacity: 0 }}
177
+ transition={{ duration: 0.15 }}
178
+ className="overflow-hidden"
179
+ >
180
+ <div className="flex gap-2 px-4 pt-2 pb-1.5 flex-wrap">
181
+ {attachments.map((att) => (
182
+ <div key={att.id} className="relative group flex-shrink-0">
183
+ {att.type === 'image' ? (
184
+ <img
185
+ src={att.preview}
186
+ alt={att.name}
187
+ className="w-14 h-14 rounded-lg object-cover border border-white/10"
188
+ />
189
+ ) : (
190
+ <div className="w-14 h-14 rounded-lg bg-white/5 border border-white/10 flex flex-col items-center justify-center gap-0.5 px-1">
191
+ <svg className="w-5 h-5 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
192
+ <path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
193
+ </svg>
194
+ <span className="text-[8px] text-muted-foreground truncate w-full text-center">{att.name.split('.').pop()}</span>
195
+ </div>
196
+ )}
197
+ <button
198
+ onClick={() => removeAttachment(att.id)}
199
+ className="absolute -top-1.5 -right-1.5 w-4 h-4 bg-black/80 border border-white/20 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
200
+ >
201
+ <X className="w-2.5 h-2.5 text-white" />
202
+ </button>
203
+ </div>
204
+ ))}
205
+ </div>
206
+ </motion.div>
207
+ )}
208
+ </AnimatePresence>
209
+
210
+ <div className="flex items-center gap-2">
211
+ <div className="flex-1 flex items-center bg-white rounded-full px-4">
212
+ <textarea
213
+ ref={textareaRef}
214
+ value={text}
215
+ onChange={(e) => setText(e.target.value)}
216
+ onKeyDown={handleKeyDown}
217
+ placeholder="Type a message..."
218
+ rows={1}
219
+ className="flex-1 resize-none bg-transparent text-gray-900 py-3 text-sm outline-none placeholder:text-gray-400"
220
+ />
221
+ {/* Hidden file inputs for native PWA pickers */}
222
+ <input ref={fileRef} type="file" className="hidden" accept="image/*,.pdf" multiple onChange={handleFileChange} />
223
+ <input ref={cameraRef} type="file" className="hidden" accept="image/*" capture="environment" onChange={handleFileChange} />
224
+ <button
225
+ type="button"
226
+ onClick={() => fileRef.current?.click()}
227
+ className="shrink-0 p-1.5 text-gray-400 hover:text-gray-600 transition-colors"
228
+ >
229
+ <Paperclip className="h-4 w-4" />
230
+ </button>
231
+ <button
232
+ type="button"
233
+ onClick={() => cameraRef.current?.click()}
234
+ className="shrink-0 p-1.5 text-gray-400 hover:text-gray-600 transition-colors"
235
+ >
236
+ <Camera className="h-4 w-4" />
237
+ </button>
171
238
  </div>
172
- )}
239
+ {streaming ? (
240
+ <button
241
+ onClick={onStop}
242
+ className="flex items-center justify-center h-12 w-12 shrink-0 rounded-full bg-destructive text-destructive-foreground transition-colors"
243
+ >
244
+ <Square className="h-4 w-4" />
245
+ </button>
246
+ ) : hasContent ? (
247
+ <button
248
+ onClick={handleSend}
249
+ className="flex items-center justify-center h-12 w-12 shrink-0 rounded-full bg-white text-gray-900 transition-colors hover:bg-gray-100"
250
+ >
251
+ <SendHorizontal className="h-4 w-4" />
252
+ </button>
253
+ ) : (
254
+ <div
255
+ className="flex items-center justify-center h-12 w-12 shrink-0 rounded-full bg-white text-gray-900 transition-colors hover:bg-gray-100 cursor-pointer touch-none select-none"
256
+ onPointerDown={handleMicDown}
257
+ onPointerMove={handleMicMove}
258
+ onPointerUp={handleMicUp}
259
+ onPointerCancel={handleMicCancel}
260
+ >
261
+ <Mic className="h-4 w-4" />
262
+ </div>
263
+ )}
264
+ </div>
173
265
  </div>
174
266
 
175
267
  {/* ── Recording overlay (absolute so no layout shift) ── */}
@@ -6,23 +6,35 @@ import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
6
6
  interface Props {
7
7
  role: 'user' | 'assistant';
8
8
  content: string;
9
+ timestamp?: string;
9
10
  }
10
11
 
11
- export default function MessageBubble({ role, content }: Props) {
12
+ function formatTime(iso: string): string {
13
+ try {
14
+ const d = new Date(iso);
15
+ return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
16
+ } catch {
17
+ return '';
18
+ }
19
+ }
20
+
21
+ export default function MessageBubble({ role, content, timestamp }: Props) {
12
22
  const isUser = role === 'user';
23
+ const time = timestamp ? formatTime(timestamp) : '';
13
24
 
14
25
  if (isUser) {
15
26
  return (
16
- <div className="flex justify-end">
27
+ <div className="flex flex-col items-end gap-0.5">
17
28
  <div className="max-w-[75%] rounded-2xl px-4 py-2.5 text-sm leading-relaxed whitespace-pre-wrap bg-primary text-primary-foreground">
18
29
  {content}
19
30
  </div>
31
+ {time && <span className="text-[10px] text-muted-foreground/50 px-1">{time}</span>}
20
32
  </div>
21
33
  );
22
34
  }
23
35
 
24
36
  return (
25
- <div className="flex justify-start">
37
+ <div className="flex flex-col items-start gap-0.5">
26
38
  <div className="max-w-[75%] rounded-2xl px-4 py-2.5 text-sm leading-relaxed bg-muted text-foreground prose prose-sm prose-invert max-w-none [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
27
39
  <ReactMarkdown
28
40
  remarkPlugins={[remarkGfm]}
@@ -75,6 +87,7 @@ export default function MessageBubble({ role, content }: Props) {
75
87
  {content}
76
88
  </ReactMarkdown>
77
89
  </div>
90
+ {time && <span className="text-[10px] text-muted-foreground/50 px-1">{time}</span>}
78
91
  </div>
79
92
  );
80
93
  }
@@ -46,7 +46,7 @@ export default function MessageList({ messages, streaming, streamBuffer, tools }
46
46
 
47
47
  <div className="space-y-3">
48
48
  {messages.map((msg) => (
49
- <MessageBubble key={msg.id} role={msg.role} content={msg.content} />
49
+ <MessageBubble key={msg.id} role={msg.role} content={msg.content} timestamp={msg.timestamp} />
50
50
  ))}
51
51
 
52
52
  {/* Tool activity indicators */}
@@ -1,10 +1,11 @@
1
- import { useCallback, useEffect, useState } from 'react';
1
+ import { useCallback, useEffect, useState, useRef } from 'react';
2
2
  import type { WsClient } from '../lib/ws-client';
3
3
 
4
4
  export interface ChatMessage {
5
5
  id: string;
6
6
  role: 'user' | 'assistant';
7
7
  content: string;
8
+ timestamp: string; // ISO string
8
9
  }
9
10
 
10
11
  export interface ToolActivity {
@@ -12,12 +13,59 @@ export interface ToolActivity {
12
13
  status: 'running' | 'done';
13
14
  }
14
15
 
16
+ export interface Attachment {
17
+ id: string;
18
+ type: 'image' | 'file';
19
+ name: string;
20
+ preview: string; // base64 data URL
21
+ }
22
+
23
+ const CONV_KEY = 'fluxy_conversation_id';
24
+
15
25
  export function useChat(ws: WsClient | null) {
16
26
  const [messages, setMessages] = useState<ChatMessage[]>([]);
17
- const [conversationId, setConversationId] = useState<string | null>(null);
27
+ const [conversationId, setConversationId] = useState<string | null>(
28
+ () => localStorage.getItem(CONV_KEY),
29
+ );
18
30
  const [streaming, setStreaming] = useState(false);
19
31
  const [streamBuffer, setStreamBuffer] = useState('');
20
32
  const [tools, setTools] = useState<ToolActivity[]>([]);
33
+ const loaded = useRef(false);
34
+
35
+ // Load existing conversation messages on mount
36
+ useEffect(() => {
37
+ if (loaded.current || !conversationId) return;
38
+ loaded.current = true;
39
+ fetch(`/api/conversations/${conversationId}`)
40
+ .then((r) => {
41
+ if (!r.ok) throw new Error('not found');
42
+ return r.json();
43
+ })
44
+ .then((data) => {
45
+ if (data.messages?.length) {
46
+ setMessages(
47
+ data.messages
48
+ .filter((m: any) => m.role === 'user' || m.role === 'assistant')
49
+ .map((m: any) => ({
50
+ id: m.id,
51
+ role: m.role,
52
+ content: m.content,
53
+ timestamp: m.created_at,
54
+ })),
55
+ );
56
+ }
57
+ })
58
+ .catch(() => {
59
+ // Conversation gone — clear stale ID
60
+ localStorage.removeItem(CONV_KEY);
61
+ setConversationId(null);
62
+ });
63
+ }, [conversationId]);
64
+
65
+ // Persist conversationId to localStorage
66
+ useEffect(() => {
67
+ if (conversationId) localStorage.setItem(CONV_KEY, conversationId);
68
+ }, [conversationId]);
21
69
 
22
70
  useEffect(() => {
23
71
  if (!ws) return;
@@ -39,7 +87,15 @@ export function useChat(ws: WsClient | null) {
39
87
  }),
40
88
  ws.on('bot:response', (data: { conversationId: string; messageId?: string; content: string }) => {
41
89
  setConversationId(data.conversationId);
42
- setMessages((msgs) => [...msgs, { id: data.messageId || Date.now().toString(), role: 'assistant', content: data.content }]);
90
+ setMessages((msgs) => [
91
+ ...msgs,
92
+ {
93
+ id: data.messageId || Date.now().toString(),
94
+ role: 'assistant',
95
+ content: data.content,
96
+ timestamp: new Date().toISOString(),
97
+ },
98
+ ]);
43
99
  setStreamBuffer('');
44
100
  setStreaming(false);
45
101
  setTools([]);
@@ -50,7 +106,12 @@ export function useChat(ws: WsClient | null) {
50
106
  setTools([]);
51
107
  setMessages((msgs) => [
52
108
  ...msgs,
53
- { id: Date.now().toString(), role: 'assistant', content: `Error: ${data.error}` },
109
+ {
110
+ id: Date.now().toString(),
111
+ role: 'assistant',
112
+ content: `Error: ${data.error}`,
113
+ timestamp: new Date().toISOString(),
114
+ },
54
115
  ]);
55
116
  }),
56
117
  ];
@@ -59,12 +120,31 @@ export function useChat(ws: WsClient | null) {
59
120
  }, [ws]);
60
121
 
61
122
  const sendMessage = useCallback(
62
- (content: string) => {
63
- if (!ws || !content.trim()) return;
123
+ (content: string, attachments?: Attachment[]) => {
124
+ if (!ws || (!content.trim() && (!attachments || attachments.length === 0))) return;
64
125
 
65
- const userMsg: ChatMessage = { id: Date.now().toString(), role: 'user', content };
126
+ const userMsg: ChatMessage = {
127
+ id: Date.now().toString(),
128
+ role: 'user',
129
+ content,
130
+ timestamp: new Date().toISOString(),
131
+ };
66
132
  setMessages((msgs) => [...msgs, userMsg]);
67
- ws.send('user:message', { conversationId, content });
133
+
134
+ // Build WS payload
135
+ const payload: any = { conversationId, content };
136
+ if (attachments?.length) {
137
+ payload.attachments = attachments.map((att) => {
138
+ const match = att.preview.match(/^data:([^;]+);base64,(.+)$/);
139
+ return {
140
+ type: att.type,
141
+ name: att.name,
142
+ mediaType: match?.[1] || 'application/octet-stream',
143
+ data: match?.[2] || '',
144
+ };
145
+ });
146
+ }
147
+ ws.send('user:message', payload);
68
148
  },
69
149
  [ws, conversationId],
70
150
  );
@@ -83,6 +163,8 @@ export function useChat(ws: WsClient | null) {
83
163
  setStreamBuffer('');
84
164
  setStreaming(false);
85
165
  setTools([]);
166
+ localStorage.removeItem(CONV_KEY);
167
+ loaded.current = false;
86
168
  }, []);
87
169
 
88
170
  return { messages, streaming, streamBuffer, conversationId, tools, sendMessage, stopStreaming, newChat };