@polderlabs/bizar 4.3.0 → 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/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 +26 -0
- package/bizar-dash/src/server/routes/tasks.mjs +59 -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/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 +345 -167
- 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/styles/chat.css +1536 -133
- package/bizar-dash/src/web/views/BackgroundAgents.tsx +3 -0
- package/bizar-dash/src/web/views/Chat.tsx +147 -57
- package/bizar-dash/src/web/views/Tasks.tsx +23 -1
- 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 +1 -1
- 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,9 +1,31 @@
|
|
|
1
|
-
// src/components/chat/useChat.ts — shared chat state: messages, sessions, send,
|
|
2
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
153
|
+
setActiveOpencodeSessionId(id);
|
|
97
154
|
setActiveSource('opencode');
|
|
98
155
|
|
|
99
|
-
//
|
|
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(
|
|
103
|
-
: `/api/opencode-sessions/${encodeURIComponent(
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
}
|
|
140
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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 {
|
|
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(
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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(
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
}
|
|
263
|
-
|
|
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
|
-
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
}
|
|
283
|
-
|
|
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 () => {
|
|
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) => {
|
|
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
|
+
}
|