@polderlabs/bizar 4.2.4 → 4.3.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.
@@ -1,11 +1,13 @@
1
1
  // src/components/chat/useChat.ts — shared chat state: messages, sessions, send, polling.
2
+ // Supports two parallel message streams: bizar (GET /api/chat) and opencode (GET /api/opencode-sessions/:id/messages + SSE).
2
3
 
3
4
  import { useCallback, useEffect, useRef, useState } from 'react';
4
5
  import type { ChatMessage, ChatResponse, ChatSession, Settings, Snapshot } from '../../lib/types';
5
6
  import { api } from '../../lib/api';
6
7
 
7
8
  export function useChat(snapshot: Snapshot, settings: Settings, initialTaskId?: string | null) {
8
- const [messages, setMessages] = useState<ChatMessage[]>([]);
9
+ // ── Bizar stream ────────────────────────────────────────────────────────────
10
+ const [bizarMessages, setBizarMessages] = useState<ChatMessage[]>([]);
9
11
  const [sessions, setSessions] = useState<ChatSession[]>([]);
10
12
  const [opencodeSessions, setOpencodeSessions] = useState<ChatSession[]>([]);
11
13
  const [sessionId, setSessionId] = useState('');
@@ -13,21 +15,28 @@ export function useChat(snapshot: Snapshot, settings: Settings, initialTaskId?:
13
15
  const [sending, setSending] = useState(false);
14
16
  const [pinned, setPinned] = useState<Set<number>>(new Set());
15
17
 
18
+ // ── Opencode stream ─────────────────────────────────────────────────────────
19
+ const [opencodeMessages, setOpencodeMessages] = useState<ChatMessage[]>([]);
20
+ const [activeSource, setActiveSource] = useState<'bizar' | 'opencode' | null>(null);
21
+ const [activeOpencodeSessionId, setActiveOpencodeSessionId] = useState<string | null>(null);
22
+ const opencodeEsRef = useRef<EventSource | null>(null);
23
+
16
24
  const listRef = useRef<HTMLDivElement>(null);
17
25
  const toastRef = useRef<{ error: (msg: string) => void; success: (msg: string) => void; info: (msg: string) => void } | null>(null);
18
26
 
19
- // Auto-scroll on new messages
27
+ // Auto-scroll on new messages (both streams)
20
28
  useEffect(() => {
21
29
  if (listRef.current) {
22
30
  listRef.current.scrollTop = listRef.current.scrollHeight;
23
31
  }
24
- }, [messages]);
32
+ }, [bizarMessages, opencodeMessages]);
25
33
 
