@polderlabs/bizar 4.2.4 → 4.4.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.
Files changed (63) hide show
  1. package/bizar-dash/src/server/api.mjs +2 -0
  2. package/bizar-dash/src/server/opencode-sdk.mjs +72 -0
  3. package/bizar-dash/src/server/routes/background.mjs +92 -0
  4. package/bizar-dash/src/server/routes/chat.mjs +300 -123
  5. package/bizar-dash/src/server/routes/opencode-session-detail.mjs +452 -0
  6. package/bizar-dash/src/server/routes/tasks.mjs +59 -1
  7. package/bizar-dash/src/server/serve-info.mjs +124 -1
  8. package/bizar-dash/src/server/task-delegator.mjs +154 -8
  9. package/bizar-dash/src/server/tasks-store.mjs +50 -2
  10. package/bizar-dash/src/web/components/background/AttachButton.tsx +96 -0
  11. package/bizar-dash/src/web/components/background/TmuxAttachCard.tsx +122 -0
  12. package/bizar-dash/src/web/components/chat/AgentNode.tsx +127 -0
  13. package/bizar-dash/src/web/components/chat/AgentTree.tsx +80 -0
  14. package/bizar-dash/src/web/components/chat/ChatComposer.tsx +76 -0
  15. package/bizar-dash/src/web/components/chat/ChatInfoPanel.tsx +144 -0
  16. package/bizar-dash/src/web/components/chat/ChatRail.tsx +387 -0
  17. package/bizar-dash/src/web/components/chat/ChatThread.tsx +28 -7
  18. package/bizar-dash/src/web/components/chat/JumpToLatest.tsx +58 -0
  19. package/bizar-dash/src/web/components/chat/MessageBlock.tsx +260 -0
  20. package/bizar-dash/src/web/components/chat/SessionList.tsx +15 -5
  21. package/bizar-dash/src/web/components/chat/SessionRowMenu.tsx +206 -0
  22. package/bizar-dash/src/web/components/chat/StreamingIndicator.tsx +33 -5
  23. package/bizar-dash/src/web/components/chat/_legacy.ts +30 -0
  24. package/bizar-dash/src/web/components/chat/index.ts +11 -0
  25. package/bizar-dash/src/web/components/chat/useChat.ts +426 -62
  26. package/bizar-dash/src/web/components/tasks/BacklogPanel.css +109 -0
  27. package/bizar-dash/src/web/components/tasks/BacklogPanel.tsx +209 -0
  28. package/bizar-dash/src/web/lib/types.ts +1 -0
  29. package/bizar-dash/src/web/mobile/views/MobileChat.tsx +7 -5
  30. package/bizar-dash/src/web/styles/chat.css +1536 -133
  31. package/bizar-dash/src/web/views/BackgroundAgents.tsx +3 -0
  32. package/bizar-dash/src/web/views/Chat.tsx +147 -55
  33. package/bizar-dash/src/web/views/Tasks.tsx +23 -1
  34. package/bizar-dash/tests/opencode-sessions-detail.test.mjs +273 -0
  35. package/cli/bg.mjs +94 -71
  36. package/cli/bin.mjs +19 -7
  37. package/cli/service-controller.mjs +587 -0
  38. package/cli/service-controller.test.mjs +92 -0
  39. package/cli/service.mjs +162 -14
  40. package/config/agents/baldr.md +2 -0
  41. package/config/agents/browser-harness.md +2 -0
  42. package/config/agents/forseti.md +2 -0
  43. package/config/agents/frigg.md +2 -0
  44. package/config/agents/heimdall.md +2 -0
  45. package/config/agents/hermod.md +2 -0
  46. package/config/agents/mimir.md +2 -0
  47. package/config/agents/odin.md +2 -0
  48. package/config/agents/quick.md +2 -0
  49. package/config/agents/semble-search.md +2 -0
  50. package/config/agents/thor.md +2 -0
  51. package/config/agents/tyr.md +2 -0
  52. package/config/agents/vidarr.md +2 -0
  53. package/config/agents/vor.md +2 -0
  54. package/config/opencode.json.template +1 -0
  55. package/install.sh +448 -787
  56. package/package.json +2 -2
  57. package/packages/sdk/package.json +20 -0
  58. package/packages/sdk/src/client.ts +5 -0
  59. package/packages/sdk/src/errors.ts +11 -2
  60. package/packages/sdk/src/index.ts +19 -0
  61. package/packages/sdk/src/opencode-events.ts +134 -0
  62. package/packages/sdk/src/opencode-types.ts +66 -0
  63. package/packages/sdk/src/opencode.ts +335 -0
