@polderlabs/bizar 4.3.0 → 4.4.1

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 (57) hide show
  1. package/bizar-dash/src/server/opencode-sdk.mjs +72 -0
  2. package/bizar-dash/src/server/routes/background.mjs +92 -0
  3. package/bizar-dash/src/server/routes/chat.mjs +300 -123
  4. package/bizar-dash/src/server/routes/opencode-session-detail.mjs +26 -0
  5. package/bizar-dash/src/server/routes/tasks.mjs +59 -1
  6. package/bizar-dash/src/server/task-delegator.mjs +154 -8
  7. package/bizar-dash/src/server/tasks-store.mjs +50 -2
  8. package/bizar-dash/src/web/components/background/AttachButton.tsx +96 -0
  9. package/bizar-dash/src/web/components/background/TmuxAttachCard.tsx +122 -0
  10. package/bizar-dash/src/web/components/chat/AgentNode.tsx +127 -0
  11. package/bizar-dash/src/web/components/chat/AgentTree.tsx +80 -0
  12. package/bizar-dash/src/web/components/chat/ChatComposer.tsx +76 -0
  13. package/bizar-dash/src/web/components/chat/ChatInfoPanel.tsx +144 -0
  14. package/bizar-dash/src/web/components/chat/ChatRail.tsx +387 -0
  15. package/bizar-dash/src/web/components/chat/ChatThread.tsx +28 -7
  16. package/bizar-dash/src/web/components/chat/JumpToLatest.tsx +58 -0
  17. package/bizar-dash/src/web/components/chat/MessageBlock.tsx +260 -0
  18. package/bizar-dash/src/web/components/chat/SessionRowMenu.tsx +206 -0
  19. package/bizar-dash/src/web/components/chat/StreamingIndicator.tsx +33 -5
  20. package/bizar-dash/src/web/components/chat/_legacy.ts +30 -0
  21. package/bizar-dash/src/web/components/chat/index.ts +11 -0
  22. package/bizar-dash/src/web/components/chat/useChat.ts +345 -167
  23. package/bizar-dash/src/web/components/tasks/BacklogPanel.css +109 -0
  24. package/bizar-dash/src/web/components/tasks/BacklogPanel.tsx +209 -0
  25. package/bizar-dash/src/web/styles/chat.css +1536 -133
  26. package/bizar-dash/src/web/views/BackgroundAgents.tsx +3 -0
  27. package/bizar-dash/src/web/views/Chat.tsx +147 -57
  28. package/bizar-dash/src/web/views/Tasks.tsx +23 -1
  29. package/cli/bg.mjs +94 -71
  30. package/cli/bin.mjs +36 -8
  31. package/cli/service-controller.mjs +587 -0
  32. package/cli/service-controller.test.mjs +92 -0
  33. package/cli/service.mjs +162 -14
  34. package/config/agents/baldr.md +2 -0
  35. package/config/agents/browser-harness.md +2 -0
  36. package/config/agents/forseti.md +2 -0
  37. package/config/agents/frigg.md +2 -0
  38. package/config/agents/heimdall.md +2 -0
  39. package/config/agents/hermod.md +2 -0
  40. package/config/agents/mimir.md +2 -0
  41. package/config/agents/odin.md +2 -0
  42. package/config/agents/quick.md +2 -0
  43. package/config/agents/semble-search.md +2 -0
  44. package/config/agents/thor.md +2 -0
  45. package/config/agents/tyr.md +2 -0
  46. package/config/agents/vidarr.md +2 -0
  47. package/config/agents/vor.md +2 -0
  48. package/config/opencode.json.template +1 -0
  49. package/install.sh +448 -787
  50. package/package.json +2 -2
  51. package/packages/sdk/package.json +20 -0
  52. package/packages/sdk/src/client.ts +5 -0
  53. package/packages/sdk/src/errors.ts +11 -2
  54. package/packages/sdk/src/index.ts +19 -0
  55. package/packages/sdk/src/opencode-events.ts +134 -0
  56. package/packages/sdk/src/opencode-types.ts +66 -0
  57. package/packages/sdk/src/opencode.ts +335 -0
@@ -1,9 +1,31 @@
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).
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.
3
12
 
4
13
  import { useCallback, useEffect, useRef, useState } from 'react';
5
14
  import type { ChatMessage, ChatResponse, ChatSession, Settings, Snapshot } from '../../lib/types';
6
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
+ }
7
29
 