34
+ // ── Load bizar chat ─────────────────────────────────────────────────────────
26
35
  const loadChat = useCallback(async (sid?: string) => {
27
36
  try {
28
37
  const url = sid ? `/chat?session=${encodeURIComponent(sid)}` : '/chat?limit=200';
29
38
  const data = await api.get<ChatResponse>(url);
30
- setMessages(data.messages || []);
39
+ setBizarMessages(data.messages || []);
31
40
  setSessions(data.sessions || []);
32
41
  } catch (err) {
33
42
  toastRef.current?.error(`Chat load failed: ${(err as Error).message}`);
@@ -40,7 +49,7 @@ export function useChat(snapshot: Snapshot, settings: Settings, initialTaskId?:
40
49
  setLoading(true);
41
50
  try {
42
51
  const data = await api.get<{ sessionId?: string; messages: ChatMessage[] }>(`/tasks/${encodeURIComponent(taskId)}/chat`);
43
- setMessages(data.messages || []);
52
+ setBizarMessages(data.messages || []);
44
53
  setSessionId(data.sessionId || taskId);
45
54
  } catch (err) {
46
55
  toastRef.current?.error(`Task chat load failed: ${(err as Error).message}`);
@@ -70,6 +79,120 @@ export function useChat(snapshot: Snapshot, settings: Settings, initialTaskId?:
70
79
  } catch { /* best-effort */ }
71
80
  }, []);
72
81
 
82
+ // ── Opencode session: load messages + open SSE ───────────────────────────────
83
+ const loadOpencodeSession = useCallback(async (sessionId: string) => {
84
+ // Close any existing opencode SSE
85
+ opencodeEsRef.current?.close();
86
+ opencodeEsRef.current = null;
87
+
88
+ try {
89
+ const data = await api.get<{ messages: ChatMessage[] }>(`/opencode-sessions/${encodeURIComponent(sessionId)}/messages`);
90
+ setOpencodeMessages(data.messages || []);
91
+ } catch (err) {
92
+ toastRef.current?.error(`Opencode session load failed: ${(err as Error).message}`);
93
+ setOpencodeMessages([]);
94
+ }
95
+
96
+ setActiveOpencodeSessionId(sessionId);
97
+ setActiveSource('opencode');
98
+
99
+ // Open SSE — EventSource can't set headers; token goes in query string via urlWithToken
100
+ const tok = api.getToken();
101
+ const baseUrl = tok
102
+ ? `/api/opencode-sessions/${encodeURIComponent(sessionId)}/stream?token=${encodeURIComponent(tok)}`
103
+ : `/api/opencode-sessions/${encodeURIComponent(sessionId)}/stream`;
104
+ const es = new EventSource(baseUrl);
105
+ opencodeEsRef.current = es;
106
+
107
+ es.onmessage = (e: MessageEvent) => {
108
+ // Each message is one SSE frame: "event: <type>\ndata: <json>\n\n"
109
+ // Split on newlines to isolate "event: …" and "data: …" lines
110
+ const raw = e.data;
111
+ if (!raw || raw.startsWith(':')) return; // skip heartbeat
112
+
113
+ let eventType = '';
114
+ let eventData: string | null = null;
115
+
116
+ const lines = raw.split('\n');
117
+ for (const line of lines) {
118
+ if (line.startsWith('event:')) {
119
+ eventType = line.slice(5).trim();
120
+ } else if (line.startsWith('data:')) {
121
+ eventData = line.slice(5).trim();
122
+ }
123
+ }
124
+
125
+ if (!eventType || eventData === null) return;
126
+
127
+ // Build a ChatMessage from the SSE event
128
+ let role: ChatMessage['role'] = 'assistant';
129
+ if (eventType.startsWith('message.user.')) role = 'user';
130
+
131
+ let content: string;
132
+ if (eventType === 'message.part.updated' && eventData !== null) {
133
+ try {
134
+ const parsed = JSON.parse(eventData);
135
+ content = parsed.data?.part?.text ?? JSON.stringify(parsed.data ?? parsed);
136
+ } catch {
137
+ content = eventData;
138
+ }
139
+ } else {
140
+ try {
141
+ const parsed = JSON.parse(eventData);
142
+ content = parsed.data ?? JSON.stringify(parsed);
143
+ } catch {
144
+ content = eventData;
145
+ }
146
+ }
147
+
148
+ // Dedup: skip if we already have a message with this id (from SSE echo)
149
+ const msgId = (() => {
150
+ try {
151
+ const parsed = JSON.parse(eventData!);
152
+ return parsed.messageID || parsed.id || null;
153
+ } catch {
154
+ return null;
155
+ }
156
+ })();
157
+
158
+ const newMsg: ChatMessage = {
159
+ id: msgId ?? undefined,
160
+ role,
161
+ content,
162
+ ts: new Date().toISOString(),
163
+ };
164
+
165
+ setOpencodeMessages((cur) => {
166
+ if (msgId && cur.some((m) => m.id === msgId)) return cur;
167
+ return [...cur, newMsg];
168
+ });
169
+ };
170
+
171
+ es.onerror = () => {
172
+ // SSE error — silently close; don't spam the user
173
+ try { es.close(); } catch { /* noop */ }
174
+ opencodeEsRef.current = null;
175
+ };
176
+ }, []);
177
+
178
+ // ── Close opencode session ──────────────────────────────────────────────────
179
+ const closeOpencodeSession = useCallback(() => {
180
+ opencodeEsRef.current?.close();
181
+ opencodeEsRef.current = null;
182
+ setOpencodeMessages([]);
183
+ setActiveOpencodeSessionId(null);
184
+ setActiveSource('bizar');
185
+ }, []);
186
+
187
+ // ── Select a bizar session ──────────────────────────────────────────────────
188
+ const selectBizarSession = useCallback((sid: string) => {
189
+ closeOpencodeSession();
190
+ setSessionId(sid);
191
+ setActiveSource('bizar');
192
+ loadChat(sid);
193
+ }, [closeOpencodeSession, loadChat]);
194
+
195
+ // ── Create session ──────────────────────────────────────────────────────────
73
196
  const onCreateSession = useCallback(async () => {
74
197
  if (!snapshot.activeProject) {
75
198
  toastRef.current?.error('Pick a project in Overview to scope chat sessions.');
@@ -78,7 +201,7 @@ export function useChat(snapshot: Snapshot, settings: Settings, initialTaskId?:
78
201
  try {
79
202
  const created = await api.post<ChatSession>('/chat/sessions', {});
80
203
  setSessionId(created.id);
81
- setMessages([]);
204
+ setBizarMessages([]);
82
205
  setPinned(new Set());
83
206
  await loadChat(created.id);
84
207
  refreshSessions().catch(() => {});
@@ -87,36 +210,66 @@ export function useChat(snapshot: Snapshot, settings: Settings, initialTaskId?:
87
210
  }
88
211
  }, [snapshot.activeProject, loadChat, refreshSessions]);
89
212
 
213
+ // ── Send message ────────────────────────────────────────────────────────────
90
214
  const onSend = useCallback(async (message: string, agent: string, model: string, attachments?: string[]) => {
91
- const optimistic: ChatMessage = {
92
- role: 'user',
93
- content: message,
94
- agent,
95
- ts: new Date().toISOString(),
96
- };
97
- setMessages((cur) => [...cur, optimistic]);
98
- setSending(true);
99
- try {
100
- const response = await api.post<{ messages?: ChatMessage[] }>('/chat', { message, agent, model, attachments: attachments || [] });
101
- if (response?.messages) {
102
- for (const msg of response.messages) {
103
- if (msg.role === 'assistant') {
104
- setMessages((cur) => {
105
- const exists = cur.some((m) => m.ts === msg.ts);
106
- return exists ? cur : [...cur, msg];
107
- });
215
+ if (activeSource === 'opencode' && activeOpencodeSessionId) {
216
+ // Optimistic add
217
+ const optimisticId = `msg_${Date.now()}_${Math.random().toString(36).slice(2)}`;
218
+ const optimistic: ChatMessage = {
219
+ id: optimisticId,
220
+ role: 'user',
221
+ content: message,
222
+ agent,
223
+ ts: new Date().toISOString(),
224
+ };
225
+ setOpencodeMessages((cur) => [...cur, optimistic]);
226
+ setSending(true);
227
+ try {
228
+ const result = await api.post<{ ok: boolean; messageId: string }>(
229
+ `/opencode-sessions/${encodeURIComponent(activeOpencodeSessionId)}/send`,
230
+ { message, agent },
231
+ );
232
+ // SSE echo will dedup on messageId === optimisticId or server-provided id
233
+ void result;
234
+ } catch (err) {
235
+ // Remove optimistic on failure
236
+ setOpencodeMessages((cur) => cur.filter((m) => m.id !== optimisticId));
237
+ toastRef.current?.error(`Send failed: ${(err as Error).message}`);
238
+ } finally {
239
+ setSending(false);
240
+ }
241
+ } else {
242
+ // Default: send to bizar chat
243
+ const optimistic: ChatMessage = {
244
+ role: 'user',
245
+ content: message,
246
+ agent,
247
+ ts: new Date().toISOString(),
248
+ };
249
+ setBizarMessages((cur) => [...cur, optimistic]);
250
+ setSending(true);
251
+ try {
252
+ const response = await api.post<{ messages?: ChatMessage[] }>('/chat', { message, agent, model, attachments: attachments || [] });
253
+ if (response?.messages) {
254
+ for (const msg of response.messages) {
255
+ if (msg.role === 'assistant') {
256
+ setBizarMessages((cur) => {
257
+ const exists = cur.some((m) => m.ts === msg.ts);
258
+ return exists ? cur : [...cur, msg];
259
+ });
260
+ }
108
261
  }
262
+ } else {
263
+ toastRef.current?.info('Still processing… the response will appear shortly.');
109
264
  }
110
- } else {
111
- toastRef.current?.info('Still processing… the response will appear shortly.');
265
+ } catch (err) {
266
+ setBizarMessages((cur) => cur.filter((m) => m !== optimistic));
267
+ toastRef.current?.error(`Send failed: ${(err as Error).message}`);
268
+ } finally {
269
+ setSending(false);
112
270
  }
113
- } catch (err) {
114
- setMessages((cur) => cur.filter((m) => m !== optimistic));
115
- toastRef.current?.error(`Send failed: ${(err as Error).message}`);
116
- } finally {
117
- setSending(false);
118
271
  }
119
- }, []);
272
+ }, [activeSource, activeOpencodeSessionId]);
120
273
 
121
274
  const onRegenerate = useCallback(async (messageId: string) => {
122
275
  if (!sessionId) {
@@ -132,8 +285,12 @@ export function useChat(snapshot: Snapshot, settings: Settings, initialTaskId?:
132
285
  }, [sessionId, loadChat]);
133
286
 
134
287
  const deleteMessage = useCallback((idx: number) => {
135
- setMessages((cur) => cur.filter((_, i) => i !== idx));
136
- }, []);
288
+ if (activeSource === 'opencode') {
289
+ setOpencodeMessages((cur) => cur.filter((_, i) => i !== idx));
290
+ } else {
291
+ setBizarMessages((cur) => cur.filter((_, i) => i !== idx));
292
+ }
293
+ }, [activeSource]);
137
294
 
138
295
  const togglePin = useCallback((idx: number) => {
139
296
  setPinned((cur) => {
@@ -152,7 +309,15 @@ export function useChat(snapshot: Snapshot, settings: Settings, initialTaskId?:
152
309
  );
153
310
  }, []);
154
311
 
155
- // Initial load
312
+ // ── Cleanup SSE on unmount ──────────────────────────────────────────────────
313
+ useEffect(() => {
314
+ return () => {
315
+ opencodeEsRef.current?.close();
316
+ opencodeEsRef.current = null;
317
+ };
318
+ }, []);
319
+
320
+ // ── Initial load ────────────────────────────────────────────────────────────
156
321
  useEffect(() => {
157
322
  if (initialTaskId) {
158
323
  loadTaskChat(initialTaskId);
@@ -163,7 +328,7 @@ export function useChat(snapshot: Snapshot, settings: Settings, initialTaskId?:
163
328
  // eslint-disable-next-line react-hooks/exhaustive-deps
164
329
  }, []);
165
330
 
166
- // Listen for bizar:setChatTask events
331
+ // ── Listen for bizar:setChatTask events ────────────────────────────────────
167
332
  useEffect(() => {
168
333
  const handler = (e: Event) => {
169
334
  const taskId = (e as CustomEvent<{ taskId: string }>).detail?.taskId;
@@ -173,7 +338,7 @@ export function useChat(snapshot: Snapshot, settings: Settings, initialTaskId?:
173
338
  return () => window.removeEventListener('bizar:setChatTask', handler);
174
339
  }, [loadTaskChat]);
175
340
 
176
- // WebSocket for live updates — dynamically imported to avoid SSR issues
341
+ // ── WebSocket for live bizar updates ────────────────────────────────────────
177
342
  useEffect(() => {
178
343
  let closed = false;
179
344
  import('../../lib/ws').then(({ Ws }) => {
@@ -182,7 +347,7 @@ export function useChat(snapshot: Snapshot, settings: Settings, initialTaskId?:
182
347
  ws.on((msg) => {
183
348
  if (msg.type !== 'chat:message') return;
184
349
  const next = msg.message;
185
- setMessages((cur) => {
350
+ setBizarMessages((cur) => {
186
351
  const exists = cur.some((m) => m.ts === next.ts);
187
352
  return exists ? cur : [...cur, next];
188
353
  });
@@ -192,8 +357,18 @@ export function useChat(snapshot: Snapshot, settings: Settings, initialTaskId?:
192
357
  }, []);
193
358
 
194
359
  return {
195
- // State
196
- messages,
360
+ // ── State ────────────────────────────────────────────────────────────────
361
+ /**
362
+ * Messages from the active source.
363
+ * Use `activeSource === 'opencode' ? opencodeMessages : bizarMessages`.
364
+ * Kept as `messages` alias for backward compatibility with components
365
+ * that read `chat.messages` directly.
366
+ */
367
+ messages: activeSource === 'opencode' ? opencodeMessages : bizarMessages,
368
+ /** Messages from the bizar chat stream. */
369
+ bizarMessages,
370
+ /** Messages from the opencode session stream. */
371
+ opencodeMessages,
197
372
  sessions,
198
373
  opencodeSessions,
199
374
  sessionId,
@@ -202,13 +377,24 @@ export function useChat(snapshot: Snapshot, settings: Settings, initialTaskId?:
202
377
  sending,
203
378
  pinned,
204
379
  listRef,
205
- // Setters for toast integration
380
+ // ── Opencode state ──────────────────────────────────────────────────────
381
+ /** Which source is currently displayed: 'bizar', 'opencode', or null. */
382
+ activeSource,
383
+ /** The opencode session ID currently selected, or null. */
384
+ activeOpencodeSessionId,
385
+ // ── Setters for toast integration ───────────────────────────────────────
206
386
  setToast: (toast: typeof toastRef.current) => { toastRef.current = toast; },
207
- // Actions
387
+ // ── Actions ─────────────────────────────────────────────────────────────
208
388
  loadChat,
209
389
  loadTaskChat,
210
390
  refreshSessions,
211
391
  refreshOpencodeSessions,
392
+ /** Load an opencode session's messages and open its SSE stream. */
393
+ loadOpencodeSession,
394
+ /** Close the opencode SSE and fall back to bizar. */
395
+ closeOpencodeSession,
396
+ /** Select a bizar session (closes any opencode SSE first). */
397
+ selectBizarSession,
212
398
  onCreateSession,
213
399
  onSend,
214
400
  onRegenerate,
@@ -216,4 +402,4 @@ export function useChat(snapshot: Snapshot, settings: Settings, initialTaskId?:
216
402
  togglePin,
217
403
  copyMessage,
218
404
  };
219
- }
405
+ }
@@ -302,6 +302,7 @@ export type Overview = {
302
302
  };
303
303
 
304
304
  export type ChatMessage = {
305
+ id?: string;
305
306
  role: 'user' | 'assistant' | 'system' | string;
306
307
  content?: string;
307
308
  message?: string;
@@ -121,19 +121,21 @@ export function MobileChat({ snapshot, settings, setActiveTab, initialTaskId, on
121
121
  sessions={chat.sessions}
122
122
  opencodeSessions={chat.opencodeSessions}
123
123
  activeSessionId={chat.sessionId}
124
+ activeOpencodeSessionId={chat.activeOpencodeSessionId}
124
125
  activeProject={snapshot.activeProject}
125
126
  onCreateSession={handleCreateSession}
126
- onSelectSession={chat.loadChat}
127
+ onSelectSession={chat.selectBizarSession}
128
+ onSelectOpencodeSession={(s) => chat.loadOpencodeSession(s.id)}
127
129
  creating={creating}
128
130
  />
129
131
  </aside>
130
132
 
131
133
  <main className="chat-main">
132
134
  <ChatThread
133
- messages={chat.messages}
135
+ messages={chat.activeSource === 'opencode' ? chat.opencodeMessages : chat.bizarMessages}
134
136
  loading={chat.loading}
135
137
  activeProject={snapshot.activeProject}
136
- sessionId={chat.sessionId}
138
+ sessionId={chat.activeSource === 'opencode' ? (chat.activeOpencodeSessionId ?? chat.sessionId) : chat.sessionId}
137
139
  pinned={chat.pinned}
138
140
  onPickSuggestion={(t) => setText(t)}
139
141
  onCopy={(m) => chat.copyMessage(m as Parameters<typeof chat.copyMessage>[0])}
@@ -164,8 +166,8 @@ export function MobileChat({ snapshot, settings, setActiveTab, initialTaskId, on
164
166
 
165
167
  <aside className={`chat-info ${!infoOpen ? 'chat-info-hidden' : ''}`}>
166
168
  <InfoPanel
167
- sessionId={chat.sessionId}
168
- messages={chat.messages}
169
+ sessionId={chat.activeSource === 'opencode' ? (chat.activeOpencodeSessionId ?? chat.sessionId) : chat.sessionId}
170
+ messages={chat.activeSource === 'opencode' ? chat.opencodeMessages : chat.bizarMessages}
169
171
  pinned={chat.pinned}
170
172
  agent={agent}
171
173
  model={model}
@@ -131,19 +131,21 @@ export function Chat({ snapshot, settings, setActiveTab, initialTaskId, onClearT
131
131
  sessions={chat.sessions}
132
132
  opencodeSessions={chat.opencodeSessions}
133
133
  activeSessionId={chat.sessionId}
134
+ activeOpencodeSessionId={chat.activeOpencodeSessionId}
134
135
  activeProject={snapshot.activeProject}
135
136
  onCreateSession={handleCreateSession}
136
- onSelectSession={chat.loadChat}
137
+ onSelectSession={chat.selectBizarSession}
138
+ onSelectOpencodeSession={(s) => chat.loadOpencodeSession(s.id)}
137
139
  creating={creating}
138
140
  />
139
141
  </aside>
140
142
 
141
143
  <main className="chat-main">
142
144
  <ChatThread
143
- messages={chat.messages}
145
+ messages={chat.activeSource === 'opencode' ? chat.opencodeMessages : chat.bizarMessages}
144
146
  loading={chat.loading}
145
147
  activeProject={snapshot.activeProject}
146
- sessionId={chat.sessionId}
148
+ sessionId={chat.activeSource === 'opencode' ? (chat.activeOpencodeSessionId ?? chat.sessionId) : chat.sessionId}
147
149
  pinned={chat.pinned}
148
150
  onPickSuggestion={(t) => setText(t)}
149
151
  onCopy={(m) => chat.copyMessage(m as Parameters<typeof chat.copyMessage>[0])}
@@ -174,8 +176,8 @@ export function Chat({ snapshot, settings, setActiveTab, initialTaskId, onClearT
174
176
 
175
177
  <aside className="chat-info">
176
178
  <InfoPanel
177
- sessionId={chat.sessionId}
178
- messages={chat.messages}
179
+ sessionId={chat.activeSource === 'opencode' ? (chat.activeOpencodeSessionId ?? chat.sessionId) : chat.sessionId}
180
+ messages={chat.activeSource === 'opencode' ? chat.opencodeMessages : chat.bizarMessages}
179
181
  pinned={chat.pinned}
180
182
  agent={agent}
181
183
  model={model}