@@ -1,11 +1,35 @@
1
- // src/components/chat/useChat.ts — shared chat state: messages, sessions, send, polling.
1
+ // src/components/chat/useChat.ts — shared chat state: messages, sessions, send,
2
+ // polling, SSE, session-state tracking.
3
+ //
4
+ // Supports two parallel message streams:
5
+ // - bizar (GET /api/chat)
6
+ // - opencode (GET /api/opencode-sessions/:id/messages + SSE)
7
+ //
8
+ // v3.22 — extended with per-session state (idle / streaming / awaiting),
9
+ // per-session sub-agent tree, per-session unread count, and jump-to-
10
+ // latest scroll behavior (stickToBottom + newMessageCount). The export
11
+ // shape stays compatible with v3.21; new fields are added.
2
12
 
3
13
  import { useCallback, useEffect, useRef, useState } from 'react';
4
14
  import type { ChatMessage, ChatResponse, ChatSession, Settings, Snapshot } from '../../lib/types';
5
15
  import { api } from '../../lib/api';
16
+ import type { SessionState } from './ChatRail';
17
+ import type { AgentTreeNode } from './AgentNode';
18
+
19
+ export interface SessionDisplayState {
20
+ /** idle / streaming / awaiting — derived from the latest SSE event. */
21
+ state: SessionState;
22
+ /** Number of unread (i.e. arrived while the user wasn't viewing) messages. */
23
+ unread: number;
24
+ /** Pinned? (cosmetic for now; pinned state lives in the parent.) */
25
+ pinned: boolean;
26
+ /** Sub-agent tree, if any. */
27
+ tree?: { root: AgentTreeNode };
28
+ }
6
29
 
7
30
  export function useChat(snapshot: Snapshot, settings: Settings, initialTaskId?: string | null) {
8
- const [messages, setMessages] = useState<ChatMessage[]>([]);
31
+ // ── Bizar stream ────────────────────────────────────────────────────────────
32
+ const [bizarMessages, setBizarMessages] = useState<ChatMessage[]>([]);
9
33
  const [sessions, setSessions] = useState<ChatSession[]>([]);
10
34
  const [opencodeSessions, setOpencodeSessions] = useState<ChatSession[]>([]);
11
35
  const [sessionId, setSessionId] = useState('');
@@ -13,21 +37,58 @@ export function useChat(snapshot: Snapshot, settings: Settings, initialTaskId?:
13
37
  const [sending, setSending] = useState(false);
14
38
  const [pinned, setPinned] = useState<Set<number>>(new Set());
15
39
 
40
+ // ── Opencode stream ─────────────────────────────────────────────────────────
41
+ const [opencodeMessages, setOpencodeMessages] = useState<ChatMessage[]>([]);
42
+ const [activeSource, setActiveSource] = useState<'bizar' | 'opencode' | null>(null);
43
+ const [activeOpencodeSessionId, setActiveOpencodeSessionId] = useState<string | null>(null);
44
+ const opencodeEsRef = useRef<EventSource | null>(null);
45
+
46
+ // ── v3.22 — session-state, unread, tree per session ────────────────────────
47
+ const [sessionStates, setSessionStates] = useState<Record<string, SessionDisplayState>>({});
48
+ const [stickToBottom, setStickToBottom] = useState(true);
49
+ const [newMessageCount, setNewMessageCount] = useState(0);
50
+
16
51
  const listRef = useRef<HTMLDivElement>(null);
17
52
  const toastRef = useRef<{ error: (msg: string) => void; success: (msg: string) => void; info: (msg: string) => void } | null>(null);
18
53
 
19
- // Auto-scroll on new messages
54
+ // Auto-scroll on new messages (both streams) — only when the user is
55
+ // already at the bottom; otherwise count the new messages for the
56
+ // jump-to-latest pill.
20
57
  useEffect(() => {
21
58
  if (listRef.current) {
22
- listRef.current.scrollTop = listRef.current.scrollHeight;
59
+ if (stickToBottom) {
60
+ listRef.current.scrollTop = listRef.current.scrollHeight;
61
+ } else {
62
+ setNewMessageCount((n) => n + 1);
63
+ }
23
64
  }
24
- }, [messages]);
65
+ // eslint-disable-next-line react-hooks/exhaustive-deps
66
+ }, [bizarMessages, opencodeMessages]);
67
+
68
+ // ── Helpers to mutate per-session state ────────────────────────────────────
69
+ const setSessionDisplayState = useCallback(
70
+ (id: string, patch: Partial<SessionDisplayState>) => {
71
+ setSessionStates((cur) => {
72
+ const prev: SessionDisplayState = cur[id] ?? { state: 'idle', unread: 0, pinned: false };
73
+ return { ...cur, [id]: { ...prev, ...patch } };
74
+ });
75
+ },
76
+ [],
77
+ );
25
78
 