8
30
  export function useChat(snapshot: Snapshot, settings: Settings, initialTaskId?: string | null) {
9
31
  // ── Bizar stream ────────────────────────────────────────────────────────────
@@ -21,16 +43,46 @@ export function useChat(snapshot: Snapshot, settings: Settings, initialTaskId?:
21
43
  const [activeOpencodeSessionId, setActiveOpencodeSessionId] = useState<string | null>(null);
22
44
  const opencodeEsRef = useRef<EventSource | null>(null);
23
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
+
24
51
  const listRef = useRef<HTMLDivElement>(null);
25
52
  const toastRef = useRef<{ error: (msg: string) => void; success: (msg: string) => void; info: (msg: string) => void } | null>(null);
26
53
 
27
- // Auto-scroll on new messages (both streams)
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.
28
57
  useEffect(() => {
29
58
  if (listRef.current) {
30
- listRef.current.scrollTop = listRef.current.scrollHeight;
59
+ if (stickToBottom) {
60
+ listRef.current.scrollTop = listRef.current.scrollHeight;
61
+ } else {
62
+ setNewMessageCount((n) => n + 1);
63
+ }
31
64
  }
65
+ // eslint-disable-next-line react-hooks/exhaustive-deps
32
66
  }, [bizarMessages, opencodeMessages]);
33
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
+ );
78
+
79
+ const markSessionStreaming = useCallback(
80
+ (id: string, streaming: boolean) => {
81
+ setSessionDisplayState(id, { state: streaming ? 'streaming' : 'idle' });
82
+ },
83
+ [setSessionDisplayState],
84
+ );
85
+
34
86
  // ── Load bizar chat ─────────────────────────────────────────────────────────
35
87
  const loadChat = useCallback(async (sid?: string) => {
36
88
  try {
@@ -80,100 +132,139 @@ export function useChat(snapshot: Snapshot, settings: Settings, initialTaskId?:
80
132
  }, []);
81
133
 
82
134
  // ── Opencode session: load messages + open SSE ───────────────────────────────
83
- const loadOpencodeSession = useCallback(async (sessionId: string) => {
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) => {
84
141
  // Close any existing opencode SSE
85
142
  opencodeEsRef.current?.close();
86
143
  opencodeEsRef.current = null;
87
144
 
88
145
  try {
89
- const data = await api.get<{ messages: ChatMessage[] }>(`/opencode-sessions/${encodeURIComponent(sessionId)}/messages`);
146
+ const data = await api.get<{ messages: ChatMessage[] }>(`/opencode-sessions/${encodeURIComponent(id)}/messages`);
90
147
  setOpencodeMessages(data.messages || []);
91
148
  } catch (err) {
92
149
  toastRef.current?.error(`Opencode session load failed: ${(err as Error).message}`);
93
150
  setOpencodeMessages([]);
94
151
  }
95
152
 
96
- setActiveOpencodeSessionId(sessionId);
153
+ setActiveOpencodeSessionId(id);
97
154
  setActiveSource('opencode');
98
155
 
99
- // Open SSE EventSource can't set headers; token goes in query string via urlWithToken
156
+ // Build SSE URL with token in query string (EventSource can't set headers).
100
157
  const tok = api.getToken();
101
158
  const baseUrl = tok
102
- ? `/api/opencode-sessions/${encodeURIComponent(sessionId)}/stream?token=${encodeURIComponent(tok)}`
103
- : `/api/opencode-sessions/${encodeURIComponent(sessionId)}/stream`;
159
+ ? `/api/opencode-sessions/${encodeURIComponent(id)}/stream?token=${encodeURIComponent(tok)}`
160
+ : `/api/opencode-sessions/${encodeURIComponent(id)}/stream`;
104
161
  const es = new EventSource(baseUrl);
105
162
  opencodeEsRef.current = es;
106
163
 
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';
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
+ };
130
171
 
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
- }
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;
146
182
  }
183
+ };
147
184
 
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
- })();
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
+ }
157
198
 
158
- const newMsg: ChatMessage = {
159
- id: msgId ?? undefined,
160
- role,
161
- content,
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,
162
232
  ts: new Date().toISOString(),
163
- };
233
+ });
234
+ });
164
235
 
165
- setOpencodeMessages((cur) => {
166
- if (msgId && cur.some((m) => m.id === msgId)) return cur;
167
- return [...cur, newMsg];
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);
168
244
  });
169
- };
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
+ }
170
257
 
171
258
  es.onerror = () => {
172
- // SSE error — silently close; don't spam the user
173
- try { es.close(); } catch { /* noop */ }
259
+ // SSE error — silently close; don't spam the user.
260
+ try {
261
+ es.close();
262
+ } catch {
263
+ /* noop */
264
+ }
174
265
  opencodeEsRef.current = null;
175
266
  };
176
- }, []);
267
+ }, [markSessionStreaming, setSessionDisplayState]);
177
268
 
178
269
  // ── Close opencode session ──────────────────────────────────────────────────
179
270
  const closeOpencodeSession = useCallback(() => {
@@ -185,12 +276,17 @@ export function useChat(snapshot: Snapshot, settings: Settings, initialTaskId?:
185
276
  }, []);
186
277
 
187
278
  // ── Select a bizar session ──────────────────────────────────────────────────
188
- const selectBizarSession = useCallback((sid: string) => {
189
- closeOpencodeSession();
190
- setSessionId(sid);
191
- setActiveSource('bizar');
192
- loadChat(sid);
193
- }, [closeOpencodeSession, loadChat]);
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
+ );
194
290
 
195
291
  // ── Create session ──────────────────────────────────────────────────────────
