@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.
- package/bizar-dash/src/server/api.mjs +2 -0
- package/bizar-dash/src/server/opencode-sdk.mjs +72 -0
- package/bizar-dash/src/server/routes/background.mjs +92 -0
- package/bizar-dash/src/server/routes/chat.mjs +300 -123
- package/bizar-dash/src/server/routes/opencode-session-detail.mjs +452 -0
- package/bizar-dash/src/server/routes/tasks.mjs +59 -1
- package/bizar-dash/src/server/serve-info.mjs +124 -1
- package/bizar-dash/src/server/task-delegator.mjs +154 -8
- package/bizar-dash/src/server/tasks-store.mjs +50 -2
- package/bizar-dash/src/web/components/background/AttachButton.tsx +96 -0
- package/bizar-dash/src/web/components/background/TmuxAttachCard.tsx +122 -0
- package/bizar-dash/src/web/components/chat/AgentNode.tsx +127 -0
- package/bizar-dash/src/web/components/chat/AgentTree.tsx +80 -0
- package/bizar-dash/src/web/components/chat/ChatComposer.tsx +76 -0
- package/bizar-dash/src/web/components/chat/ChatInfoPanel.tsx +144 -0
- package/bizar-dash/src/web/components/chat/ChatRail.tsx +387 -0
- package/bizar-dash/src/web/components/chat/ChatThread.tsx +28 -7
- package/bizar-dash/src/web/components/chat/JumpToLatest.tsx +58 -0
- package/bizar-dash/src/web/components/chat/MessageBlock.tsx +260 -0
- package/bizar-dash/src/web/components/chat/SessionList.tsx +15 -5
- package/bizar-dash/src/web/components/chat/SessionRowMenu.tsx +206 -0
- package/bizar-dash/src/web/components/chat/StreamingIndicator.tsx +33 -5
- package/bizar-dash/src/web/components/chat/_legacy.ts +30 -0
- package/bizar-dash/src/web/components/chat/index.ts +11 -0
- package/bizar-dash/src/web/components/chat/useChat.ts +426 -62
- package/bizar-dash/src/web/components/tasks/BacklogPanel.css +109 -0
- package/bizar-dash/src/web/components/tasks/BacklogPanel.tsx +209 -0
- package/bizar-dash/src/web/lib/types.ts +1 -0
- package/bizar-dash/src/web/mobile/views/MobileChat.tsx +7 -5
- package/bizar-dash/src/web/styles/chat.css +1536 -133
- package/bizar-dash/src/web/views/BackgroundAgents.tsx +3 -0
- package/bizar-dash/src/web/views/Chat.tsx +147 -55
- package/bizar-dash/src/web/views/Tasks.tsx +23 -1
- package/bizar-dash/tests/opencode-sessions-detail.test.mjs +273 -0
- package/cli/bg.mjs +94 -71
- package/cli/bin.mjs +19 -7
- package/cli/service-controller.mjs +587 -0
- package/cli/service-controller.test.mjs +92 -0
- package/cli/service.mjs +162 -14
- package/config/agents/baldr.md +2 -0
- package/config/agents/browser-harness.md +2 -0
- package/config/agents/forseti.md +2 -0
- package/config/agents/frigg.md +2 -0
- package/config/agents/heimdall.md +2 -0
- package/config/agents/hermod.md +2 -0
- package/config/agents/mimir.md +2 -0
- package/config/agents/odin.md +2 -0
- package/config/agents/quick.md +2 -0
- package/config/agents/semble-search.md +2 -0
- package/config/agents/thor.md +2 -0
- package/config/agents/tyr.md +2 -0
- package/config/agents/vidarr.md +2 -0
- package/config/agents/vor.md +2 -0
- package/config/opencode.json.template +1 -0
- package/install.sh +448 -787
- package/package.json +2 -2
- package/packages/sdk/package.json +20 -0
- package/packages/sdk/src/client.ts +5 -0
- package/packages/sdk/src/errors.ts +11 -2
- package/packages/sdk/src/index.ts +19 -0
- package/packages/sdk/src/opencode-events.ts +134 -0
- package/packages/sdk/src/opencode-types.ts +66 -0
- package/packages/sdk/src/opencode.ts +335 -0
|
@@ -1,11 +1,35 @@
|
|
|
1
|
-
// src/components/chat/useChat.ts — shared chat state: messages, sessions, send,
|
|
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
|
-
|
|
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
|
-
|
|
59
|
+
if (stickToBottom) {
|
|
60
|
+
listRef.current.scrollTop = listRef.current.scrollHeight;
|
|
61
|
+
} else {
|
|
62
|
+
setNewMessageCount((n) => n + 1);
|
|
63
|
+
}
|
|
23
64
|
}
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
} finally {
|
|
117
|
-
setSending(false);
|
|
118
|
-
}
|
|
119
|
-
}, []);
|
|
378
|
+
},
|
|
379
|
+
[activeSource, activeOpencodeSessionId, sessionId, markSessionStreaming],
|
|
380
|
+
);
|
|
120
381
|
|
|
121
|
-
const onRegenerate = useCallback(
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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(
|
|
135
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
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 () => {
|
|
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
|
-
//
|
|
206
|
-
|
|
207
|
-
|
|
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
|
}
|