79
+ const markSessionStreaming = useCallback(
80
+ (id: string, streaming: boolean) => {
81
+ setSessionDisplayState(id, { state: streaming ? 'streaming' : 'idle' });
82
+ },
83
+ [setSessionDisplayState],
84
+ );
85
+
86
+ // ── Load bizar chat ─────────────────────────────────────────────────────────
26
87
  const loadChat = useCallback(async (sid?: string) => {
27
88
  try {
28
89
  const url = sid ? `/chat?session=${encodeURIComponent(sid)}` : '/chat?limit=200';
29
90
  const data = await api.get<ChatResponse>(url);
30
- setMessages(data.messages || []);
91
+ setBizarMessages(data.messages || []);
31
92
  setSessions(data.sessions || []);
32
93
  } catch (err) {
33
94
  toastRef.current?.error(`Chat load failed: ${(err as Error).message}`);
@@ -40,7 +101,7 @@ export function useChat(snapshot: Snapshot, settings: Settings, initialTaskId?:
40
101
  setLoading(true);
41
102
  try {
42
103
  const data = await api.get<{ sessionId?: string; messages: ChatMessage[] }>(`/tasks/${encodeURIComponent(taskId)}/chat`);
43
- setMessages(data.messages || []);
104
+ setBizarMessages(data.messages || []);
44
105
  setSessionId(data.sessionId || taskId);
45
106
  } catch (err) {
46
107
  toastRef.current?.error(`Task chat load failed: ${(err as Error).message}`);
@@ -70,6 +131,164 @@ export function useChat(snapshot: Snapshot, settings: Settings, initialTaskId?:
70
131
  } catch { /* best-effort */ }
71
132
  }, []);
72
133
 
134
+ // ── Opencode session: load messages + open SSE ───────────────────────────────
135
+ //
136
+ // v3.22 — SSE parsing rewritten to use the browser's native EventSource
137
+ // event-type dispatch instead of manual line-splitting. EventSource
138
+ // already parses "event: <type>" / "data: <json>" frames and dispatches
139
+ // `addEventListener(type, …)` per named event.
140
+ const loadOpencodeSession = useCallback(async (id: string) => {
141
+ // Close any existing opencode SSE
142
+ opencodeEsRef.current?.close();
143
+ opencodeEsRef.current = null;
144
+
145
+ try {
146
+ const data = await api.get<{ messages: ChatMessage[] }>(`/opencode-sessions/${encodeURIComponent(id)}/messages`);
147
+ setOpencodeMessages(data.messages || []);
148
+ } catch (err) {
149
+ toastRef.current?.error(`Opencode session load failed: ${(err as Error).message}`);
150
+ setOpencodeMessages([]);
151
+ }
152
+
153
+ setActiveOpencodeSessionId(id);
154
+ setActiveSource('opencode');
155
+
156
+ // Build SSE URL with token in query string (EventSource can't set headers).
157
+ const tok = api.getToken();
158
+ const baseUrl = tok
159
+ ? `/api/opencode-sessions/${encodeURIComponent(id)}/stream?token=${encodeURIComponent(tok)}`
160
+ : `/api/opencode-sessions/${encodeURIComponent(id)}/stream`;
161
+ const es = new EventSource(baseUrl);
162
+ opencodeEsRef.current = es;
163
+
164
+ // Helper: append a new message to the opencode list (with dedup by id).
165
+ const append = (msg: ChatMessage) => {
166
+ setOpencodeMessages((cur) => {
167
+ if (msg.id && cur.some((m) => m.id === msg.id)) return cur;
168
+ return [...cur, msg];
169
+ });
170
+ };
171
+
172
+ // v3.22 — register handlers per named event type. The server sends
173
+ // `message.user.*`, `message.assistant.*`, `message.part.updated`,
174
+ // `session.*`, and a heartbeat (no event name). Each event's data
175
+ // is a JSON-encoded object.
176
+ const parseData = (raw: string | null): unknown => {
177
+ if (!raw) return null;
178
+ try {
179
+ return JSON.parse(raw);
180
+ } catch {
181
+ return raw;
182
+ }
183
+ };
184
+
185
+ const userEventNames = ['message.user.created', 'message.user.updated'];
186
+ for (const name of userEventNames) {
187
+ es.addEventListener(name, (e: MessageEvent) => {
188
+ const parsed = parseData(e.data) as { messageID?: string; data?: { content?: string } } | null;
189
+ const content = parsed?.data?.content ?? '';
190
+ append({
191
+ id: parsed?.messageID,
192
+ role: 'user',
193
+ content,
194
+ ts: new Date().toISOString(),
195
+ });
196
+ });
197
+ }
198
+
199
+ const assistantEventNames = [
200
+ 'message.assistant.created',
201
+ 'message.assistant.updated',
202
+ ];
203
+ for (const name of assistantEventNames) {
204
+ es.addEventListener(name, (e: MessageEvent) => {
205
+ const parsed = parseData(e.data) as { messageID?: string; data?: { content?: string } } | null;
206
+ const content = parsed?.data?.content ?? '';
207
+ append({
208
+ id: parsed?.messageID,
209
+ role: 'assistant',
210
+ content,
211
+ ts: new Date().toISOString(),
212
+ });
213
+ markSessionStreaming(id, true);
214
+ });
215
+ }
216
+
217
+ es.addEventListener('message.part.updated', (e: MessageEvent) => {
218
+ // Streamed token / chunk. The full message is built from many
219
+ // of these; we append each chunk as its own message for the
220
+ // dashboard's text-only renderer. Dedup by part id.
221
+ const parsed = parseData(e.data) as {
222
+ messageID?: string;
223
+ id?: string;
224
+ data?: { part?: { text?: string } };
225
+ } | null;
226
+ const text = parsed?.data?.part?.text ?? '';
227
+ if (!text) return;
228
+ append({
229
+ id: parsed?.id ?? parsed?.messageID,
230
+ role: 'assistant',
231
+ content: text,
232
+ ts: new Date().toISOString(),
233
+ });
234
+ });
235
+
236
+ const idleEventNames = [
237
+ 'session.idle',
238
+ 'message.assistant.completed',
239
+ 'session.completed',
240
+ ];
241
+ for (const name of idleEventNames) {
242
+ es.addEventListener(name, () => {
243
+ markSessionStreaming(id, false);
244
+ });
245
+ }
246
+
247
+ const awaitingEventNames = [
248
+ 'session.awaiting',
249
+ 'session.awaiting_input',
250
+ 'message.assistant.awaiting',
251
+ ];
252
+ for (const name of awaitingEventNames) {
253
+ es.addEventListener(name, () => {
254
+ setSessionDisplayState(id, { state: 'awaiting' });
255
+ });
256
+ }
257
+
258
+ es.onerror = () => {
259
+ // SSE error — silently close; don't spam the user.
260
+ try {
261
+ es.close();
262
+ } catch {
263
+ /* noop */
264
+ }
265
+ opencodeEsRef.current = null;
266
+ };
267
+ }, [markSessionStreaming, setSessionDisplayState]);
268
+
269
+ // ── Close opencode session ──────────────────────────────────────────────────
270
+ const closeOpencodeSession = useCallback(() => {
271
+ opencodeEsRef.current?.close();
272
+ opencodeEsRef.current = null;
273
+ setOpencodeMessages([]);
274
+ setActiveOpencodeSessionId(null);
275
+ setActiveSource('bizar');
276
+ }, []);
277
+
278
+ // ── Select a bizar session ──────────────────────────────────────────────────
279
+ const selectBizarSession = useCallback(
280
+ (sid: string) => {
281
+ closeOpencodeSession();
282
+ setSessionId(sid);
283
+ setActiveSource('bizar');
284
+ loadChat(sid);
285
+ // Reset unread for the session being opened.
286
+ setSessionDisplayState(sid, { unread: 0 });
287
+ },
288
+ [closeOpencodeSession, loadChat, setSessionDisplayState],
289
+ );
290
+
291
+ // ── Create session ──────────────────────────────────────────────────────────
73
292
  const onCreateSession = useCallback(async () => {
74
293
  if (!snapshot.activeProject) {
75
294
  toastRef.current?.error('Pick a project in Overview to scope chat sessions.');
@@ -78,62 +297,114 @@ export function useChat(snapshot: Snapshot, settings: Settings, initialTaskId?:
78
297
  try {
79
298
  const created = await api.post<ChatSession>('/chat/sessions', {});
80
299
  setSessionId(created.id);
81
- setMessages([]);
300
+ setBizarMessages([]);
82
301
  setPinned(new Set());
302
+ setSessionDisplayState(created.id, { state: 'idle', unread: 0 });
83
303
  await loadChat(created.id);
84
304
  refreshSessions().catch(() => {});
85
305
  } catch (err) {
86
306
  toastRef.current?.error(`Create failed: ${(err as Error).message}`);
87
307
  }
88
- }, [snapshot.activeProject, loadChat, refreshSessions]);
89
-
90
- 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
- });
108
- }
308
+ }, [snapshot.activeProject, loadChat, refreshSessions, setSessionDisplayState]);
309
+
310
+ // ── Send message ────────────────────────────────────────────────────────────
311
+ const onSend = useCallback(
312
+ async (message: string, agent: string, model: string, attachments?: string[]) => {
313
+ if (activeSource === 'opencode' && activeOpencodeSessionId) {
314
+ // Optimistic add
315
+ const optimisticId = `msg_${Date.now()}_${Math.random().toString(36).slice(2)}`;
316
+ const optimistic: ChatMessage = {
317
+ id: optimisticId,
318
+ role: 'user',
319
+ content: message,
320
+ agent,
321
+ ts: new Date().toISOString(),
322
+ };
323
+ setOpencodeMessages((cur) => [...cur, optimistic]);
324
+ setSending(true);
325
+ try {
326
+ const result = await api.post<{ ok: boolean; messageId: string }>(
327
+ `/opencode-sessions/${encodeURIComponent(activeOpencodeSessionId)}/send`,
328
+ { message, agent },
329
+ );
330
+ // SSE echo will dedup on messageId === optimisticId or server-provided id
331
+ void result;
332
+ markSessionStreaming(activeOpencodeSessionId, true);
333
+ } catch (err) {
334
+ // Remove optimistic on failure
335
+ setOpencodeMessages((cur) => cur.filter((m) => m.id !== optimisticId));
336
+ toastRef.current?.error(`Send failed: ${(err as Error).message}`);
337
+ } finally {
338
+ setSending(false);
109
339
  }
110
340
  } else {
111
- toastRef.current?.info('Still processing… the response will appear shortly.');
341
+ // Default: send to bizar chat
342
+ const optimistic: ChatMessage = {
343
+ role: 'user',
344
+ content: message,
345
+ agent,
346
+ ts: new Date().toISOString(),
347
+ };
348
+ setBizarMessages((cur) => [...cur, optimistic]);
349
+ setSending(true);
350
+ markSessionStreaming(sessionId, true);
351
+ try {
352
+ const response = await api.post<{ messages?: ChatMessage[] }>('/chat', {
353
+ message,
354
+ agent,
355
+ model,
356
+ attachments: attachments || [],
357
+ });
358
+ if (response?.messages) {
359
+ for (const msg of response.messages) {
360
+ if (msg.role === 'assistant') {
361
+ setBizarMessages((cur) => {
362
+ const exists = cur.some((m) => m.ts === msg.ts);
363
+ return exists ? cur : [...cur, msg];
364
+ });
365
+ }
366
+ }
367
+ } else {
368
+ toastRef.current?.info('Still processing… the response will appear shortly.');
369
+ }
370
+ } catch (err) {
371
+ setBizarMessages((cur) => cur.filter((m) => m !== optimistic));
372
+ toastRef.current?.error(`Send failed: ${(err as Error).message}`);
373
+ } finally {
374
+ setSending(false);
375
+ markSessionStreaming(sessionId, false);
376
+ }
112
377
  }
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
- }
119
- }, []);
378
+ },
379
+ [activeSource, activeOpencodeSessionId, sessionId, markSessionStreaming],
380
+ );
120
381
 
