@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.
Files changed (118) hide show
  1. package/bizar-dash/CHANGELOG.md +37 -276
  2. package/bizar-dash/dist/assets/__vite-browser-external-BIHI7g3E.js +2 -0
  3. package/bizar-dash/dist/assets/__vite-browser-external-BIHI7g3E.js.map +1 -0
  4. package/bizar-dash/dist/assets/main-eWZ4NlCL.css +1 -0
  5. package/bizar-dash/dist/assets/main-usWhlPWa.js +362 -0
  6. package/bizar-dash/dist/assets/main-usWhlPWa.js.map +1 -0
  7. package/bizar-dash/dist/assets/{mobile-DSb-t42Y.js → mobile-DQLFCjwJ.js} +2 -2
  8. package/bizar-dash/dist/assets/{mobile-DSb-t42Y.js.map → mobile-DQLFCjwJ.js.map} +1 -1
  9. package/bizar-dash/dist/assets/mobile-O6ANdD4W.js +352 -0
  10. package/bizar-dash/dist/assets/mobile-O6ANdD4W.js.map +1 -0
  11. package/bizar-dash/dist/index.html +3 -3
  12. package/bizar-dash/dist/mobile.html +2 -2
  13. package/bizar-dash/skills/agent-baseline/SKILL.md +80 -0
  14. package/bizar-dash/skills/bizar/SKILL.md +116 -0
  15. package/bizar-dash/skills/chat/SKILL.md +74 -0
  16. package/bizar-dash/skills/headroom/SKILL.md +94 -0
  17. package/bizar-dash/skills/lightrag/SKILL.md +86 -0
  18. package/bizar-dash/skills/minimax/SKILL.md +80 -0
  19. package/bizar-dash/skills/obsidian/SKILL.md +68 -0
  20. package/bizar-dash/skills/providers/SKILL.md +75 -0
  21. package/bizar-dash/skills/sdk/SKILL.md +138 -0
  22. package/bizar-dash/skills/self-improvement/SKILL.md +53 -0
  23. package/bizar-dash/skills/skills-cli/SKILL.md +94 -0
  24. package/bizar-dash/skills/usage/SKILL.md +62 -0
  25. package/bizar-dash/src/server/api.mjs +18 -0
  26. package/bizar-dash/src/server/headroom.mjs +645 -0
  27. package/bizar-dash/src/server/memory-lightrag.mjs +272 -2
  28. package/bizar-dash/src/server/memory-obsidian.mjs +230 -0
  29. package/bizar-dash/src/server/memory-store.mjs +189 -0
  30. package/bizar-dash/src/server/minimax-usage-store.mjs +372 -0
  31. package/bizar-dash/src/server/minimax.mjs +196 -5
  32. package/bizar-dash/src/server/providers-store.mjs +956 -0
  33. package/bizar-dash/src/server/routes/_shared.mjs +17 -0
  34. package/bizar-dash/src/server/routes/config.mjs +52 -1
  35. package/bizar-dash/src/server/routes/env-vars.mjs +165 -0
  36. package/bizar-dash/src/server/routes/headroom.mjs +126 -0
  37. package/bizar-dash/src/server/routes/lightrag.mjs +154 -0
  38. package/bizar-dash/src/server/routes/memory.mjs +668 -1
  39. package/bizar-dash/src/server/routes/opencode-session-detail.mjs +14 -29
  40. package/bizar-dash/src/server/routes/opencode-sessions.mjs +205 -3
  41. package/bizar-dash/src/server/routes/providers.mjs +266 -5
  42. package/bizar-dash/src/server/routes/skills.mjs +32 -43
  43. package/bizar-dash/src/server/routes/update.mjs +340 -0
  44. package/bizar-dash/src/server/routes/usage.mjs +136 -0
  45. package/bizar-dash/src/server/serve-info.mjs +135 -4
  46. package/bizar-dash/src/server/server.mjs +20 -0
  47. package/bizar-dash/src/server/skills-store.mjs +152 -262
  48. package/bizar-dash/src/web/App.tsx +120 -29
  49. package/bizar-dash/src/web/components/EnvVarManager.tsx +247 -0
  50. package/bizar-dash/src/web/components/HeadroomSettings.tsx +418 -0
  51. package/bizar-dash/src/web/components/HeadroomStatus.tsx +158 -0
  52. package/bizar-dash/src/web/components/SettingsSearch.tsx +213 -0
  53. package/bizar-dash/src/web/components/Topbar.tsx +2 -1
  54. package/bizar-dash/src/web/components/UsageChart.tsx +250 -0
  55. package/bizar-dash/src/web/components/UsageTable.tsx +90 -0
  56. package/bizar-dash/src/web/components/chat/ChatComposer.tsx +21 -25
  57. package/bizar-dash/src/web/components/chat/ChatInfoPanel.tsx +199 -37
  58. package/bizar-dash/src/web/components/chat/ChatThread.tsx +29 -17
  59. package/bizar-dash/src/web/components/chat/FloatingComposer.tsx +7 -1
  60. package/bizar-dash/src/web/components/chat/InfoPanel.tsx +71 -6
  61. package/bizar-dash/src/web/components/chat/useChat.ts +751 -257
  62. package/bizar-dash/src/web/lib/api.ts +43 -0
  63. package/bizar-dash/src/web/lib/types.ts +16 -0
  64. package/bizar-dash/src/web/main.tsx +2 -0
  65. package/bizar-dash/src/web/mobile/views/MobileChat.tsx +110 -35
  66. package/bizar-dash/src/web/styles/chat.css +135 -1
  67. package/bizar-dash/src/web/styles/main.css +46 -0
  68. package/bizar-dash/src/web/styles/memory.css +955 -0
  69. package/bizar-dash/src/web/styles/minimax-usage.css +335 -0
  70. package/bizar-dash/src/web/styles/settings.css +418 -0
  71. package/bizar-dash/src/web/styles/skills.css +302 -0
  72. package/bizar-dash/src/web/styles/tasks.css +288 -0
  73. package/bizar-dash/src/web/views/Chat.tsx +276 -48
  74. package/bizar-dash/src/web/views/Config.tsx +3 -2065
  75. package/bizar-dash/src/web/views/Memory.tsx +140 -0
  76. package/bizar-dash/src/web/views/MiniMaxUsage.tsx +476 -461
  77. package/bizar-dash/src/web/views/Overview.tsx +3 -0
  78. package/bizar-dash/src/web/views/Settings.tsx +36 -0
  79. package/bizar-dash/src/web/views/Skills.tsx +208 -260
  80. package/bizar-dash/src/web/views/Tasks.tsx +348 -1119
  81. package/bizar-dash/src/web/views/memory/ConfigPanel.tsx +289 -0
  82. package/bizar-dash/src/web/views/memory/GitSyncPanel.tsx +220 -0
  83. package/bizar-dash/src/web/views/memory/LightragPanel.tsx +307 -0
  84. package/bizar-dash/src/web/views/memory/MemoryOverview.tsx +354 -0
  85. package/bizar-dash/src/web/views/memory/MemoryStatusCard.tsx +160 -0
  86. package/bizar-dash/src/web/views/memory/ObsidianPanel.tsx +642 -0
  87. package/bizar-dash/src/web/views/memory/SemanticSearchPanel.tsx +194 -0
  88. package/bizar-dash/tests/chat-session-create.test.mjs +391 -0
  89. package/bizar-dash/tests/chat-session-stream.test.mjs +308 -0
  90. package/bizar-dash/tests/env-vars-store.test.mjs +216 -0
  91. package/bizar-dash/tests/headroom-install.test.mjs +173 -0
  92. package/bizar-dash/tests/headroom-settings.test.mjs +126 -0
  93. package/bizar-dash/tests/headroom-status.test.mjs +117 -0
  94. package/bizar-dash/tests/lightrag-defaults.node.test.mjs +118 -0
  95. package/bizar-dash/tests/memory-lightrag-extended.test.mjs +162 -0
  96. package/bizar-dash/tests/memory-obsidian.test.mjs +269 -0
  97. package/bizar-dash/tests/memory-tab.test.mjs +322 -0
  98. package/bizar-dash/tests/minimax-chat-usage.test.mjs +178 -0
  99. package/bizar-dash/tests/minimax-usage-store.node.test.mjs +293 -0
  100. package/bizar-dash/tests/mod-upgrade.node.test.mjs +1 -1
  101. package/bizar-dash/tests/opencode-sessions-detail.test.mjs +12 -9
  102. package/bizar-dash/tests/providers-store-backup-keys.node.test.mjs +479 -3
  103. package/bizar-dash/tests/providers-store-search.node.test.mjs +166 -0
  104. package/bizar-dash/tests/skills-list.test.mjs +232 -0
  105. package/bizar-dash/tests/skills-search.test.mjs +222 -0
  106. package/bizar-dash/tests/submit-feedback.test.mjs +6 -6
  107. package/bizar-dash/tests/tasks-create.test.mjs +187 -0
  108. package/bizar-dash/tests/update-check.test.mjs +127 -0
  109. package/bizar-dash/tests/update-run.test.mjs +266 -0
  110. package/cli/bin.mjs +247 -1
  111. package/cli/provision.mjs +118 -4
  112. package/config/agents/_shared/SKILLS.md +109 -0
  113. package/package.json +1 -1
  114. package/bizar-dash/dist/assets/main-BB5mJurD.js +0 -352
  115. package/bizar-dash/dist/assets/main-BB5mJurD.js.map +0 -1
  116. package/bizar-dash/dist/assets/main-BsnQLXdh.css +0 -1
  117. package/bizar-dash/dist/assets/mobile-Dl1q7Cyq.js +0 -354
  118. 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, send,
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 — 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.
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 { ChatMessage, ChatResponse, ChatSession, Settings, Snapshot } from '../../lib/types';
15
- import { api } from '../../lib/api';
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 [sending, setSending] = useState(false);
38
- const [pinned, setPinned] = useState<Set<number>>(new Set());
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 opencodeEsRef = useRef<EventSource | null>(null);
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<{ error: (msg: string) => void; success: (msg: string) => void; info: (msg: string) => void } | null>(null);
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
- // 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.
57
- useEffect(() => {
58
- if (listRef.current) {
59
- if (stickToBottom) {
60
- listRef.current.scrollTop = listRef.current.scrollHeight;
61
- } else {
62
- setNewMessageCount((n) => n + 1);
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
- // eslint-disable-next-line react-hooks/exhaustive-deps
66
- }, [bizarMessages, opencodeMessages]);
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 = cur[id] ?? { state: 'idle', unread: 0, pinned: false };
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(async (sid?: string) => {
88
- try {
89
- const url = sid ? `/chat?session=${encodeURIComponent(sid)}` : '/chat?limit=200';
90
- const data = await api.get<ChatResponse>(url);
91
- setBizarMessages(data.messages || []);
92
- setSessions(data.sessions || []);
93
- } catch (err) {
94
- toastRef.current?.error(`Chat load failed: ${(err as Error).message}`);
95
- } finally {
96
- setLoading(false);
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<{ sessionId?: string; messages: ChatMessage[] }>(`/tasks/${encodeURIComponent(taskId)}/chat`);
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(`Task chat load failed: ${(err as Error).message}`);
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 { /* best-effort */ }
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[] }>('/opencode-sessions');
123
- // Enrich with source marker and opencodeUrl; port is discovered from serve.json
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 { /* best-effort */ }
258
+ } catch {
259
+ /* best-effort */
260
+ }
132
261
  }, []);
133
262
 
134
- // ── Opencode session: load messages + open SSE ───────────────────────────────
263
+ // ── Opencode SSE connection ─────────────────────────────────────────────────
135
264
  //
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;
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
- 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
- }
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
- 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
- };
299
+ sseCurrentSessionIdRef.current = id;
300
+ sseAutoReconnectRef.current = true;
301
+ sseReconnectAttemptRef.current = 0;
302
+ setOpencodeError(null);
171
303
 
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
- };
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
- 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
- }
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
- 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
- }
317
+ const ts = new Date().toISOString();
318
+ const sidForState = id;
216
319
 
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
- });
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
- 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
- }
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
- 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
- }
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
- es.onerror = () => {
259
- // SSE error silently close; don't spam the user.
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
- es.close();
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
- if (!snapshot.activeProject) {
294
- toastRef.current?.error('Pick a project in Overview to scope chat sessions.');
295
- return;
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
- }, [snapshot.activeProject, loadChat, refreshSessions, setSessionDisplayState]);
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 (message: string, agent: string, model: string, attachments?: string[]) => {
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
- // Optimistic add
315
- const optimisticId = `msg_${Date.now()}_${Math.random().toString(36).slice(2)}`;
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: message,
705
+ content: trimmed,
320
706
  agent,
321
707
  ts: new Date().toISOString(),
322
708
  };
