@polderlabs/bizar 4.4.13 → 4.5.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.
- package/bizar-dash/CHANGELOG.md +37 -276
- package/bizar-dash/dist/assets/__vite-browser-external-BIHI7g3E.js +2 -0
- package/bizar-dash/dist/assets/__vite-browser-external-BIHI7g3E.js.map +1 -0
- package/bizar-dash/dist/assets/main-eWZ4NlCL.css +1 -0
- package/bizar-dash/dist/assets/main-usWhlPWa.js +362 -0
- package/bizar-dash/dist/assets/main-usWhlPWa.js.map +1 -0
- package/bizar-dash/dist/assets/{mobile-DSb-t42Y.js → mobile-DQLFCjwJ.js} +2 -2
- package/bizar-dash/dist/assets/{mobile-DSb-t42Y.js.map → mobile-DQLFCjwJ.js.map} +1 -1
- package/bizar-dash/dist/assets/mobile-O6ANdD4W.js +352 -0
- package/bizar-dash/dist/assets/mobile-O6ANdD4W.js.map +1 -0
- package/bizar-dash/dist/index.html +3 -3
- package/bizar-dash/dist/mobile.html +2 -2
- package/bizar-dash/skills/agent-baseline/SKILL.md +80 -0
- package/bizar-dash/skills/bizar/SKILL.md +116 -0
- package/bizar-dash/skills/chat/SKILL.md +74 -0
- package/bizar-dash/skills/headroom/SKILL.md +94 -0
- package/bizar-dash/skills/lightrag/SKILL.md +86 -0
- package/bizar-dash/skills/minimax/SKILL.md +80 -0
- package/bizar-dash/skills/obsidian/SKILL.md +68 -0
- package/bizar-dash/skills/providers/SKILL.md +75 -0
- package/bizar-dash/skills/sdk/SKILL.md +138 -0
- package/bizar-dash/skills/self-improvement/SKILL.md +53 -0
- package/bizar-dash/skills/skills-cli/SKILL.md +94 -0
- package/bizar-dash/skills/usage/SKILL.md +62 -0
- package/bizar-dash/src/server/api.mjs +18 -0
- package/bizar-dash/src/server/headroom.mjs +645 -0
- package/bizar-dash/src/server/memory-lightrag.mjs +272 -2
- package/bizar-dash/src/server/memory-obsidian.mjs +230 -0
- package/bizar-dash/src/server/memory-store.mjs +189 -0
- package/bizar-dash/src/server/minimax-usage-store.mjs +372 -0
- package/bizar-dash/src/server/minimax.mjs +196 -5
- package/bizar-dash/src/server/providers-store.mjs +956 -0
- package/bizar-dash/src/server/routes/_shared.mjs +17 -0
- package/bizar-dash/src/server/routes/config.mjs +52 -1
- package/bizar-dash/src/server/routes/env-vars.mjs +165 -0
- package/bizar-dash/src/server/routes/headroom.mjs +126 -0
- package/bizar-dash/src/server/routes/lightrag.mjs +154 -0
- package/bizar-dash/src/server/routes/memory.mjs +668 -1
- package/bizar-dash/src/server/routes/opencode-session-detail.mjs +14 -29
- package/bizar-dash/src/server/routes/opencode-sessions.mjs +205 -3
- package/bizar-dash/src/server/routes/providers.mjs +266 -5
- package/bizar-dash/src/server/routes/skills.mjs +32 -43
- package/bizar-dash/src/server/routes/update.mjs +340 -0
- package/bizar-dash/src/server/routes/usage.mjs +136 -0
- package/bizar-dash/src/server/serve-info.mjs +135 -4
- package/bizar-dash/src/server/server.mjs +20 -0
- package/bizar-dash/src/server/skills-store.mjs +152 -262
- package/bizar-dash/src/web/App.tsx +120 -29
- package/bizar-dash/src/web/components/EnvVarManager.tsx +247 -0
- package/bizar-dash/src/web/components/HeadroomSettings.tsx +418 -0
- package/bizar-dash/src/web/components/HeadroomStatus.tsx +158 -0
- package/bizar-dash/src/web/components/SettingsSearch.tsx +213 -0
- package/bizar-dash/src/web/components/Topbar.tsx +2 -1
- package/bizar-dash/src/web/components/UsageChart.tsx +250 -0
- package/bizar-dash/src/web/components/UsageTable.tsx +90 -0
- package/bizar-dash/src/web/components/chat/ChatComposer.tsx +21 -25
- package/bizar-dash/src/web/components/chat/ChatInfoPanel.tsx +199 -37
- package/bizar-dash/src/web/components/chat/ChatThread.tsx +29 -17
- package/bizar-dash/src/web/components/chat/FloatingComposer.tsx +7 -1
- package/bizar-dash/src/web/components/chat/InfoPanel.tsx +71 -6
- package/bizar-dash/src/web/components/chat/useChat.ts +751 -257
- package/bizar-dash/src/web/lib/api.ts +43 -0
- package/bizar-dash/src/web/lib/types.ts +16 -0
- package/bizar-dash/src/web/main.tsx +2 -0
- package/bizar-dash/src/web/mobile/views/MobileChat.tsx +110 -35
- package/bizar-dash/src/web/styles/chat.css +135 -1
- package/bizar-dash/src/web/styles/main.css +46 -0
- package/bizar-dash/src/web/styles/memory.css +955 -0
- package/bizar-dash/src/web/styles/minimax-usage.css +335 -0
- package/bizar-dash/src/web/styles/settings.css +418 -0
- package/bizar-dash/src/web/styles/skills.css +302 -0
- package/bizar-dash/src/web/styles/tasks.css +288 -0
- package/bizar-dash/src/web/views/Chat.tsx +276 -48
- package/bizar-dash/src/web/views/Config.tsx +3 -2065
- package/bizar-dash/src/web/views/Memory.tsx +140 -0
- package/bizar-dash/src/web/views/MiniMaxUsage.tsx +476 -461
- package/bizar-dash/src/web/views/Overview.tsx +3 -0
- package/bizar-dash/src/web/views/Settings.tsx +36 -0
- package/bizar-dash/src/web/views/Skills.tsx +208 -260
- package/bizar-dash/src/web/views/Tasks.tsx +348 -1119
- package/bizar-dash/src/web/views/memory/ConfigPanel.tsx +289 -0
- package/bizar-dash/src/web/views/memory/GitSyncPanel.tsx +220 -0
- package/bizar-dash/src/web/views/memory/LightragPanel.tsx +307 -0
- package/bizar-dash/src/web/views/memory/MemoryOverview.tsx +354 -0
- package/bizar-dash/src/web/views/memory/MemoryStatusCard.tsx +160 -0
- package/bizar-dash/src/web/views/memory/ObsidianPanel.tsx +642 -0
- package/bizar-dash/src/web/views/memory/SemanticSearchPanel.tsx +194 -0
- package/bizar-dash/tests/chat-session-create.test.mjs +391 -0
- package/bizar-dash/tests/chat-session-stream.test.mjs +308 -0
- package/bizar-dash/tests/env-vars-store.test.mjs +216 -0
- package/bizar-dash/tests/headroom-install.test.mjs +173 -0
- package/bizar-dash/tests/headroom-settings.test.mjs +126 -0
- package/bizar-dash/tests/headroom-status.test.mjs +117 -0
- package/bizar-dash/tests/lightrag-defaults.node.test.mjs +118 -0
- package/bizar-dash/tests/memory-lightrag-extended.test.mjs +162 -0
- package/bizar-dash/tests/memory-obsidian.test.mjs +269 -0
- package/bizar-dash/tests/memory-tab.test.mjs +322 -0
- package/bizar-dash/tests/minimax-chat-usage.test.mjs +178 -0
- package/bizar-dash/tests/minimax-usage-store.node.test.mjs +293 -0
- package/bizar-dash/tests/mod-upgrade.node.test.mjs +1 -1
- package/bizar-dash/tests/opencode-sessions-detail.test.mjs +12 -9
- package/bizar-dash/tests/providers-store-backup-keys.node.test.mjs +479 -3
- package/bizar-dash/tests/providers-store-search.node.test.mjs +166 -0
- package/bizar-dash/tests/skills-list.test.mjs +232 -0
- package/bizar-dash/tests/skills-search.test.mjs +222 -0
- package/bizar-dash/tests/submit-feedback.test.mjs +6 -6
- package/bizar-dash/tests/tasks-create.test.mjs +187 -0
- package/bizar-dash/tests/update-check.test.mjs +127 -0
- package/bizar-dash/tests/update-run.test.mjs +266 -0
- package/cli/bin.mjs +247 -1
- package/cli/provision.mjs +118 -4
- package/config/agents/_shared/SKILLS.md +109 -0
- package/package.json +1 -1
- package/bizar-dash/dist/assets/main-BB5mJurD.js +0 -352
- package/bizar-dash/dist/assets/main-BB5mJurD.js.map +0 -1
- package/bizar-dash/dist/assets/main-BsnQLXdh.css +0 -1
- package/bizar-dash/dist/assets/mobile-Dl1q7Cyq.js +0 -354
- package/bizar-dash/dist/assets/mobile-Dl1q7Cyq.js.map +0 -1
|
@@ -1,18 +1,42 @@
|
|
|
1
|
-
// src/components/chat/useChat.ts — shared chat state: messages, sessions,
|
|
2
|
-
// polling, SSE, session-state tracking.
|
|
1
|
+
// src/components/chat/useChat.ts — shared chat state: messages, sessions,
|
|
2
|
+
// send, polling, SSE, session-state tracking.
|
|
3
3
|
//
|
|
4
|
-
// Supports two parallel message streams:
|
|
4
|
+
// v4.2.5 — Chat overhaul. Supports two parallel message streams:
|
|
5
5
|
// - bizar (GET /api/chat)
|
|
6
6
|
// - opencode (GET /api/opencode-sessions/:id/messages + SSE)
|
|
7
7
|
//
|
|
8
|
-
// v3.22
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
8
|
+
// Changes from v3.22:
|
|
9
|
+
// * SSE has automatic reconnect-with-backoff (1s → 30s, doubled on
|
|
10
|
+
// each error up to a cap). Closes cleanly on unmount and on
|
|
11
|
+
// session-switch (no leaked EventSource).
|
|
12
|
+
// * Optimistic send uses a stable `msg_*` ID and is deduped when
|
|
13
|
+
// opencode echoes the message back via SSE `message.updated`/`message.user.*`.
|
|
14
|
+
// * `opencodeMessages` keeps server-provided IDs distinct from
|
|
15
|
+
// optimistic IDs so the merge is unambiguous (server echo
|
|
16
|
+
// replaces optimistic by matching messageID; otherwise both stick
|
|
17
|
+
// around briefly until the user/session state catches up).
|
|
18
|
+
// * `onSend` routes to `/opencode-sessions/:id/send` when an
|
|
19
|
+
// opencode session is active, `/chat` otherwise. Errors surface
|
|
20
|
+
// via toast + we always remove the optimistic placeholder.
|
|
21
|
+
// * Sessions are managed by source:
|
|
22
|
+
// - `bizarSessions` → local .jsonl store, POST /chat/sessions
|
|
23
|
+
// - `opencodeSessions` → opencode serve, GET /opencode-sessions
|
|
24
|
+
// The rail shows both side-by-side with a source indicator and
|
|
25
|
+
// uses separate rename/delete endpoints per source.
|
|
26
|
+
// * All async actions surface a single `busy: { create, send, rename, delete }`
|
|
27
|
+
// so the rail can disable buttons properly.
|
|
28
|
+
// * No `any` leaks — all SSE event shapes are typed via
|
|
29
|
+
// `OpencodeSseEnvelope`.
|
|
12
30
|
|
|
13
31
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
14
|
-
import type {
|
|
15
|
-
|
|
32
|
+
import type {
|
|
33
|
+
ChatMessage,
|
|
34
|
+
ChatResponse,
|
|
35
|
+
ChatSession,
|
|
36
|
+
Settings,
|
|
37
|
+
Snapshot,
|
|
38
|
+
} from '../../lib/types';
|
|
39
|
+
import { api, ApiError } from '../../lib/api';
|
|
16
40
|
import type { SessionState } from './ChatRail';
|
|
17
41
|
import type { AgentTreeNode } from './AgentNode';
|
|
18
42
|
|
|
@@ -27,49 +51,129 @@ export interface SessionDisplayState {
|
|
|
27
51
|
tree?: { root: AgentTreeNode };
|
|
28
52
|
}
|
|
29
53
|
|
|
54
|
+
/** Per-source action busy flags. Used by rail/info-panel to disable buttons. */
|
|
55
|
+
export interface ChatBusyState {
|
|
56
|
+
create: boolean;
|
|
57
|
+
send: boolean;
|
|
58
|
+
rename: boolean;
|
|
59
|
+
delete: boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Shape of one opencode SSE envelope after unwrapping. */
|
|
63
|
+
interface OpencodeSseEnvelope {
|
|
64
|
+
type: string;
|
|
65
|
+
sessionID?: string;
|
|
66
|
+
messageID?: string;
|
|
67
|
+
/** Raw upstream payload (kept for forwarders that want full fidelity). */
|
|
68
|
+
data?: unknown;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The chat hook returns a stable bag of state + actions used by
|
|
73
|
+
* both Chat.tsx (desktop) and MobileChat.tsx (mobile).
|
|
74
|
+
*
|
|
75
|
+
* The hook owns the lifecycle of the opencode SSE stream. Callers
|
|
76
|
+
* should never instantiate their own EventSource for the same
|
|
77
|
+
* session — they should always go through this hook.
|
|
78
|
+
*/
|
|
30
79
|
export function useChat(snapshot: Snapshot, settings: Settings, initialTaskId?: string | null) {
|
|
31
80
|
// ── Bizar stream ────────────────────────────────────────────────────────────
|
|
32
81
|
const [bizarMessages, setBizarMessages] = useState<ChatMessage[]>([]);
|
|
33
82
|
const [sessions, setSessions] = useState<ChatSession[]>([]);
|
|
34
|
-
const [opencodeSessions, setOpencodeSessions] = useState<ChatSession[]>([]);
|
|
35
83
|
const [sessionId, setSessionId] = useState('');
|
|
36
84
|
const [loading, setLoading] = useState(true);
|
|
37
|
-
const [
|
|
38
|
-
|
|
85
|
+
const [busy, setBusy] = useState<ChatBusyState>({
|
|
86
|
+
create: false,
|
|
87
|
+
send: false,
|
|
88
|
+
rename: false,
|
|
89
|
+
delete: false,
|
|
90
|
+
});
|
|
39
91
|
|
|
40
92
|
// ── Opencode stream ─────────────────────────────────────────────────────────
|
|
93
|
+
const [opencodeSessions, setOpencodeSessions] = useState<ChatSession[]>([]);
|
|
41
94
|
const [opencodeMessages, setOpencodeMessages] = useState<ChatMessage[]>([]);
|
|
42
95
|
const [activeSource, setActiveSource] = useState<'bizar' | 'opencode' | null>(null);
|
|
43
96
|
const [activeOpencodeSessionId, setActiveOpencodeSessionId] = useState<string | null>(null);
|
|
44
|
-
const
|
|
97
|
+
const [opencodeError, setOpencodeError] = useState<string | null>(null);
|
|
45
98
|
|
|
46
99
|
// ── v3.22 — session-state, unread, tree per session ────────────────────────
|
|
47
100
|
const [sessionStates, setSessionStates] = useState<Record<string, SessionDisplayState>>({});
|
|
48
101
|
const [stickToBottom, setStickToBottom] = useState(true);
|
|
49
102
|
const [newMessageCount, setNewMessageCount] = useState(0);
|
|
50
103
|
|
|
104
|
+
// Set of messageIDs the user has already accepted (or that arrived
|
|
105
|
+
// via SSE) — used by the opencode path to dedupe optimistic + echo.
|
|
106
|
+
const [seenOpencodeMessages, setSeenOpencodeMessages] = useState<Set<string>>(new Set());
|
|
107
|
+
|
|
108
|
+
// Backwards-compat alias (chat.sendSession delete etc. used `pinned` historically).
|
|
109
|
+
const [pinned, setPinned] = useState<Set<number>>(new Set());
|
|
110
|
+
|
|
51
111
|
const listRef = useRef<HTMLDivElement>(null);
|
|
52
|
-
const toastRef = useRef<
|
|
112
|
+
const toastRef = useRef<
|
|
113
|
+
| {
|
|
114
|
+
error: (msg: string) => void;
|
|
115
|
+
success: (msg: string) => void;
|
|
116
|
+
info: (msg: string) => void;
|
|
117
|
+
warning: (msg: string) => void;
|
|
118
|
+
}
|
|
119
|
+
| null
|
|
120
|
+
>(null);
|
|
53
121
|
|
|
54
|
-
//
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
122
|
+
// ── SSE lifecycle refs ──────────────────────────────────────────────────────
|
|
123
|
+
const opencodeEsRef = useRef<EventSource | null>(null);
|
|
124
|
+
const sseReconnectAttemptRef = useRef(0);
|
|
125
|
+
const sseReconnectTimerRef = useRef<number | null>(null);
|
|
126
|
+
// The id of the session we're CURRENTLY streaming. When the user
|
|
127
|
+
// switches sessions, this id changes and the SSE handler short-
|
|
128
|
+
// circuits any in-flight appends.
|
|
129
|
+
const sseCurrentSessionIdRef = useRef<string | null>(null);
|
|
130
|
+
// Whether the SSE connection should be auto-reconnected on error.
|
|
131
|
+
// Set to false on explicit disconnect (session switch / unmount).
|
|
132
|
+
const sseAutoReconnectRef = useRef(false);
|
|
133
|
+
|
|
134
|
+
/** Append `msg` to `opencodeMessages`, replacing any prior
|
|
135
|
+
* optimistic entry whose content matches or whose ID matches. */
|
|
136
|
+
const appendOpencodeMessage = useCallback((msg: ChatMessage) => {
|
|
137
|
+
if (!msg) return;
|
|
138
|
+
setOpencodeMessages((cur) => {
|
|
139
|
+
// Existing exact-ID match: skip.
|
|
140
|
+
if (msg.id && cur.some((m) => m.id === msg.id)) return cur;
|
|
141
|
+
// Optimistic placeholder (starts with msg_) with the same role
|
|
142
|
+
// and content: replace it. This is how opencode's echo of our
|
|
143
|
+
// own message gets deduplicated against the optimistic copy.
|
|
144
|
+
const idx = cur.findIndex(
|
|
145
|
+
(m) =>
|
|
146
|
+
typeof m.id === 'string' &&
|
|
147
|
+
m.id.startsWith('msg_') &&
|
|
148
|
+
m.role === msg.role &&
|
|
149
|
+
(m.content || '') === (msg.content || ''),
|
|
150
|
+
);
|
|
151
|
+
if (idx >= 0) {
|
|
152
|
+
const next = cur.slice();
|
|
153
|
+
next[idx] = { ...msg, id: msg.id ?? cur[idx].id };
|
|
154
|
+
return next;
|
|
63
155
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
}, [
|
|
156
|
+
return [...cur, msg];
|
|
157
|
+
});
|
|
158
|
+
}, []);
|
|
159
|
+
|
|
160
|
+
/** Mark a messageID as seen — used to dedupe echo rounds. */
|
|
161
|
+
const markSeen = useCallback((id: string | undefined) => {
|
|
162
|
+
if (!id) return;
|
|
163
|
+
setSeenOpencodeMessages((cur) => {
|
|
164
|
+
if (cur.has(id)) return cur;
|
|
165
|
+
const next = new Set(cur);
|
|
166
|
+
next.add(id);
|
|
167
|
+
return next;
|
|
168
|
+
});
|
|
169
|
+
}, []);
|
|
67
170
|
|
|
68
171
|
// ── Helpers to mutate per-session state ────────────────────────────────────
|
|
69
172
|
const setSessionDisplayState = useCallback(
|
|
70
173
|
(id: string, patch: Partial<SessionDisplayState>) => {
|
|
71
174
|
setSessionStates((cur) => {
|
|
72
|
-
const prev: SessionDisplayState =
|
|
175
|
+
const prev: SessionDisplayState =
|
|
176
|
+
cur[id] ?? { state: 'idle', unread: 0, pinned: false };
|
|
73
177
|
return { ...cur, [id]: { ...prev, ...patch } };
|
|
74
178
|
});
|
|
75
179
|
},
|
|
@@ -83,28 +187,48 @@ export function useChat(snapshot: Snapshot, settings: Settings, initialTaskId?:
|
|
|
83
187
|
[setSessionDisplayState],
|
|
84
188
|
);
|
|
85
189
|
|
|
190
|
+
/** ─── Toast binding (set by the consuming view via `setToast`) ──── */
|
|
191
|
+
const setToast = useCallback(
|
|
192
|
+
(toast: typeof toastRef.current) => {
|
|
193
|
+
toastRef.current = toast;
|
|
194
|
+
},
|
|
195
|
+
[],
|
|
196
|
+
);
|
|
197
|
+
|
|
86
198
|
// ── Load bizar chat ─────────────────────────────────────────────────────────
|
|
87
|
-
const loadChat = useCallback(
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
199
|
+
const loadChat = useCallback(
|
|
200
|
+
async (sid?: string) => {
|
|
201
|
+
try {
|
|
202
|
+
const url = sid
|
|
203
|
+
? `/chat?session=${encodeURIComponent(sid)}`
|
|
204
|
+
: '/chat?limit=200';
|
|
205
|
+
const data = await api.get<ChatResponse>(url);
|
|
206
|
+
setBizarMessages(data.messages || []);
|
|
207
|
+
setSessions(data.sessions || []);
|
|
208
|
+
} catch (err) {
|
|
209
|
+
toastRef.current?.error(
|
|
210
|
+
`Chat load failed: ${(err as Error).message}`,
|
|
211
|
+
);
|
|
212
|
+
} finally {
|
|
213
|
+
setLoading(false);
|
|
214
|
+
}
|
|
215
|
+
},
|
|
216
|
+
[],
|
|
217
|
+
);
|
|
99
218
|
|
|
100
219
|
const loadTaskChat = useCallback(async (taskId: string) => {
|
|
101
220
|
setLoading(true);
|
|
102
221
|
try {
|
|
103
|
-
const data = await api.get<{
|
|
222
|
+
const data = await api.get<{
|
|
223
|
+
sessionId?: string;
|
|
224
|
+
messages: ChatMessage[];
|
|
225
|
+
}>(`/tasks/${encodeURIComponent(taskId)}/chat`);
|
|
104
226
|
setBizarMessages(data.messages || []);
|
|
105
227
|
setSessionId(data.sessionId || taskId);
|
|
106
228
|
} catch (err) {
|
|
107
|
-
toastRef.current?.error(
|
|
229
|
+
toastRef.current?.error(
|
|
230
|
+
`Task chat load failed: ${(err as Error).message}`,
|
|
231
|
+
);
|
|
108
232
|
} finally {
|
|
109
233
|
setLoading(false);
|
|
110
234
|
}
|
|
@@ -114,167 +238,367 @@ export function useChat(snapshot: Snapshot, settings: Settings, initialTaskId?:
|
|
|
114
238
|
try {
|
|
115
239
|
const data = await api.get<{ sessions: ChatSession[] }>('/chat/sessions');
|
|
116
240
|
setSessions(data.sessions || []);
|
|
117
|
-
} catch {
|
|
241
|
+
} catch {
|
|
242
|
+
/* best-effort */
|
|
243
|
+
}
|
|
118
244
|
}, []);
|
|
119
245
|
|
|
120
246
|
const refreshOpencodeSessions = useCallback(async () => {
|
|
121
247
|
try {
|
|
122
|
-
const data = await api.get<{ sessions: ChatSession[] }>(
|
|
123
|
-
|
|
248
|
+
const data = await api.get<{ sessions: ChatSession[] }>(
|
|
249
|
+
'/opencode-sessions',
|
|
250
|
+
);
|
|
251
|
+
// Enrich with source marker.
|
|
124
252
|
const enriched: ChatSession[] = (data.sessions || []).map((s) => ({
|
|
125
253
|
...s,
|
|
126
254
|
source: 'opencode' as const,
|
|
127
|
-
opencodeUrl: `/opencode/session/${s.id}`,
|
|
128
255
|
title: s.title || s.id,
|
|
129
256
|
}));
|
|
130
257
|
setOpencodeSessions(enriched);
|
|
131
|
-
} catch {
|
|
258
|
+
} catch {
|
|
259
|
+
/* best-effort */
|
|
260
|
+
}
|
|
132
261
|
}, []);
|
|
133
262
|
|
|
134
|
-
// ── Opencode
|
|
263
|
+
// ── Opencode SSE connection ─────────────────────────────────────────────────
|
|
135
264
|
//
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
265
|
+
// Single source of truth for the EventSource lifecycle. Callers:
|
|
266
|
+
// - `loadOpencodeSession(id)` — close any current, open a new one.
|
|
267
|
+
// - `closeOpencodeSession()` — close and stop auto-reconnect.
|
|
268
|
+
// - unmount — close and stop.
|
|
269
|
+
//
|
|
270
|
+
// On error / network drop, the EventSource triggers `onerror`. We
|
|
271
|
+
// close the connection and schedule a reconnect (with exponential
|
|
272
|
+
// backoff: 1s, 2s, 4s, …, capped at 30s) UNLESS `sseAutoReconnectRef`
|
|
273
|
+
// is false (explicit disconnect).
|
|
274
|
+
//
|
|
275
|
+
const openSseForSession = useCallback(
|
|
276
|
+
(id: string) => {
|
|
277
|
+
// Close any existing connection first.
|
|
278
|
+
if (opencodeEsRef.current) {
|
|
279
|
+
try {
|
|
280
|
+
opencodeEsRef.current.close();
|
|
281
|
+
} catch {
|
|
282
|
+
/* noop */
|
|
283
|
+
}
|
|
284
|
+
opencodeEsRef.current = null;
|
|
285
|
+
}
|
|
286
|
+
if (sseReconnectTimerRef.current !== null) {
|
|
287
|
+
clearTimeout(sseReconnectTimerRef.current);
|
|
288
|
+
sseReconnectTimerRef.current = null;
|
|
289
|
+
}
|
|
144
290
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
291
|
+
// Build the SSE URL. EventSource can't set headers, so we
|
|
292
|
+
// pass the auth token as ?token= when set (loopback makes this
|
|
293
|
+
// optional, but harmless).
|
|
294
|
+
const tok = api.getToken();
|
|
295
|
+
const url = tok
|
|
296
|
+
? `/api/opencode-sessions/${encodeURIComponent(id)}/stream?token=${encodeURIComponent(tok)}`
|
|
297
|
+
: `/api/opencode-sessions/${encodeURIComponent(id)}/stream`;
|
|
152
298
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
};
|
|
299
|
+
sseCurrentSessionIdRef.current = id;
|
|
300
|
+
sseAutoReconnectRef.current = true;
|
|
301
|
+
sseReconnectAttemptRef.current = 0;
|
|
302
|
+
setOpencodeError(null);
|
|
171
303
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
if (!raw) return null;
|
|
178
|
-
try {
|
|
179
|
-
return JSON.parse(raw);
|
|
180
|
-
} catch {
|
|
181
|
-
return raw;
|
|
182
|
-
}
|
|
183
|
-
};
|
|
304
|
+
const connect = () => {
|
|
305
|
+
if (!sseAutoReconnectRef.current) return;
|
|
306
|
+
if (sseCurrentSessionIdRef.current !== id) return;
|
|
307
|
+
const es = new EventSource(url);
|
|
308
|
+
opencodeEsRef.current = es;
|
|
184
309
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
id: parsed?.messageID,
|
|
192
|
-
role: 'user',
|
|
193
|
-
content,
|
|
194
|
-
ts: new Date().toISOString(),
|
|
195
|
-
});
|
|
196
|
-
});
|
|
197
|
-
}
|
|
310
|
+
// Helper: dispatch one parsed envelope to local state.
|
|
311
|
+
const onEnvelope = (evt: OpencodeSseEnvelope | null) => {
|
|
312
|
+
if (!evt || !evt.type) return;
|
|
313
|
+
// Drop events for OTHER sessions even though the proxy
|
|
314
|
+
// already filters — defense in depth.
|
|
315
|
+
if (evt.sessionID && evt.sessionID !== id) return;
|
|
198
316
|
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
}
|
|
317
|
+
const ts = new Date().toISOString();
|
|
318
|
+
const sidForState = id;
|
|
216
319
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
320
|
+
if (
|
|
321
|
+
evt.type === 'message.user.created' ||
|
|
322
|
+
evt.type === 'message.user.updated'
|
|
323
|
+
) {
|
|
324
|
+
const payload = evt.data as
|
|
325
|
+
| { messageID?: string; data?: { content?: string } }
|
|
326
|
+
| undefined;
|
|
327
|
+
appendOpencodeMessage({
|
|
328
|
+
id: payload?.messageID ?? evt.messageID,
|
|
329
|
+
role: 'user',
|
|
330
|
+
content: payload?.data?.content ?? '',
|
|
331
|
+
ts,
|
|
332
|
+
});
|
|
333
|
+
markSeen(payload?.messageID ?? evt.messageID);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
if (
|
|
337
|
+
evt.type === 'message.assistant.created' ||
|
|
338
|
+
evt.type === 'message.assistant.updated'
|
|
339
|
+
) {
|
|
340
|
+
const payload = evt.data as
|
|
341
|
+
| { messageID?: string; data?: { content?: string } }
|
|
342
|
+
| undefined;
|
|
343
|
+
appendOpencodeMessage({
|
|
344
|
+
id: payload?.messageID ?? evt.messageID,
|
|
345
|
+
role: 'assistant',
|
|
346
|
+
content: payload?.data?.content ?? '',
|
|
347
|
+
ts,
|
|
348
|
+
});
|
|
349
|
+
markSeen(payload?.messageID ?? evt.messageID);
|
|
350
|
+
markSessionStreaming(sidForState, true);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
if (evt.type === 'message.part.updated') {
|
|
354
|
+
const payload = evt.data as
|
|
355
|
+
| {
|
|
356
|
+
part?: { type?: string; text?: string };
|
|
357
|
+
messageID?: string;
|
|
358
|
+
id?: string;
|
|
359
|
+
}
|
|
360
|
+
| undefined;
|
|
361
|
+
const text = payload?.part?.text ?? '';
|
|
362
|
+
if (!text) return;
|
|
363
|
+
appendOpencodeMessage({
|
|
364
|
+
id: payload?.id ?? payload?.messageID ?? evt.messageID,
|
|
365
|
+
role: 'assistant',
|
|
366
|
+
content: text,
|
|
367
|
+
ts,
|
|
368
|
+
});
|
|
369
|
+
markSeen(payload?.id ?? payload?.messageID ?? evt.messageID);
|
|
370
|
+
markSessionStreaming(sidForState, true);
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
if (
|
|
374
|
+
evt.type === 'session.idle' ||
|
|
375
|
+
evt.type === 'message.assistant.completed' ||
|
|
376
|
+
evt.type === 'session.completed'
|
|
377
|
+
) {
|
|
378
|
+
markSessionStreaming(sidForState, false);
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
if (
|
|
382
|
+
evt.type === 'session.awaiting' ||
|
|
383
|
+
evt.type === 'session.awaiting_input' ||
|
|
384
|
+
evt.type === 'message.assistant.awaiting'
|
|
385
|
+
) {
|
|
386
|
+
setSessionDisplayState(sidForState, { state: 'awaiting' });
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
if (evt.type === 'session.error') {
|
|
390
|
+
setOpencodeError(
|
|
391
|
+
(evt.data as { message?: string } | undefined)?.message ||
|
|
392
|
+
'opencode session error',
|
|
393
|
+
);
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
// Other event types are intentionally ignored — the proxy
|
|
397
|
+
// is canonical, we don't need to project every upstream
|
|
398
|
+
// event into local state.
|
|
399
|
+
};
|
|
235
400
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
401
|
+
// Stable parser — the server may send both `{type: "x"}` and
|
|
402
|
+
// `sync` envelopes with a `.<n>` version suffix. We accept both.
|
|
403
|
+
const parseData = (raw: string | null | undefined):
|
|
404
|
+
| OpencodeSseEnvelope
|
|
405
|
+
| null => {
|
|
406
|
+
if (!raw) return null;
|
|
407
|
+
try {
|
|
408
|
+
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
|
409
|
+
// Direct shape: {type, properties: {...}}
|
|
410
|
+
if (typeof parsed.type === 'string') {
|
|
411
|
+
const props = parsed.properties as
|
|
412
|
+
| Record<string, unknown>
|
|
413
|
+
| undefined;
|
|
414
|
+
const data = (parsed.data as Record<string, unknown> | undefined) ?? props;
|
|
415
|
+
const sessionID = (
|
|
416
|
+
typeof parsed.sessionID === 'string'
|
|
417
|
+
? parsed.sessionID
|
|
418
|
+
: typeof props?.sessionID === 'string'
|
|
419
|
+
? (props.sessionID as string)
|
|
420
|
+
: typeof data?.sessionID === 'string'
|
|
421
|
+
? (data.sessionID as string)
|
|
422
|
+
: undefined
|
|
423
|
+
);
|
|
424
|
+
const messageID = (
|
|
425
|
+
typeof parsed.messageID === 'string'
|
|
426
|
+
? parsed.messageID
|
|
427
|
+
: typeof props?.messageID === 'string'
|
|
428
|
+
? (props.messageID as string)
|
|
429
|
+
: typeof data?.messageID === 'string'
|
|
430
|
+
? (data.messageID as string)
|
|
431
|
+
: undefined
|
|
432
|
+
);
|
|
433
|
+
let type = parsed.type as string;
|
|
434
|
+
const m = /\.\d+$/.exec(type);
|
|
435
|
+
if (m) type = type.slice(0, m.index);
|
|
436
|
+
return { type, sessionID, messageID, data: parsed };
|
|
437
|
+
}
|
|
438
|
+
// Sync envelope: {type: "sync", syncEvent: {...}}
|
|
439
|
+
if (
|
|
440
|
+
parsed.type === 'sync' &&
|
|
441
|
+
parsed.syncEvent &&
|
|
442
|
+
typeof parsed.syncEvent === 'object'
|
|
443
|
+
) {
|
|
444
|
+
const inner = parsed.syncEvent as Record<string, unknown>;
|
|
445
|
+
let type = (inner.type as string) || '';
|
|
446
|
+
const m = /\.\d+$/.exec(type);
|
|
447
|
+
if (m) type = type.slice(0, m.index);
|
|
448
|
+
const innerData = (inner.data as Record<string, unknown>) || {};
|
|
449
|
+
return {
|
|
450
|
+
type,
|
|
451
|
+
sessionID:
|
|
452
|
+
typeof innerData.sessionID === 'string'
|
|
453
|
+
? innerData.sessionID
|
|
454
|
+
: undefined,
|
|
455
|
+
messageID:
|
|
456
|
+
typeof innerData.messageID === 'string'
|
|
457
|
+
? innerData.messageID
|
|
458
|
+
: undefined,
|
|
459
|
+
data: inner,
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
return null;
|
|
463
|
+
} catch {
|
|
464
|
+
return null;
|
|
465
|
+
}
|
|
466
|
+
};
|
|
246
467
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
468
|
+
// register handlers per named event type
|
|
469
|
+
const namedTypes = [
|
|
470
|
+
'message.user.created',
|
|
471
|
+
'message.user.updated',
|
|
472
|
+
'message.assistant.created',
|
|
473
|
+
'message.assistant.updated',
|
|
474
|
+
'message.part.updated',
|
|
475
|
+
'session.idle',
|
|
476
|
+
'message.assistant.completed',
|
|
477
|
+
'session.completed',
|
|
478
|
+
'session.awaiting',
|
|
479
|
+
'session.awaiting_input',
|
|
480
|
+
'message.assistant.awaiting',
|
|
481
|
+
'session.error',
|
|
482
|
+
];
|
|
483
|
+
for (const name of namedTypes) {
|
|
484
|
+
es.addEventListener(name, (e: MessageEvent) => {
|
|
485
|
+
onEnvelope(parseData((e as MessageEvent).data));
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
es.onerror = () => {
|
|
490
|
+
if (sseCurrentSessionIdRef.current !== id) return;
|
|
491
|
+
if (!sseAutoReconnectRef.current) {
|
|
492
|
+
try {
|
|
493
|
+
es.close();
|
|
494
|
+
} catch {
|
|
495
|
+
/* noop */
|
|
496
|
+
}
|
|
497
|
+
opencodeEsRef.current = null;
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
try {
|
|
501
|
+
es.close();
|
|
502
|
+
} catch {
|
|
503
|
+
/* noop */
|
|
504
|
+
}
|
|
505
|
+
opencodeEsRef.current = null;
|
|
506
|
+
|
|
507
|
+
const nextAttempt = sseReconnectAttemptRef.current + 1;
|
|
508
|
+
sseReconnectAttemptRef.current = nextAttempt;
|
|
509
|
+
// Backoff: 1s, 2s, 4s, 8s, … capped at 30s.
|
|
510
|
+
const delayMs = Math.min(30_000, 1000 * 2 ** (nextAttempt - 1));
|
|
511
|
+
|
|
512
|
+
if (sseReconnectTimerRef.current !== null) {
|
|
513
|
+
clearTimeout(sseReconnectTimerRef.current);
|
|
514
|
+
}
|
|
515
|
+
sseReconnectTimerRef.current = window.setTimeout(() => {
|
|
516
|
+
sseReconnectTimerRef.current = null;
|
|
517
|
+
if (
|
|
518
|
+
sseAutoReconnectRef.current &&
|
|
519
|
+
sseCurrentSessionIdRef.current === id
|
|
520
|
+
) {
|
|
521
|
+
connect();
|
|
522
|
+
}
|
|
523
|
+
}, delayMs);
|
|
524
|
+
};
|
|
525
|
+
|
|
526
|
+
es.onopen = () => {
|
|
527
|
+
// Successful (re)connect — reset backoff.
|
|
528
|
+
sseReconnectAttemptRef.current = 0;
|
|
529
|
+
setOpencodeError(null);
|
|
530
|
+
};
|
|
531
|
+
};
|
|
532
|
+
|
|
533
|
+
connect();
|
|
534
|
+
},
|
|
535
|
+
[appendOpencodeMessage, markSeen, markSessionStreaming, setSessionDisplayState],
|
|
536
|
+
);
|
|
257
537
|
|
|
258
|
-
|
|
259
|
-
|
|
538
|
+
const closeOpencodeSession = useCallback(() => {
|
|
539
|
+
// Disable auto-reconnect first so the onerror handler short-circuits.
|
|
540
|
+
sseAutoReconnectRef.current = false;
|
|
541
|
+
sseCurrentSessionIdRef.current = null;
|
|
542
|
+
if (sseReconnectTimerRef.current !== null) {
|
|
543
|
+
clearTimeout(sseReconnectTimerRef.current);
|
|
544
|
+
sseReconnectTimerRef.current = null;
|
|
545
|
+
}
|
|
546
|
+
if (opencodeEsRef.current) {
|
|
260
547
|
try {
|
|
261
|
-
|
|
548
|
+
opencodeEsRef.current.close();
|
|
262
549
|
} catch {
|
|
263
550
|
/* noop */
|
|
264
551
|
}
|
|
265
552
|
opencodeEsRef.current = null;
|
|
266
|
-
}
|
|
267
|
-
}, [markSessionStreaming, setSessionDisplayState]);
|
|
268
|
-
|
|
269
|
-
// ── Close opencode session ──────────────────────────────────────────────────
|
|
270
|
-
const closeOpencodeSession = useCallback(() => {
|
|
271
|
-
opencodeEsRef.current?.close();
|
|
272
|
-
opencodeEsRef.current = null;
|
|
553
|
+
}
|
|
273
554
|
setOpencodeMessages([]);
|
|
555
|
+
setSeenOpencodeMessages(new Set());
|
|
556
|
+
setOpencodeError(null);
|
|
274
557
|
setActiveOpencodeSessionId(null);
|
|
275
558
|
setActiveSource('bizar');
|
|
276
559
|
}, []);
|
|
277
560
|
|
|
561
|
+
const loadOpencodeSession = useCallback(
|
|
562
|
+
async (id: string) => {
|
|
563
|
+
// Close any previous opencode session cleanly.
|
|
564
|
+
sseAutoReconnectRef.current = false;
|
|
565
|
+
if (opencodeEsRef.current) {
|
|
566
|
+
try {
|
|
567
|
+
opencodeEsRef.current.close();
|
|
568
|
+
} catch {
|
|
569
|
+
/* noop */
|
|
570
|
+
}
|
|
571
|
+
opencodeEsRef.current = null;
|
|
572
|
+
}
|
|
573
|
+
if (sseReconnectTimerRef.current !== null) {
|
|
574
|
+
clearTimeout(sseReconnectTimerRef.current);
|
|
575
|
+
sseReconnectTimerRef.current = null;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
setOpencodeError(null);
|
|
579
|
+
setLoading(true);
|
|
580
|
+
|
|
581
|
+
try {
|
|
582
|
+
const data = await api.get<{ messages: ChatMessage[] }>(
|
|
583
|
+
`/opencode-sessions/${encodeURIComponent(id)}/messages`,
|
|
584
|
+
);
|
|
585
|
+
setOpencodeMessages(data.messages || []);
|
|
586
|
+
} catch (err) {
|
|
587
|
+
const msg = (err as Error).message;
|
|
588
|
+
toastRef.current?.error(`Opencode session load failed: ${msg}`);
|
|
589
|
+
setOpencodeMessages([]);
|
|
590
|
+
} finally {
|
|
591
|
+
setLoading(false);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
setActiveOpencodeSessionId(id);
|
|
595
|
+
setActiveSource('opencode');
|
|
596
|
+
markSessionStreaming(id, false);
|
|
597
|
+
openSseForSession(id);
|
|
598
|
+
},
|
|
599
|
+
[markSessionStreaming, openSseForSession],
|
|
600
|
+
);
|
|
601
|
+
|
|
278
602
|
// ── Select a bizar session ──────────────────────────────────────────────────
|
|
279
603
|
const selectBizarSession = useCallback(
|
|
280
604
|
(sid: string) => {
|
|
@@ -288,13 +612,52 @@ export function useChat(snapshot: Snapshot, settings: Settings, initialTaskId?:
|
|
|
288
612
|
[closeOpencodeSession, loadChat, setSessionDisplayState],
|
|
289
613
|
);
|
|
290
614
|
|
|
291
|
-
// ── Create session
|
|
292
|
-
const onCreateSession = useCallback(async ()
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
615
|
+
// ── Create session (opencode primary; bizar fallback) ──────────────────────
|
|
616
|
+
const onCreateSession = useCallback(async (): Promise<{
|
|
617
|
+
ok: boolean;
|
|
618
|
+
source?: 'bizar' | 'opencode';
|
|
619
|
+
id?: string;
|
|
620
|
+
}> => {
|
|
621
|
+
if (busy.create) return { ok: false };
|
|
622
|
+
const agent = settings.defaultAgent || 'odin';
|
|
623
|
+
setBusy((b) => ({ ...b, create: true }));
|
|
297
624
|
try {
|
|
625
|
+
// Try to create a real opencode session first. This is the
|
|
626
|
+
// primary create path: it gives the user a full conversation
|
|
627
|
+
// roundtrip with SSE streaming, abort, etc.
|
|
628
|
+
try {
|
|
629
|
+
const created = await api.post<{
|
|
630
|
+
id: string;
|
|
631
|
+
title: string;
|
|
632
|
+
agent: string;
|
|
633
|
+
directory: string;
|
|
634
|
+
createdAt: number;
|
|
635
|
+
}>('/opencode-sessions/new', { agent });
|
|
636
|
+
await refreshOpencodeSessions();
|
|
637
|
+
// Open the newly created session right away so the user is
|
|
638
|
+
// dropped into a fresh empty thread, not stuck on the
|
|
639
|
+
// previous one.
|
|
640
|
+
await loadOpencodeSession(created.id);
|
|
641
|
+
toastRef.current?.success(`Session ${created.id} created.`);
|
|
642
|
+
return { ok: true, source: 'opencode', id: created.id };
|
|
643
|
+
} catch (opencodeErr) {
|
|
644
|
+
// Opencode plugin offline (503) — fall back to the local
|
|
645
|
+
// per-project .jsonl session. Otherwise, surface the error.
|
|
646
|
+
if (!(opencodeErr instanceof ApiError) || opencodeErr.status !== 503) {
|
|
647
|
+
toastRef.current?.error(
|
|
648
|
+
`Create failed: ${(opencodeErr as Error).message}`,
|
|
649
|
+
);
|
|
650
|
+
return { ok: false };
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// Fallback: local jsonl session.
|
|
655
|
+
if (!snapshot.activeProject) {
|
|
656
|
+
toastRef.current?.error(
|
|
657
|
+
'Pick a project in Overview to scope chat sessions.',
|
|
658
|
+
);
|
|
659
|
+
return { ok: false };
|
|
660
|
+
}
|
|
298
661
|
const created = await api.post<ChatSession>('/chat/sessions', {});
|
|
299
662
|
setSessionId(created.id);
|
|
300
663
|
setBizarMessages([]);
|
|
@@ -302,81 +665,127 @@ export function useChat(snapshot: Snapshot, settings: Settings, initialTaskId?:
|
|
|
302
665
|
setSessionDisplayState(created.id, { state: 'idle', unread: 0 });
|
|
303
666
|
await loadChat(created.id);
|
|
304
667
|
refreshSessions().catch(() => {});
|
|
668
|
+
toastRef.current?.success(`Session ${created.id} created.`);
|
|
669
|
+
return { ok: true, source: 'bizar', id: created.id };
|
|
305
670
|
} catch (err) {
|
|
306
671
|
toastRef.current?.error(`Create failed: ${(err as Error).message}`);
|
|
672
|
+
return { ok: false };
|
|
673
|
+
} finally {
|
|
674
|
+
setBusy((b) => ({ ...b, create: false }));
|
|
307
675
|
}
|
|
308
|
-
}, [
|
|
676
|
+
}, [
|
|
677
|
+
busy.create,
|
|
678
|
+
settings.defaultAgent,
|
|
679
|
+
snapshot.activeProject,
|
|
680
|
+
refreshOpencodeSessions,
|
|
681
|
+
loadOpencodeSession,
|
|
682
|
+
loadChat,
|
|
683
|
+
refreshSessions,
|
|
684
|
+
setSessionDisplayState,
|
|
685
|
+
]);
|
|
309
686
|
|
|
310
687
|
// ── Send message ────────────────────────────────────────────────────────────
|
|
311
688
|
const onSend = useCallback(
|
|
312
|
-
async (
|
|
689
|
+
async (
|
|
690
|
+
message: string,
|
|
691
|
+
agent: string,
|
|
692
|
+
model: string,
|
|
693
|
+
attachments?: string[],
|
|
694
|
+
): Promise<{ ok: boolean }> => {
|
|
695
|
+
if (busy.send) return { ok: false };
|
|
696
|
+
const trimmed = message.trim();
|
|
697
|
+
if (!trimmed) return { ok: false };
|
|
698
|
+
|
|
313
699
|
if (activeSource === 'opencode' && activeOpencodeSessionId) {
|
|
314
|
-
|
|
315
|
-
const optimisticId = `msg_${Date.now()}
|
|
700
|
+
const sid = activeOpencodeSessionId;
|
|
701
|
+
const optimisticId = `msg_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
|
|
316
702
|
const optimistic: ChatMessage = {
|
|
317
703
|
id: optimisticId,
|
|
318
704
|
role: 'user',
|
|
319
|
-
content:
|
|
705
|
+
content: trimmed,
|
|
320
706
|
agent,
|
|
321
707
|
ts: new Date().toISOString(),
|
|
322
708
|
};
|
|
323
|
-
|
|
324
|
-
|
|
709
|
+
appendOpencodeMessage(optimistic);
|
|
710
|
+
markSeen(optimisticId);
|
|
711
|
+
setBusy((b) => ({ ...b, send: true }));
|
|
325
712
|
try {
|
|
326
|
-
|
|
327
|
-
`/opencode-sessions/${encodeURIComponent(
|
|
328
|
-
{
|
|
713
|
+
await api.post<{ ok: boolean; messageId: string }>(
|
|
714
|
+
`/opencode-sessions/${encodeURIComponent(sid)}/send`,
|
|
715
|
+
{
|
|
716
|
+
message: trimmed,
|
|
717
|
+
agent,
|
|
718
|
+
},
|
|
329
719
|
);
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
markSessionStreaming(activeOpencodeSessionId, true);
|
|
720
|
+
markSessionStreaming(sid, true);
|
|
721
|
+
return { ok: true };
|
|
333
722
|
} catch (err) {
|
|
334
|
-
// Remove optimistic on failure
|
|
723
|
+
// Remove optimistic on failure.
|
|
335
724
|
setOpencodeMessages((cur) => cur.filter((m) => m.id !== optimisticId));
|
|
336
|
-
toastRef.current?.error(
|
|
725
|
+
toastRef.current?.error(
|
|
726
|
+
`Send failed: ${(err as Error).message}`,
|
|
727
|
+
);
|
|
728
|
+
return { ok: false };
|
|
337
729
|
} finally {
|
|
338
|
-
|
|
730
|
+
setBusy((b) => ({ ...b, send: false }));
|
|
339
731
|
}
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// Bizar fallback.
|
|
735
|
+
const optimistic: ChatMessage = {
|
|
736
|
+
role: 'user',
|
|
737
|
+
content: trimmed,
|
|
738
|
+
agent,
|
|
739
|
+
ts: new Date().toISOString(),
|
|
740
|
+
};
|
|
741
|
+
setBizarMessages((cur) => [...cur, optimistic]);
|
|
742
|
+
setBusy((b) => ({ ...b, send: true }));
|
|
743
|
+
markSessionStreaming(sessionId, true);
|
|
744
|
+
try {
|
|
745
|
+
const response = await api.post<{ messages?: ChatMessage[] }>(
|
|
746
|
+
'/chat',
|
|
747
|
+
{
|
|
748
|
+
message: trimmed,
|
|
354
749
|
agent,
|
|
355
750
|
model,
|
|
356
751
|
attachments: attachments || [],
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
}
|
|
752
|
+
},
|
|
753
|
+
);
|
|
754
|
+
if (response?.messages) {
|
|
755
|
+
for (const msg of response.messages) {
|
|
756
|
+
if (msg.role === 'assistant') {
|
|
757
|
+
setBizarMessages((cur) => {
|
|
758
|
+
const exists = cur.some((m) => m.ts === msg.ts);
|
|
759
|
+
return exists ? cur : [...cur, msg];
|
|
760
|
+
});
|
|
366
761
|
}
|
|
367
|
-
} else {
|
|
368
|
-
toastRef.current?.info('Still processing… the response will appear shortly.');
|
|
369
762
|
}
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
setSending(false);
|
|
375
|
-
markSessionStreaming(sessionId, false);
|
|
763
|
+
} else {
|
|
764
|
+
toastRef.current?.info(
|
|
765
|
+
'Still processing… the response will appear shortly.',
|
|
766
|
+
);
|
|
376
767
|
}
|
|
768
|
+
return { ok: true };
|
|
769
|
+
} catch (err) {
|
|
770
|
+
setBizarMessages((cur) => cur.filter((m) => m !== optimistic));
|
|
771
|
+
toastRef.current?.error(
|
|
772
|
+
`Send failed: ${(err as Error).message}`,
|
|
773
|
+
);
|
|
774
|
+
return { ok: false };
|
|
775
|
+
} finally {
|
|
776
|
+
setBusy((b) => ({ ...b, send: false }));
|
|
777
|
+
markSessionStreaming(sessionId, false);
|
|
377
778
|
}
|
|
378
779
|
},
|
|
379
|
-
[
|
|
780
|
+
[
|
|
781
|
+
activeSource,
|
|
782
|
+
activeOpencodeSessionId,
|
|
783
|
+
appendOpencodeMessage,
|
|
784
|
+
markSeen,
|
|
785
|
+
markSessionStreaming,
|
|
786
|
+
sessionId,
|
|
787
|
+
busy.send,
|
|
788
|
+
],
|
|
380
789
|
);
|
|
381
790
|
|
|
382
791
|
const onRegenerate = useCallback(
|
|
@@ -389,7 +798,9 @@ export function useChat(snapshot: Snapshot, settings: Settings, initialTaskId?:
|
|
|
389
798
|
await api.post('/chat/regenerate', { sessionId, messageId });
|
|
390
799
|
await loadChat(sessionId);
|
|
391
800
|
} catch (err) {
|
|
392
|
-
toastRef.current?.error(
|
|
801
|
+
toastRef.current?.error(
|
|
802
|
+
`Regenerate failed: ${(err as Error).message}`,
|
|
803
|
+
);
|
|
393
804
|
}
|
|
394
805
|
},
|
|
395
806
|
[sessionId, loadChat],
|
|
@@ -417,10 +828,14 @@ export function useChat(snapshot: Snapshot, settings: Settings, initialTaskId?:
|
|
|
417
828
|
|
|
418
829
|
const copyMessage = useCallback((m: ChatMessage) => {
|
|
419
830
|
const text = m.content || m.message || '';
|
|
420
|
-
|
|
421
|
-
()
|
|
422
|
-
|
|
423
|
-
|
|
831
|
+
try {
|
|
832
|
+
navigator.clipboard?.writeText(text).then(
|
|
833
|
+
() => toastRef.current?.success('Copied.'),
|
|
834
|
+
() => toastRef.current?.error('Copy failed.'),
|
|
835
|
+
);
|
|
836
|
+
} catch {
|
|
837
|
+
toastRef.current?.error('Copy failed.');
|
|
838
|
+
}
|
|
424
839
|
}, []);
|
|
425
840
|
|
|
426
841
|
// ── Jump-to-latest ──────────────────────────────────────────────────────────
|
|
@@ -441,47 +856,126 @@ export function useChat(snapshot: Snapshot, settings: Settings, initialTaskId?:
|
|
|
441
856
|
setNewMessageCount(0);
|
|
442
857
|
}, []);
|
|
443
858
|
|
|
444
|
-
// ── Session rename / delete (
|
|
859
|
+
// ── Session rename / delete (rail row menu + info panel actions) ────────────
|
|
445
860
|
const renameSession = useCallback(
|
|
446
|
-
async (id: string, title: string) => {
|
|
861
|
+
async (id: string, title: string): Promise<boolean> => {
|
|
862
|
+
if (busy.rename) return false;
|
|
863
|
+
setBusy((b) => ({ ...b, rename: true }));
|
|
447
864
|
try {
|
|
448
|
-
|
|
449
|
-
|
|
865
|
+
const trimmed = title.trim();
|
|
866
|
+
if (!trimmed) {
|
|
867
|
+
toastRef.current?.error('Title cannot be empty.');
|
|
868
|
+
return false;
|
|
869
|
+
}
|
|
870
|
+
// Try the opencode path first if this id is in the opencode
|
|
871
|
+
// session list, otherwise the local path. We dispatch by
|
|
872
|
+
// inspecting the in-memory set (avoids a round trip).
|
|
873
|
+
const isOpencode = opencodeSessions.some((s) => s.id === id);
|
|
874
|
+
if (isOpencode) {
|
|
875
|
+
await api.patch<{ id: string; title: string }>(
|
|
876
|
+
`/opencode-sessions/${encodeURIComponent(id)}`,
|
|
877
|
+
{ title: trimmed },
|
|
878
|
+
);
|
|
879
|
+
setOpencodeSessions((cur) =>
|
|
880
|
+
cur.map((s) => (s.id === id ? { ...s, title: trimmed } : s)),
|
|
881
|
+
);
|
|
882
|
+
} else {
|
|
883
|
+
await api.post(
|
|
884
|
+
`/chat/sessions/${encodeURIComponent(id)}/rename`,
|
|
885
|
+
{ title: trimmed },
|
|
886
|
+
);
|
|
887
|
+
setSessions((cur) =>
|
|
888
|
+
cur.map((s) => (s.id === id ? { ...s, title: trimmed } : s)),
|
|
889
|
+
);
|
|
890
|
+
}
|
|
450
891
|
toastRef.current?.success('Renamed.');
|
|
892
|
+
return true;
|
|
451
893
|
} catch (err) {
|
|
452
|
-
toastRef.current?.error(
|
|
894
|
+
toastRef.current?.error(
|
|
895
|
+
`Rename failed: ${(err as Error).message}`,
|
|
896
|
+
);
|
|
897
|
+
return false;
|
|
898
|
+
} finally {
|
|
899
|
+
setBusy((b) => ({ ...b, rename: false }));
|
|
453
900
|
}
|
|
454
901
|
},
|
|
455
|
-
[],
|
|
902
|
+
[busy.rename, opencodeSessions],
|
|
456
903
|
);
|
|
457
904
|
|
|
458
905
|
const deleteSession = useCallback(
|
|
459
|
-
async (id: string) => {
|
|
906
|
+
async (id: string): Promise<boolean> => {
|
|
907
|
+
if (busy.delete) return false;
|
|
908
|
+
setBusy((b) => ({ ...b, delete: true }));
|
|
460
909
|
try {
|
|
461
|
-
|
|
462
|
-
|
|
910
|
+
const isOpencode = opencodeSessions.some((s) => s.id === id);
|
|
911
|
+
if (isOpencode) {
|
|
912
|
+
await api.del(`/opencode-sessions/${encodeURIComponent(id)}`);
|
|
913
|
+
setOpencodeSessions((cur) => cur.filter((s) => s.id !== id));
|
|
914
|
+
if (activeOpencodeSessionId === id) {
|
|
915
|
+
closeOpencodeSession();
|
|
916
|
+
}
|
|
917
|
+
} else {
|
|
918
|
+
await api.del(`/chat/sessions/${encodeURIComponent(id)}`);
|
|
919
|
+
setSessions((cur) => cur.filter((s) => s.id !== id));
|
|
920
|
+
if (sessionId === id) {
|
|
921
|
+
setSessionId('');
|
|
922
|
+
setBizarMessages([]);
|
|
923
|
+
}
|
|
924
|
+
}
|
|
463
925
|
setSessionStates((cur) => {
|
|
464
926
|
const next = { ...cur };
|
|
465
927
|
delete next[id];
|
|
466
928
|
return next;
|
|
467
929
|
});
|
|
468
|
-
if (sessionId === id) {
|
|
469
|
-
setSessionId('');
|
|
470
|
-
setBizarMessages([]);
|
|
471
|
-
}
|
|
472
930
|
toastRef.current?.success('Session deleted.');
|
|
931
|
+
return true;
|
|
473
932
|
} catch (err) {
|
|
474
|
-
toastRef.current?.error(
|
|
933
|
+
toastRef.current?.error(
|
|
934
|
+
`Delete failed: ${(err as Error).message}`,
|
|
935
|
+
);
|
|
936
|
+
return false;
|
|
937
|
+
} finally {
|
|
938
|
+
setBusy((b) => ({ ...b, delete: false }));
|
|
475
939
|
}
|
|
476
940
|
},
|
|
477
|
-
[
|
|
941
|
+
[
|
|
942
|
+
busy.delete,
|
|
943
|
+
opencodeSessions,
|
|
944
|
+
activeOpencodeSessionId,
|
|
945
|
+
closeOpencodeSession,
|
|
946
|
+
sessionId,
|
|
947
|
+
],
|
|
478
948
|
);
|
|
479
949
|
|
|
950
|
+
// ── Auto-scroll on new messages ─────────────────────────────────────────────
|
|
951
|
+
useEffect(() => {
|
|
952
|
+
if (listRef.current) {
|
|
953
|
+
if (stickToBottom) {
|
|
954
|
+
listRef.current.scrollTop = listRef.current.scrollHeight;
|
|
955
|
+
} else {
|
|
956
|
+
setNewMessageCount((n) => n + 1);
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
960
|
+
}, [bizarMessages, opencodeMessages]);
|
|
961
|
+
|
|
480
962
|
// ── Cleanup SSE on unmount ──────────────────────────────────────────────────
|
|
481
963
|
useEffect(() => {
|
|
482
964
|
return () => {
|
|
483
|
-
|
|
484
|
-
|
|
965
|
+
sseAutoReconnectRef.current = false;
|
|
966
|
+
sseCurrentSessionIdRef.current = null;
|
|
967
|
+
if (sseReconnectTimerRef.current !== null) {
|
|
968
|
+
clearTimeout(sseReconnectTimerRef.current);
|
|
969
|
+
sseReconnectTimerRef.current = null;
|
|
970
|
+
}
|
|
971
|
+
if (opencodeEsRef.current) {
|
|
972
|
+
try {
|
|
973
|
+
opencodeEsRef.current.close();
|
|
974
|
+
} catch {
|
|
975
|
+
/* noop */
|
|
976
|
+
}
|
|
977
|
+
opencodeEsRef.current = null;
|
|
978
|
+
}
|
|
485
979
|
};
|
|
486
980
|
}, []);
|
|
487
981
|
|
|
@@ -512,9 +1006,10 @@ export function useChat(snapshot: Snapshot, settings: Settings, initialTaskId?:
|
|
|
512
1006
|
import('../../lib/ws').then(({ Ws }) => {
|
|
513
1007
|
if (closed) return;
|
|
514
1008
|
const ws = new Ws();
|
|
515
|
-
ws.on((msg) => {
|
|
1009
|
+
ws.on((msg: { type: string; message?: ChatMessage }) => {
|
|
516
1010
|
if (msg.type !== 'chat:message') return;
|
|
517
1011
|
const next = msg.message;
|
|
1012
|
+
if (!next) return;
|
|
518
1013
|
setBizarMessages((cur) => {
|
|
519
1014
|
const exists = cur.some((m) => m.ts === next.ts);
|
|
520
1015
|
return exists ? cur : [...cur, next];
|
|
@@ -528,7 +1023,8 @@ export function useChat(snapshot: Snapshot, settings: Settings, initialTaskId?:
|
|
|
528
1023
|
|
|
529
1024
|
return {
|
|
530
1025
|
// ── State ────────────────────────────────────────────────────────────────
|
|
531
|
-
messages:
|
|
1026
|
+
messages:
|
|
1027
|
+
activeSource === 'opencode' ? opencodeMessages : bizarMessages,
|
|
532
1028
|
bizarMessages,
|
|
533
1029
|
opencodeMessages,
|
|
534
1030
|
sessions,
|
|
@@ -536,17 +1032,16 @@ export function useChat(snapshot: Snapshot, settings: Settings, initialTaskId?:
|
|
|
536
1032
|
sessionId,
|
|
537
1033
|
setSessionId,
|
|
538
1034
|
loading,
|
|
539
|
-
sending,
|
|
1035
|
+
sending: busy.send,
|
|
540
1036
|
pinned,
|
|
541
1037
|
listRef,
|
|
542
1038
|
// ── Opencode state ──────────────────────────────────────────────────────
|
|
543
1039
|
activeSource,
|
|
544
1040
|
activeOpencodeSessionId,
|
|
1041
|
+
opencodeError,
|
|
545
1042
|
// ── v3.22 — per-session display state ──────────────────────────────────
|
|
546
1043
|
sessionStates,
|
|
547
|
-
|
|
548
|
-
* consumes this to overlay state/unread/tree on the raw
|
|
549
|
-
* ChatSession list. */
|
|
1044
|
+
busy,
|
|
550
1045
|
getSessionDisplay: (s: ChatSession) => ({
|
|
551
1046
|
...s,
|
|
552
1047
|
state: sessionStates[s.id]?.state ?? 'idle',
|
|
@@ -559,10 +1054,8 @@ export function useChat(snapshot: Snapshot, settings: Settings, initialTaskId?:
|
|
|
559
1054
|
newMessageCount,
|
|
560
1055
|
handleScroll,
|
|
561
1056
|
jumpToLatest,
|
|
562
|
-
// ──
|
|
563
|
-
setToast
|
|
564
|
-
toastRef.current = toast;
|
|
565
|
-
},
|
|
1057
|
+
// ── Toast integration ──────────────────────────────────────────────────
|
|
1058
|
+
setToast,
|
|
566
1059
|
// ── Actions ─────────────────────────────────────────────────────────────
|
|
567
1060
|
loadChat,
|
|
568
1061
|
loadTaskChat,
|
|
@@ -579,5 +1072,6 @@ export function useChat(snapshot: Snapshot, settings: Settings, initialTaskId?:
|
|
|
579
1072
|
copyMessage,
|
|
580
1073
|
renameSession,
|
|
581
1074
|
deleteSession,
|
|
1075
|
+
seenOpencodeMessages,
|
|
582
1076
|
};
|
|
583
|
-
}
|
|
1077
|
+
}
|