@polderlabs/bizar 4.2.4 → 4.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -40,6 +40,7 @@ import { createProvidersRouter } from './routes/providers.mjs';
40
40
  import { createSettingsRouter } from './routes/settings.mjs';
41
41
  import { createChatRouter } from './routes/chat.mjs';
42
42
  import { createOpencodeSessionsRouter } from './routes/opencode-sessions.mjs';
43
+ import { createOpencodeSessionDetailRouter } from './routes/opencode-session-detail.mjs';
43
44
  import { createDialogsRouter } from './routes/dialogs.mjs';
44
45
  import { createSkillsRouter } from './routes/skills.mjs';
45
46
  import { createObsidianRouter } from './routes/obsidian.mjs';
@@ -104,6 +105,7 @@ export async function createApiRouter({
104
105
  router.use(createSettingsRouter({ state, broadcast }));
105
106
  router.use(createChatRouter({ state, broadcast }));
106
107
  router.use(createOpencodeSessionsRouter());
108
+ router.use(createOpencodeSessionDetailRouter());
107
109
  router.use(createDialogsRouter({ broadcast }));
108
110
  router.use(createSkillsRouter({ broadcast }));
109
111
  router.use(await createObsidianRouter({ projectRoot }));
@@ -0,0 +1,426 @@
1
+ /**
2
+ * src/server/routes/opencode-session-detail.mjs
3
+ *
4
+ * v4.2.4 — Per-session deep-linking for the opencode sessions list.
5
+ *
6
+ * The dashboard's /api/opencode-sessions endpoint (see opencode-sessions.mjs)
7
+ * returns a flat list of session metadata. When the user clicks a session
8
+ * we need the dashboard to:
9
+ * 1. Render the existing chat UI inside the dashboard tab — not a 404
10
+ * to /opencode/session/:id on the opencode serve child.
11
+ * 2. Fetch the message history for that session.
12
+ * 3. Send new user prompts.
13
+ * 4. Stream live updates (assistant tokens, part updates, idle, errors)
14
+ * filtered to the chosen session.
15
+ *
16
+ * This router exposes three endpoints that sit on top of the opencode
17
+ * serve child described in serve-info.mjs:
18
+ *
19
+ * GET /api/opencode-sessions/:id/messages
20
+ * POST /api/opencode-sessions/:id/send
21
+ * GET /api/opencode-sessions/:id/stream (SSE proxy)
22
+ *
23
+ * The SSE proxy follows the same wire format as `routes-v2/events.mjs`
24
+ * (one event per `event:` + `data:` block, separated by a blank line).
25
+ * The upstream is opencode's global `/event?directory=...` stream — we
26
+ * unwrap each event with `unwrapOpencodeSseEvent` and forward only the
27
+ * ones whose `sessionID` matches the requested id.
28
+ *
29
+ * Concurrency: a module-level counter caps the number of concurrent
30
+ * SSE subscribers per dashboard instance at 50. Beyond that we emit an
31
+ * `error` event and end the stream rather than risk file-descriptor
32
+ * exhaustion when many tabs are open.
33
+ *
34
+ * The directory resolver walks `listOpencodeSessions` first (so a
35
+ * session started from any worktree gets its own directory), then
36
+ * falls back to `info.worktree` (the plugin's cwd). If neither is
37
+ * known we 503.
38
+ */
39
+
40
+ import { Router } from 'express';
41
+ import {
42
+ readServeInfo,
43
+ listOpencodeMessages,
44
+ listOpencodeSessions,
45
+ sendOpencodePrompt,
46
+ unwrapOpencodeSseEvent,
47
+ buildAuthHeader,
48
+ extractContentFromOpencodeMessage,
49
+ } from '../serve-info.mjs';
50
+ import { wrap } from './_shared.mjs';
51
+
52
+ /** Maximum number of concurrent SSE subscribers on this dashboard process. */
53
+ const MAX_SSE_SUBSCRIBERS = 50;
54
+ /** Heartbeat interval (ms) to keep proxies from killing the connection. */
55
+ const SSE_HEARTBEAT_MS = 25_000;
56
+ /** Default timeout for the upstream message listing endpoint. */
57
+ const LIST_MESSAGES_TIMEOUT_MS = 8_000;
58
+ /** Default timeout for the upstream prompt endpoint. */
59
+ const SEND_PROMPT_TIMEOUT_MS = 12_000;
60
+
61
+ /**
62
+ * Resolve the opencode `directory` query parameter for a session.
63
+ *
64
+ * Tries (in order):
65
+ * 1. The session's own `location.directory` from `listOpencodeSessions`.
66
+ * 2. `info.worktree` (the plugin's recorded cwd at startup).
67
+ *
68
+ * Returns `null` when neither is known — callers should 503 in that
69
+ * case so the UI can show "directory unknown" rather than sending the
70
+ * prompt with a blank directory (which the opencode API would 400).
71
+ *
72
+ * @param {ReturnType<typeof readServeInfo>} info
73
+ * @param {string} sessionId
74
+ * @returns {Promise<string|null>}
75
+ */
76
+ async function resolveSessionDirectory(info, sessionId) {
77
+ if (!info) return null;
78
+ try {
79
+ const sessions = await listOpencodeSessions(info, 5_000);
80
+ if (Array.isArray(sessions)) {
81
+ const entry = sessions.find((s) => s && s.id === sessionId);
82
+ const dir = entry?.location?.directory;
83
+ if (typeof dir === 'string' && dir.length > 0) return dir;
84
+ }
85
+ } catch {
86
+ /* fall through to worktree */
87
+ }
88
+ if (typeof info.worktree === 'string' && info.worktree.length > 0) {
89
+ return info.worktree;
90
+ }
91
+ return null;
92
+ }
93
+
94
+ /**
95
+ * Normalize an opencode message into the dashboard's chat shape:
96
+ * { id, role, content, ts }
97
+ *
98
+ * Mirrors `normalizeOpencodeMessage` in serve-info.mjs but is inlined
99
+ * here because the inline shape lets us cheaply build the response
100
+ * without re-parsing the parts twice.
101
+ *
102
+ * @param {object} msg one entry from `listOpencodeMessages().messages`
103
+ * @returns {{id:string, role:string, content:string, ts:number}}
104
+ */
105
+ function toChatMessage(msg) {
106
+ if (!msg) return { id: '', role: 'assistant', content: '', ts: Date.now() };
107
+ const id = msg?.info?.id || msg?.id || '';
108
+ const role = msg?.info?.role || msg?.role || 'assistant';
109
+ const ts =
110
+ msg?.info?.time?.created
111
+ || (typeof msg?.info?.time === 'object' ? msg.info.time.created : null)
112
+ || (typeof msg?.time?.created === 'number' ? msg.time.created : null)
113
+ || Date.now();
114
+ return {
115
+ id: String(id),
116
+ role: String(role),
117
+ content: extractContentFromOpencodeMessage(msg),
118
+ ts: typeof ts === 'number' ? ts : Date.now(),
119
+ };
120
+ }
121
+
122
+ /**
123
+ * @returns {import('express').Router}
124
+ */
125
+ export function createOpencodeSessionDetailRouter() {
126
+ const router = Router();
127
+
128
+ // ---------------------------------------------------------------------
129
+ // GET /opencode-sessions/:id/messages
130
+ //
131
+ // Returns the message history for a session in the dashboard's
132
+ // ChatMessage shape: { id, role, content, ts }.
133
+ // ---------------------------------------------------------------------
134
+ router.get('/opencode-sessions/:id/messages', wrap(async (req, res) => {
135
+ const sessionId = String(req.params?.id || '');
136
+ if (!sessionId) {
137
+ return res.status(400).json({ error: 'bad_request', message: 'session id is required' });
138
+ }
139
+ const info = readServeInfo();
140
+ if (!info) {
141
+ return res.status(503).json({
142
+ error: 'plugin_offline',
143
+ message: 'opencode plugin is not running',
144
+ });
145
+ }
146
+ const directory = await resolveSessionDirectory(info, sessionId);
147
+ if (!directory) {
148
+ return res.status(503).json({
149
+ error: 'directory_unknown',
150
+ message:
151
+ 'Cannot determine the opencode session directory; ensure the opencode plugin is running with serve.json containing worktree.',
152
+ });
153
+ }
154
+ const result = await listOpencodeMessages(info, sessionId, directory, LIST_MESSAGES_TIMEOUT_MS);
155
+ if (!result?.ok) {
156
+ return res.status(502).json({
157
+ error: 'opencode_error',
158
+ message: result?.error || 'unknown error from opencode serve',
159
+ });
160
+ }
161
+ const messages = Array.isArray(result.messages) ? result.messages.map(toChatMessage) : [];
162
+ return res.json({ messages });
163
+ }));
164
+
165
+ // ---------------------------------------------------------------------
166
+ // POST /opencode-sessions/:id/send
167
+ //
168
+ // Body: { message: string, agent: string } — both required.
169
+ // Returns { ok: true, messageId } or an error envelope.
170
+ // ---------------------------------------------------------------------
171
+ router.post('/opencode-sessions/:id/send', wrap(async (req, res) => {
172
+ const sessionId = String(req.params?.id || '');
173
+ if (!sessionId) {
174
+ return res.status(400).json({ error: 'bad_request', message: 'session id is required' });
175
+ }
176
+ const body = req.body && typeof req.body === 'object' ? req.body : {};
177
+ const message = typeof body.message === 'string' ? body.message.trim() : '';
178
+ const agent = typeof body.agent === 'string' ? body.agent.trim() : '';
179
+ if (!message) {
180
+ return res.status(400).json({ error: 'bad_request', message: '`message` is required' });
181
+ }
182
+ if (!agent) {
183
+ return res.status(400).json({ error: 'bad_request', message: '`agent` is required' });
184
+ }
185
+ const info = readServeInfo();
186
+ if (!info) {
187
+ return res.status(503).json({
188
+ error: 'plugin_offline',
189
+ message: 'opencode plugin is not running',
190
+ });
191
+ }
192
+ const directory = await resolveSessionDirectory(info, sessionId);
193
+ if (!directory) {
194
+ return res.status(503).json({
195
+ error: 'directory_unknown',
196
+ message:
197
+ 'Cannot determine the opencode session directory; ensure the opencode plugin is running with serve.json containing worktree.',
198
+ });
199
+ }
200
+ // Synthesize a unique messageID so the client can correlate the
201
+ // prompt with the SSE events that opencode emits for it.
202
+ const messageID = `msg_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
203
+ const result = await sendOpencodePrompt(
204
+ info,
205
+ { sessionId, agent, text: message, messageID },
206
+ directory,
207
+ SEND_PROMPT_TIMEOUT_MS,
208
+ );
209
+ if (!result?.ok) {
210
+ return res.status(502).json({
211
+ error: 'opencode_error',
212
+ message: result?.error || 'unknown error from opencode serve',
213
+ });
214
+ }
215
+ return res.json({ ok: true, messageId: result.messageId });
216
+ }));
217
+
218
+ // ---------------------------------------------------------------------
219
+ // GET /opencode-sessions/:id/stream (SSE proxy)
220
+ //
221
+ // Opens ONE upstream connection to opencode's `/event?directory=...`
222
+ // and forwards only the events whose `sessionID === :id` to the client.
223
+ //
224
+ // Concurrency is capped per-process. A 25s heartbeat keeps the
225
+ // connection alive through reverse proxies (nginx, cloudflare).
226
+ //
227
+ // Implementation notes:
228
+ // - We DON'T import plugins/bizar/src/event-stream.ts because it
229
+ // lives outside this package and is TypeScript. The unwrap logic
230
+ // is ported to serve-info.mjs (`unwrapOpencodeSseEvent`).
231
+ // - We DO mirror the response shape used by `routes-v2/events.mjs`:
232
+ // `event: <type>\ndata: <json>\n\n`.
233
+ // ---------------------------------------------------------------------
234
+ router.get('/opencode-sessions/:id/stream', (req, res) => {
235
+ const sessionId = String(req.params?.id || '');
236
+ if (!sessionId) {
237
+ return res.status(400).json({ error: 'bad_request', message: 'session id is required' });
238
+ }
239
+ if (activeSubscribers >= MAX_SSE_SUBSCRIBERS) {
240
+ res.status(503).json({
241
+ error: 'too_many_subscribers',
242
+ message: `SSE subscriber cap reached (${MAX_SSE_SUBSCRIBERS}); try again later.`,
243
+ });
244
+ return;
245
+ }
246
+
247
+ // SSE headers — mirror routes-v2/events.mjs exactly so proxies that
248
+ // already trust this dashboard's SSE format don't need new rules.
249
+ res.status(200);
250
+ res.setHeader('Content-Type', 'text/event-stream');
251
+ res.setHeader('Cache-Control', 'no-cache, no-transform');
252
+ res.setHeader('Connection', 'keep-alive');
253
+ res.setHeader('X-Accel-Buffering', 'no');
254
+ if (typeof res.flushHeaders === 'function') res.flushHeaders();
255
+
256
+ const info = readServeInfo();
257
+ if (!info) {
258
+ res.write(`event: error\ndata: ${JSON.stringify({ error: 'plugin_offline' })}\n\n`);
259
+ res.end();
260
+ return;
261
+ }
262
+
263
+ activeSubscribers += 1;
264
+
265
+ // Upstream fetch + line-buffered SSE parser. Aborted on client
266
+ // disconnect or when the upstream closes.
267
+ const controller = new AbortController();
268
+ const heartbeat = setInterval(() => {
269
+ if (res.writableEnded || res.destroyed) {
270
+ clearInterval(heartbeat);
271
+ return;
272
+ }
273
+ try {
274
+ res.write(': keepalive\n\n');
275
+ } catch {
276
+ clearInterval(heartbeat);
277
+ }
278
+ }, SSE_HEARTBEAT_MS);
279
+ let closed = false;
280
+ const cleanup = () => {
281
+ if (closed) return;
282
+ closed = true;
283
+ activeSubscribers = Math.max(0, activeSubscribers - 1);
284
+ clearInterval(heartbeat);
285
+ try { controller.abort(); } catch { /* ignore */ }
286
+ if (!res.writableEnded) {
287
+ try { res.end(); } catch { /* ignore */ }
288
+ }
289
+ };
290
+ req.on('close', cleanup);
291
+
292
+ const upstreamUrl = `${info.baseUrl}/event?directory=${encodeURIComponent(info.worktree || '')}`;
293
+ let upstream;
294
+ try {
295
+ upstream = fetch(upstreamUrl, {
296
+ method: 'GET',
297
+ headers: {
298
+ Accept: 'text/event-stream',
299
+ Authorization: buildAuthHeader(info),
300
+ },
301
+ signal: controller.signal,
302
+ });
303
+ } catch (err) {
304
+ const msg = err instanceof Error ? err.message : String(err);
305
+ res.write(`event: error\ndata: ${JSON.stringify({ error: 'upstream_open_failed', message: msg })}\n\n`);
306
+ cleanup();
307
+ return;
308
+ }
309
+
310
+ void (async () => {
311
+ try {
312
+ const r = await upstream;
313
+ if (!r.ok || !r.body) {
314
+ res.write(`event: error\ndata: ${JSON.stringify({
315
+ error: 'upstream_status',
316
+ status: r.status,
317
+ })}\n\n`);
318
+ cleanup();
319
+ return;
320
+ }
321
+ await pumpSseStream(r.body, res, sessionId);
322
+ } catch (err) {
323
+ if (controller.signal.aborted) return; // client disconnected, already cleaned up
324
+ const msg = err instanceof Error ? err.message : String(err);
325
+ if (!res.writableEnded) {
326
+ try {
327
+ res.write(`event: error\ndata: ${JSON.stringify({ error: 'upstream_read_error', message: msg })}\n\n`);
328
+ } catch { /* ignore */ }
329
+ }
330
+ } finally {
331
+ cleanup();
332
+ }
333
+ })();
334
+ });
335
+
336
+ return router;
337
+ }
338
+
339
+ /** Process-wide counter for active SSE subscribers. */
340
+ let activeSubscribers = 0;
341
+
342
+ /**
343
+ * Pump an upstream SSE ReadableStream, parse blocks, unwrap opencode
344
+ * events, filter by sessionID, and forward to `res`.
345
+ *
346
+ * SSE blocks are separated by a blank line (`\n\n` or `\r\n\r\n`).
347
+ * Within a block, lines look like `event: <name>` and `data: <json>`.
348
+ *
349
+ * @param {ReadableStream<Uint8Array>} body
350
+ * @param {import('express').Response} res
351
+ * @param {string} sessionId only forward events for this session
352
+ */
353
+ async function pumpSseStream(body, res, sessionId) {
354
+ const reader = body.getReader();
355
+ const decoder = new TextDecoder('utf-8');
356
+ let buffer = '';
357
+ try {
358
+ while (true) {
359
+ const { value, done } = await reader.read();
360
+ if (done) break;
361
+ if (value && value.byteLength > 0) {
362
+ buffer += decoder.decode(value, { stream: true });
363
+ }
364
+ let sep;
365
+ while ((sep = buffer.indexOf('\n\n')) >= 0 || (sep = buffer.indexOf('\r\n\r\n')) >= 0) {
366
+ const isCRLF = buffer[sep] === '\r';
367
+ const block = buffer.slice(0, sep);
368
+ buffer = buffer.slice(sep + (isCRLF ? 4 : 2));
369
+ handleSseBlock(block, res, sessionId);
370
+ }
371
+ }
372
+ } finally {
373
+ try { reader.releaseLock(); } catch { /* ignore */ }
374
+ }
375
+ }
376
+
377
+ /**
378
+ * Parse one SSE block and forward the event to the client if it
379
+ * matches the requested session.
380
+ *
381
+ * @param {string} block
382
+ * @param {import('express').Response} res
383
+ * @param {string} sessionId
384
+ */
385
+ function handleSseBlock(block, res, sessionId) {
386
+ if (!block || block.trim() === '') return;
387
+ let eventName = null;
388
+ const dataLines = [];
389
+ for (const line of block.split(/\r?\n/)) {
390
+ if (line === '' || line.startsWith(':')) continue;
391
+ const colon = line.indexOf(':');
392
+ if (colon < 0) continue;
393
+ const field = line.slice(0, colon);
394
+ let value = line.slice(colon + 1);
395
+ if (value.startsWith(' ')) value = value.slice(1);
396
+ if (field === 'event') eventName = value;
397
+ else if (field === 'data') dataLines.push(value);
398
+ }
399
+ if (dataLines.length === 0) return;
400
+ const raw = dataLines.join('\n');
401
+ let parsed;
402
+ try {
403
+ parsed = JSON.parse(raw);
404
+ } catch {
405
+ return; // non-JSON data: drop silently
406
+ }
407
+ const evt = unwrapOpencodeSseEvent(eventName, parsed);
408
+ if (!evt || !evt.type) return;
409
+ // Filter to the requested session. Events without a sessionID
410
+ // (e.g. opencode's own server-wide pings) are dropped.
411
+ if (!evt.sessionID || evt.sessionID !== sessionId) return;
412
+ if (res.writableEnded || res.destroyed) return;
413
+ const payload = JSON.stringify({
414
+ type: evt.type,
415
+ sessionID: evt.sessionID,
416
+ ...(evt.messageID ? { messageID: evt.messageID } : {}),
417
+ ...(evt.part ? { part: evt.part } : {}),
418
+ data: evt.data,
419
+ });
420
+ try {
421
+ res.write(`event: ${evt.type}\n`);
422
+ res.write(`data: ${payload}\n\n`);
423
+ } catch {
424
+ /* socket closed mid-write */
425
+ }
426
+ }
@@ -330,8 +330,12 @@ const DEFAULT_TIMEOUT_MS = 8_000;
330
330
  * Build the Authorization header for the opencode serve child. The wire
331
331
  * format is `Basic base64("opencode:<password>")` (matches what the
332
332
  * plugin's own HttpClient uses; see plugins/bizar/src/http-client.ts).
333
+ *
334
+ * v4.2.4 — exported so the SSE proxy in `routes/opencode-session-detail.mjs`
335
+ * can authenticate upstream fetches without duplicating the formula.
333
336
  */
334
- function buildAuthHeader(info) {
337
+ export function buildAuthHeader(info) {
338
+ if (!info || typeof info.password !== 'string') return '';
335
339
  const creds = `opencode:${info.password}`;
336
340
  return `Basic ${Buffer.from(creds).toString('base64')}`;
337
341
  }
@@ -682,3 +686,122 @@ export function pingOpencodeServe(info, timeoutMs = 1_500) {
682
686
  });
683
687
  });
684
688
  }
689
+
690
+ // ── v4.2.4 — SSE event unwrap (port from plugins/bizar/src/event-stream.ts) ──
691
+ //
692
+ // The dashboard's chat UI subscribes to opencode's `/event?directory=...`
693
+ // SSE stream to receive live updates for a chosen session. The wire
694
+ // format has changed across opencode versions; this helper normalizes
695
+ // both shapes into a single `{type, sessionID?, messageID?, part?, data}`
696
+ // envelope that the SSE proxy can filter on `sessionID` and forward.
697
+ //
698
+ // Wire format 1 (direct, older opencode):
699
+ // event: session.created
700
+ // data: {"type":"session.created","properties":{"sessionID":"abc","title":"…"}}
701
+ //
702
+ // Wire format 2 (sync envelope, newer opencode):
703
+ // event: sync
704
+ // data: {"type":"sync","syncEvent":{"type":"session.created.1","data":{"sessionID":"abc"}}}
705
+ //
706
+ // Sync event `type` carries a `.<n>` version suffix (e.g. `session.created.1`).
707
+ // We strip it so downstream code can match on `session.created`.
708
+ // Field names inside the payload have also varied across builds:
709
+ // - `sessionID` vs `sessionId` vs `session_id` (and nested under `properties`)
710
+ // - `messageID` vs `messageId` vs `message_id`
711
+ // We accept any of them defensively.
712
+ //
713
+ // Reference: plugins/bizar/src/event-stream.ts:340-399 (the TS plugin's
714
+ // `dispatchEvent` method). We re-implement the same logic in plain JS
715
+ // here so the dashboard server can use it without importing TS code.
716
+
717
+ /**
718
+ * Unwrap and normalize a raw opencode SSE event payload.
719
+ *
720
+ * v0.4.3 wire formats accepted (see plugins/bizar/src/event-stream.ts:365-400):
721
+ * 1. Direct: `{type, properties: {sessionID, ...}}` — newer opencode uses
722
+ * `data` instead of `properties`. We accept either field name.
723
+ * 2. Sync envelope: `{type: "sync", syncEvent: {type: "x.y.1", data: {...}}}`.
724
+ * Unwrap to inner data; strip the `.1` version suffix from the type.
725
+ *
726
+ * Returns `null` if the event isn't a recognizable opencode event shape
727
+ * (e.g. non-object, missing type).
728
+ *
729
+ * @param {string|null} eventName the SSE `event:` field (may be null)
730
+ * @param {unknown} data the parsed JSON `data:` payload
731
+ * @returns {{type:string, sessionID?:string, messageID?:string, part?:object, data?:object}|null}
732
+ */
733
+ export function unwrapOpencodeSseEvent(eventName, data) {
734
+ if (!data || typeof data !== 'object' || Array.isArray(data)) return null;
735
+ let obj = /** @type {Record<string, unknown>} */ (data);
736
+
737
+ // Step 1: detect sync wrapper and unwrap. After this, `obj` points at
738
+ // the inner `syncEvent.data` (or remains the original on partial shape).
739
+ let innerType = null;
740
+ if (obj.type === 'sync' && obj.syncEvent && typeof obj.syncEvent === 'object') {
741
+ const syncEvent = /** @type {Record<string, unknown>} */ (obj.syncEvent);
742
+ if (typeof syncEvent.type === 'string') innerType = syncEvent.type;
743
+ if (syncEvent.data && typeof syncEvent.data === 'object') {
744
+ obj = /** @type {Record<string, unknown>} */ (syncEvent.data);
745
+ }
746
+ }
747
+
748
+ // Step 2: resolve the event type, preferring the inner sync type.
749
+ // Strip the trailing `.<digits>` version suffix.
750
+ const typeFromObj = innerType ?? (typeof obj.type === 'string' ? obj.type : null);
751
+ const rawType = typeFromObj ?? eventName ?? null;
752
+ if (typeof rawType !== 'string' || rawType.length === 0) return null;
753
+ const type = stripVersionSuffix(rawType);
754
+
755
+ // Step 3: extract identifiers from any of the common key spellings.
756
+ const sessionID = pickString(obj, [
757
+ 'sessionID', 'sessionId', 'session_id',
758
+ ], ['properties']);
759
+ const messageID = pickString(obj, [
760
+ 'messageID', 'messageId', 'message_id',
761
+ ], ['properties']);
762
+ const part = obj.part && typeof obj.part === 'object'
763
+ ? /** @type {object} */ (obj.part)
764
+ : undefined;
765
+
766
+ return { type, sessionID, messageID, part, data: obj };
767
+ }
768
+
769
+ /**
770
+ * Strip the trailing `.<digits>` version suffix from an event type
771
+ * (e.g. `session.created.1` → `session.created`).
772
+ *
773
+ * @param {string} s
774
+ * @returns {string}
775
+ */
776
+ export function stripVersionSuffix(s) {
777
+ if (typeof s !== 'string') return '';
778
+ return s.replace(/\.\d+$/, '');
779
+ }
780
+
781
+ /**
782
+ * Look up a string field on `obj`, falling back to any nested object
783
+ * whose key is in `nestedKeys` (e.g. `['properties']`).
784
+ *
785
+ * @param {Record<string, unknown>} obj
786
+ * @param {string[]} keys top-level keys to check first
787
+ * @param {string[]} [nestedKeys] optional parents to recurse into
788
+ * @returns {string|undefined}
789
+ */
790
+ function pickString(obj, keys, nestedKeys) {
791
+ for (const k of keys) {
792
+ const v = obj[k];
793
+ if (typeof v === 'string' && v.length > 0) return v;
794
+ }
795
+ if (nestedKeys) {
796
+ for (const nk of nestedKeys) {
797
+ const nested = obj[nk];
798
+ if (nested && typeof nested === 'object') {
799
+ for (const k of keys) {
800
+ const v = /** @type {Record<string, unknown>} */ (nested)[k];
801
+ if (typeof v === 'string' && v.length > 0) return v;
802
+ }
803
+ }
804
+ }
805
+ }
806
+ return undefined;
807
+ }
@@ -11,20 +11,27 @@ interface Props {
11
11
  sessions: ChatSession[];
12
12
  opencodeSessions: ChatSession[];
13
13
  activeSessionId: string;
14
+ /** The opencode session currently displayed, or null. */
15
+ activeOpencodeSessionId: string | null;
14
16
  activeProject: { name: string } | null;
15
17
  creating: boolean;
16
18
  onCreateSession: () => void;
19
+ /** Called when a bizar session is selected. */
17
20
  onSelectSession: (id: string) => void;
21
+ /** Called when an opencode session is selected — renders it in-dash. */
22
+ onSelectOpencodeSession: (s: ChatSession) => void;
18
23
  }
19
24
 
20
25
  export function SessionList({
21
26
  sessions,
22
27
  opencodeSessions,
23
28
  activeSessionId,
29
+ activeOpencodeSessionId,
24
30
  activeProject,
25
31
  creating,
26
32
  onCreateSession,
27
33
  onSelectSession,
34
+ onSelectOpencodeSession,
28
35
  }: Props) {
29
36
  const [view, setView] = useState<'bizar' | 'all'>('all');
30
37
  const [showAll, setShowAll] = useState(false);
@@ -38,8 +45,9 @@ export function SessionList({
38
45
  const totalOpencode = opencodeSessions.length;
39
46
 
40
47
  const handleSelectSession = (s: ChatSession) => {
41
- if (s.source === 'opencode' && s.opencodeUrl) {
42
- window.open(s.opencodeUrl, '_blank', 'noopener,noreferrer');
48
+ if (s.source === 'opencode') {
49
+ // Render opencode session in-dash — no window.open redirect
50
+ onSelectOpencodeSession(s);
43
51
  return;
44
52
  }
45
53
  onSelectSession(s.id);
@@ -113,14 +121,16 @@ export function SessionList({
113
121
  ) : (
114
122
  <ul className="chat-sessions-list">
115
123
  {displayedSessions.map((s) => {
116
- const isActive = s.source !== 'opencode' && activeSessionId === s.id;
117
124
  const isOpencode = s.source === 'opencode';
125
+ const isActive = isOpencode
126
+ ? activeOpencodeSessionId === s.id
127
+ : activeSessionId === s.id;
118
128
  return (
119
129
  <li
120
130
  key={isOpencode ? `oc-${s.id}` : s.id}
121
131
  className={`chat-sessions-item ${isActive ? 'active' : ''} ${isOpencode ? 'chat-sessions-item-opencode' : ''}`}
122
132
  onClick={() => handleSelectSession(s)}
123
- title={isOpencode ? `Open in opencode UI: ${s.title || s.id}` : s.id}
133
+ title={isOpencode ? `Open in dashboard: ${s.title || s.id}` : s.id}
124
134
  >
125
135
  {isOpencode && (
126
136
  <ExternalLink size={10} className="chat-sessions-item-icon" />
@@ -150,4 +160,4 @@ export function SessionList({
150
160
  )}
151
161
  </div>
152
162
  );
153
- }
163
+ }