323
- setOpencodeMessages((cur) => [...cur, optimistic]);
324
- setSending(true);
709
+ appendOpencodeMessage(optimistic);
710
+ markSeen(optimisticId);
711
+ setBusy((b) => ({ ...b, send: true }));
325
712
  try {
326
- const result = await api.post<{ ok: boolean; messageId: string }>(
327
- `/opencode-sessions/${encodeURIComponent(activeOpencodeSessionId)}/send`,
328
- { message, agent },
713
+ await api.post<{ ok: boolean; messageId: string }>(
714
+ `/opencode-sessions/${encodeURIComponent(sid)}/send`,
715
+ {
716
+ message: trimmed,
717
+ agent,
718
+ },
329
719
  );
330
- // SSE echo will dedup on messageId === optimisticId or server-provided id
331
- void result;
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(`Send failed: ${(err as Error).message}`);
725
+ toastRef.current?.error(
726
+ `Send failed: ${(err as Error).message}`,
727
+ );
728
+ return { ok: false };
337
729
  } finally {
338
- setSending(false);
730
+ setBusy((b) => ({ ...b, send: false }));
339
731
  }
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,
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
- 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
- }
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
- } 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);
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
- [activeSource, activeOpencodeSessionId, sessionId, markSessionStreaming],
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(`Regenerate failed: ${(err as Error).message}`);
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
- navigator.clipboard?.writeText(text).then(
421
- () => toastRef.current?.success('Copied.'),
422
- () => toastRef.current?.error('Copy failed.'),
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 (used by the ChatRail row menu) ─────────────────
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
- await api.post(`/chat/sessions/${encodeURIComponent(id)}/rename`, { title });
449
- setSessions((cur) => cur.map((s) => (s.id === id ? { ...s, title } : s)));
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(`Rename failed: ${(err as Error).message}`);
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
- await api.del(`/chat/sessions/${encodeURIComponent(id)}`);
462
- setSessions((cur) => cur.filter((s) => s.id !== id));
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(`Delete failed: ${(err as Error).message}`);
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
- [sessionId],
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
- opencodeEsRef.current?.close();
484
- opencodeEsRef.current = null;
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: activeSource === 'opencode' ? opencodeMessages : bizarMessages,
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
- /** Compute the merged display fields for a session — the rail
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
- // ── Setters for toast integration ───────────────────────────────────────
563
- setToast: (toast: typeof toastRef.current) => {
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
+ }