121
- const onRegenerate = useCallback(async (messageId: string) => {
122
- if (!sessionId) {
123
- toastRef.current?.error('No active session to regenerate.');
124
- return;
125
- }
126
- try {
127
- await api.post('/chat/regenerate', { sessionId, messageId });
128
- await loadChat(sessionId);
129
- } catch (err) {
130
- toastRef.current?.error(`Regenerate failed: ${(err as Error).message}`);
131
- }
132
- }, [sessionId, loadChat]);
382
+ const onRegenerate = useCallback(
383
+ async (messageId: string) => {
384
+ if (!sessionId) {
385
+ toastRef.current?.error('No active session to regenerate.');
386
+ return;
387
+ }
388
+ try {
389
+ await api.post('/chat/regenerate', { sessionId, messageId });
390
+ await loadChat(sessionId);
391
+ } catch (err) {
392
+ toastRef.current?.error(`Regenerate failed: ${(err as Error).message}`);
393
+ }
394
+ },
395
+ [sessionId, loadChat],
396
+ );
133
397
 
134
- const deleteMessage = useCallback((idx: number) => {
135
- setMessages((cur) => cur.filter((_, i) => i !== idx));
136
- }, []);
398
+ const deleteMessage = useCallback(
399
+ (idx: number) => {
400
+ if (activeSource === 'opencode') {
401
+ setOpencodeMessages((cur) => cur.filter((_, i) => i !== idx));
402
+ } else {
403
+ setBizarMessages((cur) => cur.filter((_, i) => i !== idx));
404
+ }
405
+ },
406
+ [activeSource],
407
+ );
137
408
 
