@stratusagent/dashboard 0.6.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.
@@ -0,0 +1,704 @@
1
+ import { ago, el } from '../lib/dom.js';
2
+ import { api } from '../lib/api.js';
3
+ import { avatar } from '../lib/avatar.js';
4
+ import { onEnvelope, refreshCore, store } from '../app.js';
5
+
6
+ /** A conversation started from this page, distinguishable from a Slack thread. */
7
+ const newSessionId = (agentId) => `web:${agentId}:${crypto.randomUUID()}`;
8
+
9
+ /**
10
+ * Session ids are addresses, not names — a Slack thread key or a UUID. Show
11
+ * enough to tell two apart without letting one eat the row it sits in.
12
+ */
13
+ const shortId = (id) => (id.length <= 28 ? id : `${id.slice(0, 12)}…${id.slice(-8)}`);
14
+
15
+ const TOOL_OUTPUT_LIMIT = 260;
16
+
17
+ /**
18
+ * A tool result, as a person would want to read it.
19
+ *
20
+ * The stored message is the raw result envelope — call id, tool name, ok
21
+ * flag, output — because that is what the model consumed. Rendering it
22
+ * verbatim puts a wall of JSON with an internal id in the middle of a
23
+ * conversation, so this pulls out the part a reader is actually asking about
24
+ * and leaves the rest to the transcript in the store.
25
+ */
26
+ const summariseTool = (content) => {
27
+ const text = String(content ?? '');
28
+ let parsed;
29
+ try {
30
+ parsed = JSON.parse(text);
31
+ } catch {
32
+ return text.length > TOOL_OUTPUT_LIMIT ? `${text.slice(0, TOOL_OUTPUT_LIMIT)}…` : text;
33
+ }
34
+ if (typeof parsed !== 'object' || parsed === null || !parsed.toolName) {
35
+ return text.length > TOOL_OUTPUT_LIMIT ? `${text.slice(0, TOOL_OUTPUT_LIMIT)}…` : text;
36
+ }
37
+ const outcome = parsed.ok === false
38
+ ? `failed — ${parsed.error ?? 'no reason given'}`
39
+ : typeof parsed.output === 'string' ? parsed.output : JSON.stringify(parsed.output);
40
+ const body = String(outcome ?? '');
41
+ return `${parsed.toolName} → ${body.length > TOOL_OUTPUT_LIMIT ? `${body.slice(0, TOOL_OUTPUT_LIMIT)}…` : body}`;
42
+ };
43
+
44
+ export const renderAgent = (agentId, initialSessionId) => {
45
+ const state = {
46
+ tab: 'chat',
47
+ sessions: [],
48
+ sessionId: initialSessionId,
49
+ /**
50
+ * True while this conversation exists only in the page.
51
+ *
52
+ * A session id minted here has nothing stored behind it until the first
53
+ * message lands, so asking the daemon for it would 404 — correctly, and
54
+ * confusingly: a red line in the console for the ordinary act of opening
55
+ * a new conversation.
56
+ */
57
+ unsent: initialSessionId === undefined,
58
+ /** Canonical history, re-read from the daemon whenever a turn settles. */
59
+ messages: [],
60
+ /** The reply currently arriving, token by token. */
61
+ streaming: '',
62
+ /** Tool status lines for the turn in flight. */
63
+ toolLines: [],
64
+ turnId: undefined,
65
+ /**
66
+ * Envelopes that arrived before the POST said which turn is ours.
67
+ *
68
+ * The dispatch starts before the response is read, so its first events
69
+ * can beat the turn id here. Dropping them would lose the opening tokens
70
+ * of every reply; accepting them blind would mix in another client's
71
+ * turn on the same conversation. So they wait, and are replayed once the
72
+ * id lands.
73
+ */
74
+ pending: [],
75
+ sending: false,
76
+ /**
77
+ * Conversations whose POST is unanswered, by session id.
78
+ *
79
+ * Distinct from `sending`, which stays true for the whole turn: this is
80
+ * the window in which the store cannot answer for the turn at all — a new
81
+ * conversation has nothing stored, and an existing one still carries the
82
+ * *previous* turn's terminal status until the dispatch saves. Anything
83
+ * that reconciles against the store during it would be reading about a
84
+ * turn that has not started.
85
+ *
86
+ * Keyed rather than a flag, because sends overlap across conversations: a
87
+ * message to A, then a switch to B and a message there, leaves two in
88
+ * flight — and A's response clearing a shared flag would tell this view
89
+ * that B has nothing pending, which is exactly when clearing B's turn
90
+ * does damage.
91
+ */
92
+ awaitingSend: new Set(),
93
+ /**
94
+ * Conversations that owe a reconciliation once their POST settles.
95
+ *
96
+ * `reconcile` cannot decide anything while a send is outstanding — the
97
+ * store still reads as the previous turn — so it returns. But nothing
98
+ * replays what the stream missed, and no second reconnect is coming, so
99
+ * a turn that finished during the outage would leave the composer on
100
+ * "Working…" for good. The debt is recorded here and paid the moment the
101
+ * response lands, which is the earliest point anything can decide.
102
+ */
103
+ reconcileAfterSend: new Set(),
104
+ /**
105
+ * The turn this page started on each conversation, by session id.
106
+ *
107
+ * `turnId` above is only the selected conversation's. Switching away and
108
+ * back used to clear it, which enabled the composer on a turn that was
109
+ * still running and made the rest of that turn's envelopes fail
110
+ * `isOurTurn` — so its reply rendered nowhere and its completion cleared
111
+ * nothing. An entry lives from the POST that minted it until that turn
112
+ * ends, or until the store says it already has.
113
+ */
114
+ turns: new Map(),
115
+ error: undefined,
116
+ saved: undefined,
117
+ /**
118
+ * The agent's full soul, fetched for editing.
119
+ *
120
+ * The roster carries `persona`: the first line of the instructions,
121
+ * trimmed to fit a table row. Seeding the editor from it and saving
122
+ * would write that snippet back as the whole persona, so the settings
123
+ * tab waits for the real thing.
124
+ */
125
+ soul: undefined,
126
+ soulError: undefined,
127
+ };
128
+
129
+ const node = el('div', { class: 'main' });
130
+ const transcriptBox = el('div', { class: 'transcript' });
131
+ const composer = el('textarea', {
132
+ placeholder: 'Message this agent…',
133
+ rows: 2,
134
+ onKeyDown: (event) => {
135
+ if (event.key === 'Enter' && !event.shiftKey) {
136
+ event.preventDefault();
137
+ void send();
138
+ }
139
+ },
140
+ });
141
+
142
+ const agent = () => store.agents.find((candidate) => candidate.id === agentId);
143
+
144
+ // ---- transcript --------------------------------------------------------
145
+
146
+ const bubbles = () => {
147
+ const nodes = [];
148
+ for (const message of state.messages) {
149
+ if (message.role === 'system') {
150
+ continue;
151
+ }
152
+ if (message.role === 'tool') {
153
+ nodes.push(el('div', { class: 'bubble tool' }, summariseTool(message.content)));
154
+ continue;
155
+ }
156
+ if (!message.content) {
157
+ // An assistant turn that only carried tool calls has no text to show;
158
+ // the tool bubbles beside it are the story.
159
+ continue;
160
+ }
161
+ nodes.push(el('div', { class: `bubble ${message.role}` }, message.content));
162
+ }
163
+ for (const line of state.toolLines) {
164
+ nodes.push(el('div', { class: 'bubble tool' }, line));
165
+ }
166
+ if (state.streaming) {
167
+ nodes.push(el('div', { class: 'bubble assistant streaming' }, state.streaming));
168
+ }
169
+ return nodes;
170
+ };
171
+
172
+ const paintTranscript = () => {
173
+ // Pinned to the bottom only when the reader already was: yanking someone
174
+ // back down while they are reading earlier output is worse than a reply
175
+ // they have to scroll to.
176
+ const pinned = transcriptBox.scrollHeight - transcriptBox.scrollTop - transcriptBox.clientHeight < 60;
177
+ transcriptBox.replaceChildren(...bubbles());
178
+ if (pinned) {
179
+ transcriptBox.scrollTop = transcriptBox.scrollHeight;
180
+ }
181
+ };
182
+
183
+ // ---- data --------------------------------------------------------------
184
+
185
+ const loadSessions = async () => {
186
+ const listed = await api.sessions(agentId, 50).catch(() => ({ sessions: [] }));
187
+ state.sessions = listed.sessions ?? [];
188
+ if (!state.sessionId) {
189
+ const newest = state.sessions[0]?.id;
190
+ state.sessionId = newest ?? newSessionId(agentId);
191
+ state.unsent = newest === undefined;
192
+ }
193
+ await loadTranscript();
194
+ };
195
+
196
+ const loadTranscript = async () => {
197
+ if (!state.sessionId) {
198
+ return;
199
+ }
200
+ if (state.unsent) {
201
+ // Nothing to fetch: this conversation has never been sent anywhere.
202
+ state.messages = [];
203
+ state.streaming = '';
204
+ state.toolLines = [];
205
+ paintTranscript();
206
+ update();
207
+ return;
208
+ }
209
+ // Which conversation this fetch is for. Opening B while A is still in
210
+ // flight would otherwise land A's history under B's selector, and the
211
+ // next message typed would go to B while showing A — so a response that
212
+ // arrives after the selection moved on is dropped.
213
+ const requested = state.sessionId;
214
+ let messages;
215
+ try {
216
+ const { session } = await api.session(requested);
217
+ messages = session.messages ?? [];
218
+ } catch {
219
+ // Raced with a turn that has not saved yet, or a session removed out
220
+ // from under the page. An empty transcript is the honest render.
221
+ messages = [];
222
+ }
223
+ if (state.sessionId !== requested) {
224
+ return;
225
+ }
226
+ state.messages = messages;
227
+ state.streaming = '';
228
+ state.toolLines = [];
229
+ paintTranscript();
230
+ update();
231
+ };
232
+
233
+ /**
234
+ * The full soul, read once and only when the settings tab needs it.
235
+ *
236
+ * Guarded rather than awaited from the render, because `update()` runs on
237
+ * every event from every agent and an unguarded fetch there would open one
238
+ * request per event.
239
+ */
240
+ let soulLoading = false;
241
+ const loadSoul = async () => {
242
+ if (soulLoading || state.soul) {
243
+ return;
244
+ }
245
+ soulLoading = true;
246
+ try {
247
+ state.soul = await api.agent(agentId);
248
+ } catch (error) {
249
+ state.soulError = `Could not read this agent's soul file: ${error.message}`;
250
+ } finally {
251
+ soulLoading = false;
252
+ }
253
+ update();
254
+ };
255
+
256
+ const openSession = (sessionId) => {
257
+ state.sessionId = sessionId;
258
+ state.unsent = false;
259
+ state.pending = [];
260
+ // Restored, not cleared: this page may still be waiting on a turn here.
261
+ state.turnId = state.turns.get(sessionId);
262
+ state.sending = state.turnId !== undefined || state.awaitingSend.has(sessionId);
263
+ state.tab = 'chat';
264
+ void loadTranscript();
265
+ if (state.sending) {
266
+ // It may equally have finished while we were looking elsewhere — the
267
+ // completion only reaches `applyEnvelope` for the selected
268
+ // conversation. The store is what settles that, and `reconcile` already
269
+ // knows how to ask it without disturbing a turn that is genuinely live.
270
+ void reconcile();
271
+ }
272
+ };
273
+
274
+ const startSession = () => {
275
+ state.sessionId = newSessionId(agentId);
276
+ state.unsent = true;
277
+ state.pending = [];
278
+ state.turnId = undefined;
279
+ state.sending = false;
280
+ state.messages = [];
281
+ state.streaming = '';
282
+ state.toolLines = [];
283
+ paintTranscript();
284
+ update();
285
+ };
286
+
287
+ const send = async () => {
288
+ const text = composer.value.trim();
289
+ // No session id yet means the first `/sessions` listing has not come back,
290
+ // so we do not know whether to resume the newest conversation or mint a
291
+ // new one. Sending anyway posted to `/sessions/undefined/messages`, which
292
+ // the daemon accepts — creating a durable conversation literally named
293
+ // `undefined` — and whose response the arriving listing then discards.
294
+ // The button is disabled for this window; Enter reaches here regardless.
295
+ if (!text || state.sending || !state.sessionId) {
296
+ return;
297
+ }
298
+ state.sending = true;
299
+ state.error = undefined;
300
+ composer.value = '';
301
+ // Which conversation this message is for. Opening or starting another one
302
+ // while the POST is in flight would otherwise stamp this turn's id onto
303
+ // that conversation — so its own next message would drain its opening
304
+ // events against a turn it never sent, and a brand-new conversation would
305
+ // be marked as stored when nothing had been written for it.
306
+ const sentTo = state.sessionId;
307
+ state.awaitingSend.add(sentTo);
308
+ // Shown immediately rather than waiting for the round trip: the daemon
309
+ // will persist exactly this, and a message that vanishes for a second
310
+ // reads as a dropped one.
311
+ const optimistic = { id: `local:${crypto.randomUUID()}`, role: 'user', content: text };
312
+ state.messages = [...state.messages, optimistic];
313
+ state.streaming = '';
314
+ state.toolLines = [];
315
+ paintTranscript();
316
+ update();
317
+
318
+ try {
319
+ const { turnId } = await api.send(sentTo, { message: text, agentId });
320
+ state.awaitingSend.delete(sentTo);
321
+ // Recorded before the selection check, so a turn started here is
322
+ // recoverable even when the user walked away before it was answered.
323
+ state.turns.set(sentTo, turnId);
324
+ if (state.sessionId !== sentTo) {
325
+ // The view moved on, and `openSession`/`startSession` already reset
326
+ // the in-flight state it would have reconciled.
327
+ state.reconcileAfterSend.delete(sentTo);
328
+ return;
329
+ }
330
+ // It exists in the store now, so later reads are real reads.
331
+ state.unsent = false;
332
+ // Kept so deltas can be attributed to *this* message: a session runs
333
+ // its turns in sequence, and without the id there is no telling our
334
+ // reply from the next one.
335
+ state.turnId = turnId;
336
+ const queued = state.pending;
337
+ state.pending = [];
338
+ for (const envelope of queued) {
339
+ applyEnvelope(envelope);
340
+ }
341
+ if (state.reconcileAfterSend.delete(sentTo)) {
342
+ // The stream came back while this POST was in flight. Now there is a
343
+ // turn id and a stored session, so the store can finally say whether
344
+ // the turn we are waiting on already ended during the outage.
345
+ await reconcile();
346
+ }
347
+ } catch (error) {
348
+ state.awaitingSend.delete(sentTo);
349
+ // Nothing ran, so there is nothing to reconcile against.
350
+ state.reconcileAfterSend.delete(sentTo);
351
+ if (state.sessionId !== sentTo) {
352
+ return;
353
+ }
354
+ // Nothing was stored, so nothing should look stored. Left in place, the
355
+ // bubble reads as a sent message forever, and the next message that
356
+ // does succeed renders after a turn that never existed. The text goes
357
+ // back in the composer rather than being lost with it.
358
+ state.messages = state.messages.filter((message) => message !== optimistic);
359
+ if (!composer.value) {
360
+ composer.value = text;
361
+ }
362
+ state.error = error.message;
363
+ state.sending = false;
364
+ paintTranscript();
365
+ update();
366
+ }
367
+ };
368
+
369
+ // ---- live --------------------------------------------------------------
370
+
371
+ /**
372
+ * Whether this envelope belongs to the turn this page started.
373
+ *
374
+ * A conversation is shared: another browser, a Slack thread, or the CLI can
375
+ * be mid-turn on the same session. Without this check their completion
376
+ * clears our composer and reloads the transcript while our message is still
377
+ * queued, and their tokens land in the reply we are rendering.
378
+ */
379
+ const isOurTurn = (envelope) => envelope.turnId !== undefined && envelope.turnId === state.turnId;
380
+
381
+ const applyEnvelope = (envelope) => {
382
+ const event = envelope.event;
383
+
384
+ // Anything that changes the stored conversation is worth re-reading even
385
+ // when somebody else caused it — it is the same conversation. Only the
386
+ // in-flight rendering below is ours alone.
387
+ if (!isOurTurn(envelope)) {
388
+ if (event.type === 'session.completed' || event.type === 'session.failed') {
389
+ void loadTranscript();
390
+ void loadSessions();
391
+ }
392
+ return;
393
+ }
394
+
395
+ switch (event.type) {
396
+ case 'provider.delta':
397
+ if (event.delta.type === 'text') {
398
+ state.streaming += event.delta.text;
399
+ paintTranscript();
400
+ } else if (event.delta.type === 'reset') {
401
+ // The provider abandoned this attempt — a fallback taking over, or
402
+ // a retry. Everything streamed so far belongs to an answer that is
403
+ // not coming, so it goes rather than being fused with the next one.
404
+ state.streaming = '';
405
+ paintTranscript();
406
+ }
407
+ break;
408
+ case 'tool.called':
409
+ state.toolLines = [...state.toolLines, `running ${event.call.toolName}…`];
410
+ paintTranscript();
411
+ break;
412
+ case 'tool.completed':
413
+ state.toolLines = [...state.toolLines, `${event.result.toolName} ${event.result.ok ? 'finished' : 'failed'}`];
414
+ paintTranscript();
415
+ break;
416
+ case 'tool.denied':
417
+ state.toolLines = [...state.toolLines, `${event.call.toolName} was denied`];
418
+ paintTranscript();
419
+ break;
420
+ case 'tool.approval-requested':
421
+ state.toolLines = [...state.toolLines, `${event.call.toolName} is waiting for approval`];
422
+ paintTranscript();
423
+ break;
424
+ case 'session.failed':
425
+ state.error = event.error;
426
+ state.sending = false;
427
+ state.turnId = undefined;
428
+ state.turns.delete(envelope.sessionId);
429
+ void loadTranscript();
430
+ break;
431
+ case 'session.completed':
432
+ state.sending = false;
433
+ state.turnId = undefined;
434
+ state.turns.delete(envelope.sessionId);
435
+ // Re-read rather than keeping what was streamed: the stored
436
+ // transcript is what every other surface will show, and a page that
437
+ // quietly disagrees with it is worse than one that flickers.
438
+ void loadTranscript();
439
+ void loadSessions();
440
+ break;
441
+ default:
442
+ break;
443
+ }
444
+ };
445
+
446
+ const detach = onEnvelope((envelope) => {
447
+ if (envelope.sessionId !== state.sessionId) {
448
+ return;
449
+ }
450
+ if (state.sending && state.turnId === undefined) {
451
+ // Our own dispatch is in flight and has not told us its id yet.
452
+ state.pending.push(envelope);
453
+ return;
454
+ }
455
+ applyEnvelope(envelope);
456
+ });
457
+
458
+ // ---- tabs --------------------------------------------------------------
459
+
460
+ const chatTab = () => el('div', { class: 'chat' },
461
+ transcriptBox,
462
+ state.error ? el('div', { class: 'notice bad' }, state.error) : null,
463
+ el('div', { class: 'composer' },
464
+ composer,
465
+ el('button', { class: 'primary', disabled: state.sending || !state.sessionId, onClick: () => void send() },
466
+ state.sending ? 'Working…' : 'Send'),
467
+ ),
468
+ );
469
+
470
+ const activityTab = () => {
471
+ const ours = new Set(state.sessions.map((session) => session.id));
472
+ ours.add(state.sessionId);
473
+ const lines = store.activity.filter((line) => ours.has(line.sessionId)).slice(0, 80);
474
+ return lines.length === 0
475
+ ? el('p', { class: 'empty' }, 'Nothing yet for this agent.')
476
+ : el('div', { class: 'activity' }, ...lines.map((line) => el('div', { class: 'line' },
477
+ el('span', { class: 'when' }, ago(new Date(line.at).toISOString())),
478
+ el('span', { class: 'what', title: line.sessionId },
479
+ line.agent ? `${line.agent} ${line.text}` : line.text),
480
+ )));
481
+ };
482
+
483
+ /**
484
+ * The settings form, built once and kept.
485
+ *
486
+ * `update()` runs on every event from every agent, and rebuilding these
487
+ * inputs from the last server values would erase whatever someone had
488
+ * typed the moment anything anywhere emitted an event. Text people are
489
+ * editing is state, not a projection — so the nodes outlive the render
490
+ * that created them, exactly as the composer does.
491
+ */
492
+ let settingsForm;
493
+
494
+ const buildSettingsForm = (current, soul) => {
495
+ const name = el('input', { value: soul.agent?.name ?? current.name });
496
+ // The stored instructions in full, never the roster's one-line snippet.
497
+ const instructions = el('textarea', { rows: 8, value: soul.agent?.instructions ?? '' });
498
+ const provider = el('input', { value: soul.provider ?? '', placeholder: 'follows your setup' });
499
+ const model = el('input', { value: soul.model ?? '', placeholder: 'follows your setup' });
500
+
501
+ const save = async () => {
502
+ state.saved = undefined;
503
+ state.error = undefined;
504
+ try {
505
+ await api.updateAgent(agentId, {
506
+ name: name.value,
507
+ instructions: instructions.value,
508
+ // An empty string clears a pin; leaving it alone keeps it.
509
+ provider: provider.value.trim(),
510
+ model: model.value.trim(),
511
+ });
512
+ await refreshCore();
513
+ state.saved = 'Saved. The next turn in every conversation uses it.';
514
+ } catch (error) {
515
+ state.error = error.message;
516
+ }
517
+ settingsForm?.refreshNotice();
518
+ };
519
+
520
+ // The one part that does change per render: whether the last save
521
+ // succeeded. Kept as its own node so the fields above are never touched.
522
+ const notice = el('div', {});
523
+ const node = el('div', {},
524
+ el('div', { class: 'form-grid' },
525
+ el('div', {}, el('label', {}, 'Name'), name),
526
+ el('div', {}, el('label', {}, 'Agent id'), el('input', { value: current.id, disabled: true })),
527
+ el('div', {}, el('label', {}, 'Provider'), provider),
528
+ el('div', {}, el('label', {}, 'Model'), model),
529
+ ),
530
+ el('p', { class: 'field-note' }, 'An id keys sessions, memory, and credentials, so it cannot be changed in place.'),
531
+ el('label', {}, 'Persona'),
532
+ instructions,
533
+ el('p', { class: 'field-note' }, soul.soulPath ? `Soul file: ${soul.soulPath}` : ''),
534
+ notice,
535
+ el('div', { class: 'actions' }, el('button', { class: 'primary', onClick: () => void save() }, 'Save')),
536
+ );
537
+
538
+ return {
539
+ node,
540
+ refreshNotice() {
541
+ notice.replaceChildren(
542
+ ...(state.saved ? [el('div', { class: 'notice ok' }, state.saved)] : []),
543
+ ...(state.error ? [el('div', { class: 'notice bad' }, state.error)] : []),
544
+ );
545
+ },
546
+ };
547
+ };
548
+
549
+ const settingsTab = () => {
550
+ const current = agent();
551
+ if (!current) {
552
+ return el('p', { class: 'empty' }, 'This agent is no longer on the roster.');
553
+ }
554
+ if (current.builtIn) {
555
+ return el('p', { class: 'empty' }, 'The built-in agent has no soul file to edit. Create your own agent to customise one.');
556
+ }
557
+ if (state.soulError) {
558
+ return el('p', { class: 'empty' }, state.soulError);
559
+ }
560
+ if (!state.soul) {
561
+ void loadSoul();
562
+ return el('p', { class: 'empty' }, 'Reading the soul file…');
563
+ }
564
+ settingsForm ??= buildSettingsForm(current, state.soul);
565
+ settingsForm.refreshNotice();
566
+ return settingsForm.node;
567
+ };
568
+
569
+ // ---- shell -------------------------------------------------------------
570
+
571
+ const sessionPicker = () => {
572
+ const picker = el('select', {
573
+ onChange: (event) => openSession(event.target.value),
574
+ }, ...state.sessions.map((session) => el('option', {
575
+ value: session.id,
576
+ selected: session.id === state.sessionId,
577
+ }, `${session.status.replace('_', ' ')} · ${ago(session.updatedAt)} · ${shortId(session.id)}`)));
578
+
579
+ if (!state.sessions.some((session) => session.id === state.sessionId)) {
580
+ // A conversation this page just started is not in the store yet, so it
581
+ // would otherwise be missing from the very picker that is selecting it.
582
+ picker.prepend(el('option', { value: state.sessionId, selected: true }, 'new conversation'));
583
+ }
584
+ return picker;
585
+ };
586
+
587
+ const header = () => {
588
+ const current = agent();
589
+ return el('div', { class: 'card-head' },
590
+ el('div', { class: 'row' },
591
+ current ? avatar(current, 34) : null,
592
+ el('div', { class: 'grow' },
593
+ el('div', { class: 'title' }, current?.name ?? agentId),
594
+ el('div', { class: 'sub' }, current
595
+ // An absent `runsOn` means the daemon is serving a soul whose
596
+ // file it can no longer read, so it cannot say what this runs on.
597
+ // "demo" would be a guess, and a billing claim is not a good
598
+ // place to guess.
599
+ ? `${current.runsOn?.provider ?? 'unknown'}${current.runsOn?.model ? ` · ${current.runsOn.model}` : ''} · ${current.memories ?? 0} remembered`
600
+ : agentId),
601
+ ),
602
+ ),
603
+ el('div', { class: 'actions' },
604
+ ...['chat', 'activity', 'settings'].map((tab) => el('button', {
605
+ class: `small${state.tab === tab ? ' primary' : ''}`,
606
+ onClick: () => { state.tab = tab; update(); },
607
+ }, tab.charAt(0).toUpperCase() + tab.slice(1))),
608
+ ),
609
+ );
610
+ };
611
+
612
+ const update = () => {
613
+ const body = state.tab === 'chat' ? chatTab()
614
+ : state.tab === 'activity' ? activityTab()
615
+ : settingsTab();
616
+
617
+ node.replaceChildren(
618
+ el('div', { class: 'column' },
619
+ el('section', { class: 'card' },
620
+ header(),
621
+ state.tab === 'chat'
622
+ ? el('div', {}, el('label', {}, 'Conversation'), sessionPicker())
623
+ : null,
624
+ body,
625
+ ),
626
+ ),
627
+ el('div', { class: 'column' },
628
+ el('section', { class: 'card' },
629
+ el('div', { class: 'card-head' },
630
+ el('h2', {}, 'Conversations'),
631
+ el('button', { class: 'small', onClick: startSession }, 'New'),
632
+ ),
633
+ state.sessions.length === 0
634
+ ? el('p', { class: 'empty' }, 'No stored conversations yet.')
635
+ : el('div', { class: 'rows' }, ...state.sessions.slice(0, 12).map((session) => el('div', { class: 'row' },
636
+ el('div', { class: 'grow' },
637
+ el('div', { class: 'title', title: session.id },
638
+ session.id === state.sessionId ? `▸ ${shortId(session.id)}` : shortId(session.id)),
639
+ el('div', { class: 'sub' }, ago(session.updatedAt))),
640
+ el('span', { class: `pill ${session.status}` }, session.status.replace('_', ' ')),
641
+ el('button', { class: 'small', onClick: () => openSession(session.id) }, 'Open'),
642
+ ))),
643
+ ),
644
+ ),
645
+ );
646
+ paintTranscript();
647
+ };
648
+
649
+ /**
650
+ * Re-read after the event stream was down.
651
+ *
652
+ * Nothing replays what was missed, so a turn that finished during the
653
+ * outage leaves this view waiting on a completion that already happened —
654
+ * the composer disabled on "Working…" forever.
655
+ *
656
+ * The store decides, not the reconnect: a socket can also come back while
657
+ * the turn is genuinely still running, and clearing then would re-enable
658
+ * the composer mid-turn and make that turn's remaining events look like
659
+ * somebody else's. So the in-flight state is dropped only once the stored
660
+ * session says it has ended.
661
+ */
662
+ const reconcile = async () => {
663
+ if (state.sessionId && state.awaitingSend.has(state.sessionId)) {
664
+ // Our own POST is about to say how this turn went — including its id,
665
+ // which lands *after* this would have cleared `sending`, re-enabling
666
+ // the composer on a turn that had only just started. The store cannot
667
+ // help here: a new conversation has nothing in it yet, and an existing
668
+ // one still reads as the previous turn. So the reconciliation is owed
669
+ // rather than skipped: the terminal event is not replayed, and this may
670
+ // be the only reconnect there is.
671
+ state.reconcileAfterSend.add(state.sessionId);
672
+ void loadSessions();
673
+ return;
674
+ }
675
+ // Past that, `unsent` can no longer hide a live turn: a conversation with
676
+ // one has either an outstanding POST, caught above, or a response that
677
+ // already marked it stored.
678
+ if (state.sending && state.sessionId && !state.unsent) {
679
+ const stored = await api.session(state.sessionId).then((read) => read.session).catch(() => undefined);
680
+ // Unreadable, or still going: leave the turn alone. A session saved a
681
+ // moment behind the turn it is running reads as `running` here, and
682
+ // the next event or check-in settles it.
683
+ if (!stored || stored.status === 'running' || stored.status === 'pending_approval') {
684
+ void loadSessions();
685
+ return;
686
+ }
687
+ }
688
+ state.sending = false;
689
+ state.turnId = undefined;
690
+ if (state.sessionId) {
691
+ state.turns.delete(state.sessionId);
692
+ }
693
+ state.pending = [];
694
+ state.streaming = '';
695
+ state.toolLines = [];
696
+ void loadSessions();
697
+ update();
698
+ };
699
+
700
+ update();
701
+ void loadSessions();
702
+
703
+ return { node, update, openSession, reconcile, destroy: detach };
704
+ };