196
292
  const onCreateSession = useCallback(async () => {
@@ -203,94 +299,112 @@ export function useChat(snapshot: Snapshot, settings: Settings, initialTaskId?:
203
299
  setSessionId(created.id);
204
300
  setBizarMessages([]);
205
301
  setPinned(new Set());
302
+ setSessionDisplayState(created.id, { state: 'idle', unread: 0 });
206
303
  await loadChat(created.id);
207
304
  refreshSessions().catch(() => {});
208
305
  } catch (err) {
209
306
  toastRef.current?.error(`Create failed: ${(err as Error).message}`);
210
307
  }
211
- }, [snapshot.activeProject, loadChat, refreshSessions]);
308
+ }, [snapshot.activeProject, loadChat, refreshSessions, setSessionDisplayState]);
212
309
 
213
310
  // ── Send message ────────────────────────────────────────────────────────────
214
- const onSend = useCallback(async (message: string, agent: string, model: string, attachments?: string[]) => {
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
- });
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);
339
+ }
340
+ } else {
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
+ }
260
366
  }
367
+ } else {
368
+ toastRef.current?.info('Still processing… the response will appear shortly.');
261
369
  }
262
- } else {
263
- toastRef.current?.info('Still processing… the response will appear shortly.');
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);
264
376
  }
377
+ }
378
+ },
379
+ [activeSource, activeOpencodeSessionId, sessionId, markSessionStreaming],
380
+ );
381
+
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);
265
391
  } 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);
392
+ toastRef.current?.error(`Regenerate failed: ${(err as Error).message}`);
270
393
  }
271
- }
272
- }, [activeSource, activeOpencodeSessionId]);
273
-
274
- const onRegenerate = useCallback(async (messageId: string) => {
275
- if (!sessionId) {
276
- toastRef.current?.error('No active session to regenerate.');
277
- return;
278
- }
279
- try {
280
- await api.post('/chat/regenerate', { sessionId, messageId });
281
- await loadChat(sessionId);
282
- } catch (err) {
283
- toastRef.current?.error(`Regenerate failed: ${(err as Error).message}`);
284
- }
285
- }, [sessionId, loadChat]);
286
-
287
- const deleteMessage = useCallback((idx: number) => {
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]);
394
+ },
395
+ [sessionId, loadChat],
396
+ );
397
+
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
+ );
294
408
 
295
409
  const togglePin = useCallback((idx: number) => {
296
410
  setPinned((cur) => {
@@ -309,6 +423,60 @@ export function useChat(snapshot: Snapshot, settings: Settings, initialTaskId?:
309
423
  );
310
424
  }, []);
311
425
 
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
+
312
480
  // ── Cleanup SSE on unmount ──────────────────────────────────────────────────
313
481
  useEffect(() => {
314
482
  return () => {
@@ -353,21 +521,15 @@ export function useChat(snapshot: Snapshot, settings: Settings, initialTaskId?:
353
521
  });
354
522
  });
355
523
  });
356
- return () => { closed = true; };
524
+ return () => {
525
+ closed = true;
526
+ };
357
527
  }, []);
358
528
 
359
529
  return {
360
530
  // ── 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
531
  messages: activeSource === 'opencode' ? opencodeMessages : bizarMessages,
368
- /** Messages from the bizar chat stream. */
369
532
  bizarMessages,
370
- /** Messages from the opencode session stream. */
371
533
  opencodeMessages,
372
534
  sessions,
373
535
  opencodeSessions,
@@ -378,22 +540,36 @@ export function useChat(snapshot: Snapshot, settings: Settings, initialTaskId?:
378
540
  pinned,
379
541
  listRef,
380
542
  // ── Opencode state ──────────────────────────────────────────────────────
381
- /** Which source is currently displayed: 'bizar', 'opencode', or null. */
382
543
  activeSource,
383
- /** The opencode session ID currently selected, or null. */
384
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,
385
562
  // ── Setters for toast integration ───────────────────────────────────────
386
- setToast: (toast: typeof toastRef.current) => { toastRef.current = toast; },
563
+ setToast: (toast: typeof toastRef.current) => {
564
+ toastRef.current = toast;
565
+ },
387
566
  // ── Actions ─────────────────────────────────────────────────────────────
388
567
  loadChat,
389
568
  loadTaskChat,
390
569
  refreshSessions,
391
570
  refreshOpencodeSessions,
392
- /** Load an opencode session's messages and open its SSE stream. */
393
571
  loadOpencodeSession,
394
- /** Close the opencode SSE and fall back to bizar. */
395
572
  closeOpencodeSession,
396
- /** Select a bizar session (closes any opencode SSE first). */
397
573
  selectBizarSession,
398
574
  onCreateSession,
399
575
  onSend,
@@ -401,5 +577,7 @@ export function useChat(snapshot: Snapshot, settings: Settings, initialTaskId?:
401
577
  deleteMessage,
402
578
  togglePin,
403
579
  copyMessage,
580
+ renameSession,
581
+ deleteSession,
404
582
  };
405
- }
583
+ }