138
409
  const togglePin = useCallback((idx: number) => {
139
410
  setPinned((cur) => {
@@ -152,7 +423,69 @@ export function useChat(snapshot: Snapshot, settings: Settings, initialTaskId?:
152
423
  );
153
424
  }, []);
154
425
 
155
- // Initial load
426
+ // ── Jump-to-latest ──────────────────────────────────────────────────────────
427
+ const handleScroll = useCallback(() => {
428
+ const el = listRef.current;
429
+ if (!el) return;
430
+ const distFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
431
+ const atBottom = distFromBottom < 32;
432
+ setStickToBottom(atBottom);
433
+ if (atBottom) setNewMessageCount(0);
434
+ }, []);
435
+
436
+ const jumpToLatest = useCallback(() => {
437
+ const el = listRef.current;
438
+ if (!el) return;
439
+ el.scrollTop = el.scrollHeight;
440
+ setStickToBottom(true);
441
+ setNewMessageCount(0);
442
+ }, []);
443
+
444
+ // ── Session rename / delete (used by the ChatRail row menu) ─────────────────
445
+ const renameSession = useCallback(
446
+ async (id: string, title: string) => {
447
+ try {
448
+ await api.post(`/chat/sessions/${encodeURIComponent(id)}/rename`, { title });
449
+ setSessions((cur) => cur.map((s) => (s.id === id ? { ...s, title } : s)));
450
+ toastRef.current?.success('Renamed.');
451
+ } catch (err) {
452
+ toastRef.current?.error(`Rename failed: ${(err as Error).message}`);
453
+ }
454
+ },
455
+ [],
456
+ );
457
+
458
+ const deleteSession = useCallback(
459
+ async (id: string) => {
460
+ try {
461
+ await api.del(`/chat/sessions/${encodeURIComponent(id)}`);
462
+ setSessions((cur) => cur.filter((s) => s.id !== id));
463
+ setSessionStates((cur) => {
464
+ const next = { ...cur };
465
+ delete next[id];
466
+ return next;
467
+ });
468
+ if (sessionId === id) {
469
+ setSessionId('');
470
+ setBizarMessages([]);
471
+ }
472
+ toastRef.current?.success('Session deleted.');
473
+ } catch (err) {
474
+ toastRef.current?.error(`Delete failed: ${(err as Error).message}`);
475
+ }
476
+ },
477
+ [sessionId],
478
+ );
479
+
480
+ // ── Cleanup SSE on unmount ──────────────────────────────────────────────────
481
+ useEffect(() => {
482
+ return () => {
483
+ opencodeEsRef.current?.close();
484
+ opencodeEsRef.current = null;
485
+ };
486
+ }, []);
487
+
488
+ // ── Initial load ────────────────────────────────────────────────────────────
156
489
  useEffect(() => {
157
490
  if (initialTaskId) {
158
491
  loadTaskChat(initialTaskId);
@@ -163,7 +496,7 @@ export function useChat(snapshot: Snapshot, settings: Settings, initialTaskId?:
163
496
  // eslint-disable-next-line react-hooks/exhaustive-deps
164
497
  }, []);
165
498
 
166
- // Listen for bizar:setChatTask events
499
+ // ── Listen for bizar:setChatTask events ────────────────────────────────────
167
500
  useEffect(() => {
168
501
  const handler = (e: Event) => {
169
502
  const taskId = (e as CustomEvent<{ taskId: string }>).detail?.taskId;
@@ -173,7 +506,7 @@ export function useChat(snapshot: Snapshot, settings: Settings, initialTaskId?:
173
506
  return () => window.removeEventListener('bizar:setChatTask', handler);
174
507
  }, [loadTaskChat]);
175
508
 
176
- // WebSocket for live updates — dynamically imported to avoid SSR issues
509
+ // ── WebSocket for live bizar updates ────────────────────────────────────────
177
510
  useEffect(() => {
178
511
  let closed = false;
179
512
  import('../../lib/ws').then(({ Ws }) => {
@@ -182,18 +515,22 @@ export function useChat(snapshot: Snapshot, settings: Settings, initialTaskId?:
182
515
  ws.on((msg) => {
183
516
  if (msg.type !== 'chat:message') return;
184
517
  const next = msg.message;
185
- setMessages((cur) => {
518
+ setBizarMessages((cur) => {
186
519
  const exists = cur.some((m) => m.ts === next.ts);
187
520
  return exists ? cur : [...cur, next];
188
521
  });
189
522
  });
190
523
  });
191
- return () => { closed = true; };
524
+ return () => {
525
+ closed = true;
526
+ };
192
527
  }, []);
193
528
 
194
529
  return {
195
- // State
196
- messages,
530
+ // ── State ────────────────────────────────────────────────────────────────
531
+ messages: activeSource === 'opencode' ? opencodeMessages : bizarMessages,
532
+ bizarMessages,
533
+ opencodeMessages,
197
534
  sessions,
198
535
  opencodeSessions,
199
536
  sessionId,
@@ -202,18 +539,45 @@ export function useChat(snapshot: Snapshot, settings: Settings, initialTaskId?:
202
539
  sending,
203
540
  pinned,
204
541
  listRef,
205
- // Setters for toast integration
206
- setToast: (toast: typeof toastRef.current) => { toastRef.current = toast; },
207
- // Actions
542
+ // ── Opencode state ──────────────────────────────────────────────────────
543
+ activeSource,
544
+ activeOpencodeSessionId,
545
+ // ── v3.22 — per-session display state ──────────────────────────────────
546
+ sessionStates,
547
+ /** Compute the merged display fields for a session — the rail
548
+ * consumes this to overlay state/unread/tree on the raw
549
+ * ChatSession list. */
550
+ getSessionDisplay: (s: ChatSession) => ({
551
+ ...s,
552
+ state: sessionStates[s.id]?.state ?? 'idle',
553
+ unread: sessionStates[s.id]?.unread ?? 0,
554
+ pinned: sessionStates[s.id]?.pinned ?? false,
555
+ tree: sessionStates[s.id]?.tree,
556
+ }),
557
+ // ── Jump-to-latest ──────────────────────────────────────────────────────
558
+ stickToBottom,
559
+ newMessageCount,
560
+ handleScroll,
561
+ jumpToLatest,
562
+ // ── Setters for toast integration ───────────────────────────────────────
563
+ setToast: (toast: typeof toastRef.current) => {
564
+ toastRef.current = toast;
565
+ },
566
+ // ── Actions ─────────────────────────────────────────────────────────────
208
567
  loadChat,
209
568
  loadTaskChat,
210
569
  refreshSessions,
211
570
  refreshOpencodeSessions,
571
+ loadOpencodeSession,
572
+ closeOpencodeSession,
573
+ selectBizarSession,
212
574
  onCreateSession,
213
575
  onSend,
214
576
  onRegenerate,
215
577
  deleteMessage,
216
578
  togglePin,
217
579
  copyMessage,
580
+ renameSession,
581
+ deleteSession,
218
582
  };
219
583
  }