multi-agent-collaboration-mcp 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/web/index.html ADDED
@@ -0,0 +1,3117 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>agent-chat</title>
7
+ <link
8
+ rel="icon"
9
+ href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='6' fill='%23121316'/%3E%3Ccircle cx='16' cy='16' r='6' fill='%23d7dade'/%3E%3C/svg%3E"
10
+ />
11
+ <style>
12
+ @import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500;600&display=swap");
13
+
14
+ :root {
15
+ /* Neutral grayscale ramp, near-black to near-white (never pure). */
16
+ --bg: #121316;
17
+ --surface: #17181c;
18
+ --surface-2: #1e2024;
19
+ --surface-3: #26282d;
20
+ --line: #2e3136;
21
+ --line-soft: #24262b;
22
+ --text: #e8eaed;
23
+ --text-dim: #9aa0a8;
24
+ --text-faint: #83898f;
25
+ /* The "accent" is near-white: emphasis through contrast, not hue. */
26
+ --accent: #f2f4f6;
27
+ --accent-soft: rgba(255, 255, 255, 0.08);
28
+ --accent-line: rgba(255, 255, 255, 0.3);
29
+ --on-accent: #101114;
30
+ /* Deliberately the one non-gray: destructive/error semantics only. */
31
+ --danger: #cf6b6b;
32
+ --font-display: "IBM Plex Sans", -apple-system, "Segoe UI", Roboto,
33
+ sans-serif;
34
+ --font-ui: "IBM Plex Sans", -apple-system, "Segoe UI", Roboto,
35
+ sans-serif;
36
+ --font-mono: "IBM Plex Mono", ui-monospace, "SF Mono", Menlo, Consolas,
37
+ monospace;
38
+ }
39
+
40
+ * {
41
+ box-sizing: border-box;
42
+ }
43
+
44
+ /* The display rules below (flex/grid) would otherwise override the UA's
45
+ [hidden] { display: none } and quietly un-hide elements. */
46
+ [hidden] {
47
+ display: none !important;
48
+ }
49
+
50
+ html,
51
+ body {
52
+ height: 100%;
53
+ }
54
+
55
+ body {
56
+ margin: 0;
57
+ font-family: var(--font-ui);
58
+ font-size: 13px;
59
+ line-height: 1.55;
60
+ color: var(--text);
61
+ /* Depth from gray gradients only: a soft top-right sheen and a
62
+ bottom-left lift over the near-black base. */
63
+ background:
64
+ radial-gradient(1100px 700px at 88% -12%, rgba(255, 255, 255, 0.05), transparent 60%),
65
+ radial-gradient(900px 900px at -10% 110%, rgba(255, 255, 255, 0.03), transparent 55%),
66
+ linear-gradient(180deg, #15161a 0%, var(--bg) 40%),
67
+ var(--bg);
68
+ }
69
+
70
+ /* Data surfaces stay monospace; UI chrome is the sans. */
71
+ .msg .body,
72
+ pre.json,
73
+ #pinned-body,
74
+ .msg-meta,
75
+ #compose-text,
76
+ #join-name,
77
+ #filter,
78
+ #search {
79
+ font-family: var(--font-mono);
80
+ }
81
+
82
+ ::selection {
83
+ background: var(--accent);
84
+ color: var(--on-accent);
85
+ }
86
+
87
+ button {
88
+ font: inherit;
89
+ color: inherit;
90
+ background: none;
91
+ border: none;
92
+ cursor: pointer;
93
+ padding: 0;
94
+ }
95
+
96
+ :focus-visible {
97
+ outline: 1px solid var(--accent);
98
+ outline-offset: 2px;
99
+ border-radius: 2px;
100
+ }
101
+
102
+ * {
103
+ scrollbar-width: thin;
104
+ scrollbar-color: var(--line) transparent;
105
+ }
106
+ *::-webkit-scrollbar {
107
+ width: 10px;
108
+ height: 10px;
109
+ }
110
+ *::-webkit-scrollbar-thumb {
111
+ background: var(--line);
112
+ border: 3px solid transparent;
113
+ border-radius: 6px;
114
+ background-clip: content-box;
115
+ }
116
+ *::-webkit-scrollbar-track {
117
+ background: transparent;
118
+ }
119
+
120
+ /* ---- shell ---------------------------------------------------------- */
121
+
122
+ #app {
123
+ display: grid;
124
+ grid-template-columns: 300px 1fr;
125
+ height: 100vh;
126
+ height: 100dvh;
127
+ }
128
+
129
+ /* ---- sidebar -------------------------------------------------------- */
130
+
131
+ #sidebar {
132
+ display: flex;
133
+ flex-direction: column;
134
+ min-height: 0;
135
+ border-right: 1px solid var(--line-soft);
136
+ background: linear-gradient(180deg, var(--surface) 0%, #131417 100%);
137
+ }
138
+
139
+ .masthead {
140
+ padding: 20px 18px 14px;
141
+ border-bottom: 1px solid var(--line-soft);
142
+ }
143
+
144
+ .wordmark {
145
+ font-family: var(--font-ui);
146
+ font-weight: 600;
147
+ font-size: 17px;
148
+ letter-spacing: 0.02em;
149
+ color: var(--text);
150
+ display: flex;
151
+ align-items: baseline;
152
+ gap: 10px;
153
+ }
154
+
155
+ .wordmark em {
156
+ color: var(--text-faint);
157
+ font-style: normal;
158
+ }
159
+
160
+ .dot {
161
+ width: 8px;
162
+ height: 8px;
163
+ border-radius: 50%;
164
+ background: var(--text-faint);
165
+ align-self: center;
166
+ flex: none;
167
+ transition: background 0.3s;
168
+ }
169
+ .dot.ok {
170
+ background: var(--accent);
171
+ animation: pulse 2.4s ease-out infinite;
172
+ }
173
+ .dot.bad {
174
+ background: var(--danger);
175
+ animation: none;
176
+ }
177
+
178
+ @keyframes pulse {
179
+ 0% {
180
+ box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.35);
181
+ }
182
+ 70% {
183
+ box-shadow: 0 0 0 9px rgba(255, 255, 255, 0);
184
+ }
185
+ 100% {
186
+ box-shadow: 0 0 0 0 rgba(255, 255, 255, 0);
187
+ }
188
+ }
189
+
190
+ .tagline {
191
+ margin-top: 3px;
192
+ font-size: 10.5px;
193
+ letter-spacing: 0.14em;
194
+ text-transform: uppercase;
195
+ color: var(--text-faint);
196
+ }
197
+
198
+ .filter-wrap {
199
+ padding: 12px 14px 6px;
200
+ }
201
+
202
+ #filter {
203
+ width: 100%;
204
+ font-family: var(--font-mono);
205
+ font-size: 12px;
206
+ color: var(--text);
207
+ background: var(--bg);
208
+ border: 1px solid var(--line);
209
+ border-radius: 6px;
210
+ padding: 7px 10px;
211
+ }
212
+ #filter::placeholder {
213
+ color: var(--text-faint);
214
+ }
215
+ #filter:focus {
216
+ outline: none;
217
+ border-color: var(--accent-line);
218
+ }
219
+
220
+ #rooms {
221
+ flex: 1;
222
+ overflow-y: auto;
223
+ padding: 8px 10px 16px;
224
+ display: flex;
225
+ flex-direction: column;
226
+ gap: 4px;
227
+ }
228
+
229
+ .room-card {
230
+ text-align: left;
231
+ width: 100%;
232
+ border-radius: 8px;
233
+ padding: 9px 11px 9px 13px;
234
+ border-left: 2px solid transparent;
235
+ position: relative;
236
+ transition: background 0.15s;
237
+ }
238
+ .room-card:hover {
239
+ background: var(--surface-2);
240
+ }
241
+ .room-card[aria-current="true"] {
242
+ background: var(--surface-2);
243
+ border-left-color: var(--accent);
244
+ }
245
+
246
+ .room-card .name {
247
+ font-weight: 600;
248
+ font-size: 12.5px;
249
+ color: var(--text);
250
+ overflow: hidden;
251
+ text-overflow: ellipsis;
252
+ white-space: nowrap;
253
+ padding-right: 14px;
254
+ }
255
+
256
+ .room-card .meta {
257
+ margin-top: 2px;
258
+ font-size: 10.5px;
259
+ font-weight: 500;
260
+ color: var(--text-faint);
261
+ }
262
+
263
+ .room-card .desc {
264
+ margin-top: 3px;
265
+ font-size: 11px;
266
+ color: var(--text-dim);
267
+ display: -webkit-box;
268
+ -webkit-line-clamp: 2;
269
+ -webkit-box-orient: vertical;
270
+ overflow: hidden;
271
+ }
272
+
273
+ .room-card .activity {
274
+ position: absolute;
275
+ top: 12px;
276
+ right: 11px;
277
+ width: 6px;
278
+ height: 6px;
279
+ border-radius: 50%;
280
+ background: var(--accent);
281
+ }
282
+
283
+ .card-in {
284
+ animation: rise 0.35s ease-out backwards;
285
+ }
286
+
287
+ .side-note {
288
+ padding: 20px 16px;
289
+ color: var(--text-faint);
290
+ font-size: 12px;
291
+ line-height: 1.6;
292
+ }
293
+
294
+ /* ---- main ----------------------------------------------------------- */
295
+
296
+ #main {
297
+ display: flex;
298
+ flex-direction: column;
299
+ min-height: 0;
300
+ min-width: 0;
301
+ position: relative;
302
+ }
303
+
304
+ #main::before {
305
+ content: "";
306
+ position: absolute;
307
+ inset: 0;
308
+ background-image: radial-gradient(
309
+ circle,
310
+ rgba(255, 255, 255, 0.03) 1px,
311
+ transparent 1px
312
+ );
313
+ background-size: 26px 26px;
314
+ pointer-events: none;
315
+ }
316
+
317
+ #mobile-bar {
318
+ display: none;
319
+ align-items: center;
320
+ gap: 12px;
321
+ padding: 10px 14px;
322
+ border-bottom: 1px solid var(--line-soft);
323
+ background: var(--surface);
324
+ }
325
+ #rooms-toggle {
326
+ font-size: 11px;
327
+ letter-spacing: 0.12em;
328
+ text-transform: uppercase;
329
+ color: var(--accent);
330
+ border: 1px solid var(--accent-line);
331
+ border-radius: 6px;
332
+ padding: 5px 10px;
333
+ }
334
+ #mobile-title {
335
+ font-family: var(--font-ui);
336
+ font-weight: 600;
337
+ font-size: 14px;
338
+ overflow: hidden;
339
+ text-overflow: ellipsis;
340
+ white-space: nowrap;
341
+ }
342
+
343
+ #room-head {
344
+ display: flex;
345
+ align-items: flex-end;
346
+ justify-content: space-between;
347
+ gap: 18px;
348
+ padding: 16px 26px 13px;
349
+ border-bottom: 1px solid var(--line-soft);
350
+ background: rgba(18, 19, 22, 0.75);
351
+ backdrop-filter: blur(6px);
352
+ z-index: 2;
353
+ }
354
+
355
+ #room-name {
356
+ margin: 0;
357
+ font-family: var(--font-ui);
358
+ font-weight: 600;
359
+ font-size: 20px;
360
+ line-height: 1.2;
361
+ letter-spacing: -0.01em;
362
+ }
363
+
364
+ #room-desc {
365
+ margin: 3px 0 0;
366
+ font-size: 12px;
367
+ color: var(--text-dim);
368
+ max-width: 62ch;
369
+ }
370
+ #room-desc:empty {
371
+ display: none;
372
+ }
373
+
374
+ #room-tools {
375
+ display: flex;
376
+ flex-direction: column;
377
+ align-items: flex-end;
378
+ gap: 7px;
379
+ flex: none;
380
+ }
381
+
382
+ #room-stats {
383
+ font-size: 11px;
384
+ color: var(--text-faint);
385
+ white-space: nowrap;
386
+ }
387
+
388
+ #search {
389
+ width: 220px;
390
+ font-family: var(--font-mono);
391
+ font-size: 11.5px;
392
+ color: var(--text);
393
+ background: var(--bg);
394
+ border: 1px solid var(--line);
395
+ border-radius: 6px;
396
+ padding: 5px 9px;
397
+ }
398
+ #search::placeholder {
399
+ color: var(--text-faint);
400
+ }
401
+ #search:focus {
402
+ outline: none;
403
+ border-color: var(--accent-line);
404
+ }
405
+
406
+ /* ---- pinned intro --------------------------------------------------- */
407
+
408
+ #pinned {
409
+ margin: 12px auto 0;
410
+ width: calc(100% - 52px);
411
+ max-width: 920px;
412
+ border: 1px solid var(--accent-line);
413
+ border-left-width: 3px;
414
+ border-radius: 8px;
415
+ background: var(--accent-soft);
416
+ z-index: 1;
417
+ }
418
+
419
+ #pinned summary {
420
+ list-style: none;
421
+ display: flex;
422
+ align-items: center;
423
+ gap: 10px;
424
+ padding: 8px 12px;
425
+ cursor: pointer;
426
+ min-width: 0;
427
+ }
428
+ #pinned summary::-webkit-details-marker {
429
+ display: none;
430
+ }
431
+ #pinned summary::before {
432
+ content: "";
433
+ flex: none;
434
+ width: 7px;
435
+ height: 7px;
436
+ border-right: 1.5px solid var(--accent);
437
+ border-bottom: 1.5px solid var(--accent);
438
+ transform: rotate(-45deg);
439
+ transition: transform 0.2s;
440
+ margin-top: -2px;
441
+ }
442
+ #pinned[open] summary::before {
443
+ transform: rotate(45deg);
444
+ margin-top: -4px;
445
+ }
446
+
447
+ .pin-label {
448
+ font-size: 10px;
449
+ font-weight: 600;
450
+ letter-spacing: 0.16em;
451
+ text-transform: uppercase;
452
+ color: var(--accent);
453
+ flex: none;
454
+ }
455
+
456
+ #pinned-preview {
457
+ font-size: 11.5px;
458
+ color: var(--text-dim);
459
+ overflow: hidden;
460
+ text-overflow: ellipsis;
461
+ white-space: nowrap;
462
+ }
463
+ #pinned[open] #pinned-preview {
464
+ visibility: hidden;
465
+ }
466
+
467
+ #pinned-body {
468
+ margin: 0;
469
+ padding: 2px 14px 12px 29px;
470
+ font-family: var(--font-mono);
471
+ font-size: 12px;
472
+ color: var(--text);
473
+ white-space: pre-wrap;
474
+ word-break: break-word;
475
+ }
476
+
477
+ .danger-link:hover {
478
+ color: var(--danger);
479
+ }
480
+
481
+ /* ---- delete confirmation --------------------------------------------- */
482
+
483
+ #delete-confirm {
484
+ display: flex;
485
+ align-items: center;
486
+ gap: 14px;
487
+ margin: 10px auto 0;
488
+ width: calc(100% - 52px);
489
+ max-width: 920px;
490
+ border: 1px solid rgba(217, 107, 87, 0.5);
491
+ border-left-width: 3px;
492
+ border-radius: 8px;
493
+ background: rgba(217, 107, 87, 0.1);
494
+ padding: 9px 12px;
495
+ font-size: 11.5px;
496
+ color: var(--text);
497
+ z-index: 1;
498
+ }
499
+ #delete-info {
500
+ color: var(--text-dim);
501
+ }
502
+ #delete-yes {
503
+ margin-left: auto;
504
+ flex: none;
505
+ background: var(--danger);
506
+ color: var(--on-accent);
507
+ font-weight: 600;
508
+ font-size: 11px;
509
+ letter-spacing: 0.04em;
510
+ border-radius: 6px;
511
+ padding: 6px 12px;
512
+ }
513
+ #delete-yes:hover {
514
+ filter: brightness(1.1);
515
+ }
516
+ #delete-yes:disabled {
517
+ opacity: 0.5;
518
+ }
519
+ #delete-no {
520
+ flex: none;
521
+ }
522
+
523
+ /* ---- banner --------------------------------------------------------- */
524
+
525
+ #banner {
526
+ margin: 10px auto 0;
527
+ width: calc(100% - 52px);
528
+ max-width: 920px;
529
+ border: 1px solid rgba(217, 107, 87, 0.4);
530
+ border-radius: 8px;
531
+ background: rgba(217, 107, 87, 0.1);
532
+ color: var(--danger);
533
+ font-size: 11.5px;
534
+ padding: 7px 12px;
535
+ z-index: 1;
536
+ }
537
+
538
+ /* ---- stream --------------------------------------------------------- */
539
+
540
+ #stream-wrap {
541
+ flex: 1;
542
+ min-height: 0;
543
+ position: relative;
544
+ display: flex;
545
+ flex-direction: column;
546
+ }
547
+
548
+ #stream {
549
+ flex: 1;
550
+ overflow-y: auto;
551
+ padding: 18px 26px 30px;
552
+ overscroll-behavior: contain;
553
+ }
554
+
555
+ #msgs {
556
+ max-width: 920px;
557
+ margin: 0 auto;
558
+ }
559
+
560
+ #older {
561
+ display: block;
562
+ margin: 0 auto 18px;
563
+ font-size: 11px;
564
+ letter-spacing: 0.08em;
565
+ text-transform: uppercase;
566
+ color: var(--text-dim);
567
+ border: 1px solid var(--line);
568
+ border-radius: 6px;
569
+ padding: 6px 12px;
570
+ background: var(--surface);
571
+ }
572
+ #older:hover {
573
+ color: var(--accent);
574
+ border-color: var(--accent-line);
575
+ }
576
+ #older.gapped {
577
+ color: var(--accent);
578
+ border-color: var(--accent-line);
579
+ }
580
+
581
+ #markall {
582
+ display: block;
583
+ margin: -10px auto 18px;
584
+ font-size: 10.5px;
585
+ letter-spacing: 0.08em;
586
+ text-transform: uppercase;
587
+ color: var(--accent);
588
+ padding: 4px 12px;
589
+ }
590
+ #markall:hover {
591
+ text-decoration: underline;
592
+ }
593
+
594
+ .msg.superseded {
595
+ opacity: 0.62;
596
+ }
597
+ .chip.super {
598
+ color: var(--danger);
599
+ border-color: rgba(217, 107, 87, 0.4);
600
+ cursor: pointer;
601
+ }
602
+ .chip.super:hover {
603
+ background: rgba(217, 107, 87, 0.1);
604
+ }
605
+
606
+ .day-sep {
607
+ display: flex;
608
+ align-items: center;
609
+ gap: 14px;
610
+ margin: 22px 0 14px;
611
+ color: var(--text-faint);
612
+ font-size: 10.5px;
613
+ letter-spacing: 0.16em;
614
+ text-transform: uppercase;
615
+ }
616
+ .day-sep::before,
617
+ .day-sep::after {
618
+ content: "";
619
+ flex: 1;
620
+ height: 1px;
621
+ background: var(--line-soft);
622
+ }
623
+
624
+ .msg {
625
+ position: relative;
626
+ border-left: 2px solid var(--hue-bar, var(--line));
627
+ background: var(--hue-bg, transparent);
628
+ border-radius: 0 6px 6px 0;
629
+ padding: 6px 12px 7px 14px;
630
+ margin-top: 10px;
631
+ }
632
+ .msg.grouped {
633
+ margin-top: 1px;
634
+ padding-top: 3px;
635
+ padding-bottom: 5px;
636
+ }
637
+
638
+ .msg-head {
639
+ display: flex;
640
+ align-items: baseline;
641
+ gap: 8px;
642
+ min-width: 0;
643
+ }
644
+
645
+ .msg .who {
646
+ font-weight: 600;
647
+ font-size: 12.5px;
648
+ color: var(--hue-name, var(--text));
649
+ white-space: nowrap;
650
+ }
651
+ .msg .who-id {
652
+ font-size: 10.5px;
653
+ color: var(--text-faint);
654
+ overflow: hidden;
655
+ text-overflow: ellipsis;
656
+ white-space: nowrap;
657
+ }
658
+
659
+ .msg-meta {
660
+ margin-left: auto;
661
+ flex: none;
662
+ font-size: 10.5px;
663
+ color: var(--text-faint);
664
+ display: flex;
665
+ gap: 10px;
666
+ }
667
+ .msg-meta .seq {
668
+ color: var(--text-faint);
669
+ }
670
+ .msg.grouped .msg-meta {
671
+ position: absolute;
672
+ top: 3px;
673
+ right: 12px;
674
+ opacity: 0;
675
+ transition: opacity 0.15s;
676
+ }
677
+ .msg.grouped:hover .msg-meta {
678
+ opacity: 1;
679
+ }
680
+
681
+ .msg .body {
682
+ margin-top: 2px;
683
+ white-space: pre-wrap;
684
+ word-break: break-word;
685
+ font-size: 13px;
686
+ color: var(--text);
687
+ }
688
+
689
+ .body-wrap.clamped .body {
690
+ max-height: 22em;
691
+ overflow: hidden;
692
+ -webkit-mask-image: linear-gradient(180deg, #000 78%, transparent);
693
+ mask-image: linear-gradient(180deg, #000 78%, transparent);
694
+ }
695
+ .expand {
696
+ margin-top: 4px;
697
+ font-size: 10.5px;
698
+ letter-spacing: 0.1em;
699
+ text-transform: uppercase;
700
+ color: var(--accent);
701
+ }
702
+ .expand:hover {
703
+ text-decoration: underline;
704
+ }
705
+ .trunc-note {
706
+ margin-top: 4px;
707
+ font-size: 10.5px;
708
+ letter-spacing: 0.06em;
709
+ color: var(--text-dim);
710
+ }
711
+
712
+ pre.json {
713
+ margin: 4px 0 0;
714
+ padding: 9px 11px;
715
+ font-family: var(--font-mono);
716
+ font-size: 12px;
717
+ line-height: 1.5;
718
+ color: var(--text);
719
+ background: rgba(0, 0, 0, 0.28);
720
+ border: 1px solid var(--line-soft);
721
+ border-radius: 6px;
722
+ white-space: pre-wrap;
723
+ word-break: break-word;
724
+ overflow-x: auto;
725
+ }
726
+
727
+ details.json-fold {
728
+ margin-top: 4px;
729
+ }
730
+ details.json-fold summary {
731
+ list-style: none;
732
+ cursor: pointer;
733
+ display: inline-flex;
734
+ align-items: center;
735
+ gap: 7px;
736
+ font-size: 10.5px;
737
+ letter-spacing: 0.1em;
738
+ text-transform: uppercase;
739
+ color: var(--text-dim);
740
+ border: 1px solid var(--line);
741
+ border-radius: 5px;
742
+ padding: 3px 9px;
743
+ background: var(--surface);
744
+ }
745
+ details.json-fold summary::-webkit-details-marker {
746
+ display: none;
747
+ }
748
+ details.json-fold summary:hover {
749
+ color: var(--accent);
750
+ border-color: var(--accent-line);
751
+ }
752
+ details.json-fold summary::before {
753
+ content: "";
754
+ width: 5px;
755
+ height: 5px;
756
+ border-right: 1.5px solid currentColor;
757
+ border-bottom: 1.5px solid currentColor;
758
+ transform: rotate(-45deg);
759
+ transition: transform 0.2s;
760
+ }
761
+ details.json-fold[open] summary::before {
762
+ transform: rotate(45deg);
763
+ }
764
+
765
+ .chips {
766
+ display: flex;
767
+ flex-wrap: wrap;
768
+ gap: 5px;
769
+ margin-top: 5px;
770
+ }
771
+
772
+ .chip {
773
+ font-size: 10.5px;
774
+ color: var(--text-dim);
775
+ border: 1px solid var(--line);
776
+ border-radius: 999px;
777
+ padding: 1px 9px;
778
+ background: var(--surface);
779
+ }
780
+ .chip.reply {
781
+ color: var(--accent);
782
+ border-color: var(--accent-line);
783
+ cursor: pointer;
784
+ }
785
+ .chip.reply:hover {
786
+ background: var(--accent-soft);
787
+ }
788
+
789
+ /* Directed at you: an @mention of your identity or a reply to your
790
+ message. Amber bar so agent traffic aimed at the human stands out. */
791
+ .msg.directed {
792
+ border-left-color: var(--accent);
793
+ background: linear-gradient(
794
+ 90deg,
795
+ rgba(255, 255, 255, 0.06),
796
+ var(--hue-bg, transparent) 55%
797
+ );
798
+ }
799
+
800
+ .body a {
801
+ color: var(--accent);
802
+ text-decoration-color: var(--accent-line);
803
+ text-underline-offset: 2px;
804
+ word-break: break-all;
805
+ }
806
+ .body a:hover {
807
+ text-decoration-color: var(--accent);
808
+ }
809
+
810
+ /* ---- search mode ------------------------------------------------------ */
811
+
812
+ #search-bar {
813
+ display: flex;
814
+ align-items: center;
815
+ gap: 14px;
816
+ max-width: 920px;
817
+ margin: 0 auto 14px;
818
+ border: 1px solid var(--accent-line);
819
+ border-radius: 8px;
820
+ background: var(--accent-soft);
821
+ padding: 8px 12px;
822
+ font-size: 11.5px;
823
+ color: var(--text-dim);
824
+ }
825
+ #search-info {
826
+ overflow: hidden;
827
+ text-overflow: ellipsis;
828
+ white-space: nowrap;
829
+ }
830
+ #search-back {
831
+ margin-left: auto;
832
+ flex: none;
833
+ font-size: 10.5px;
834
+ letter-spacing: 0.09em;
835
+ text-transform: uppercase;
836
+ color: var(--accent);
837
+ }
838
+ #search-back:hover {
839
+ text-decoration: underline;
840
+ }
841
+
842
+ #results {
843
+ max-width: 920px;
844
+ margin: 0 auto;
845
+ }
846
+ .result {
847
+ cursor: pointer;
848
+ }
849
+ .result:hover {
850
+ background: var(--surface-2);
851
+ }
852
+ .result .go {
853
+ opacity: 0;
854
+ color: var(--accent);
855
+ font-size: 10px;
856
+ letter-spacing: 0.09em;
857
+ text-transform: uppercase;
858
+ transition: opacity 0.15s;
859
+ }
860
+ .result:hover .go {
861
+ opacity: 0.9;
862
+ }
863
+
864
+ .msg.fresh {
865
+ animation: rise 0.35s ease-out, wash 1.6s ease-out;
866
+ }
867
+ .msg.flash {
868
+ animation: wash 1.6s ease-out;
869
+ }
870
+
871
+ @keyframes rise {
872
+ from {
873
+ opacity: 0;
874
+ transform: translateY(7px);
875
+ }
876
+ }
877
+ @keyframes wash {
878
+ 0% {
879
+ background-color: var(--accent-soft);
880
+ }
881
+ 100% {
882
+ background-color: var(--hue-bg, transparent);
883
+ }
884
+ }
885
+
886
+ /* ---- jump pill ------------------------------------------------------ */
887
+
888
+ #jump {
889
+ position: absolute;
890
+ left: 50%;
891
+ bottom: 20px;
892
+ transform: translateX(-50%);
893
+ background: var(--accent);
894
+ color: var(--on-accent);
895
+ font-weight: 600;
896
+ font-size: 11.5px;
897
+ letter-spacing: 0.02em;
898
+ border-radius: 6px;
899
+ padding: 8px 16px;
900
+ box-shadow: 0 6px 18px rgba(0, 0, 0, 0.45);
901
+ animation: rise 0.25s ease-out;
902
+ z-index: 3;
903
+ }
904
+ #jump:hover {
905
+ filter: brightness(1.08);
906
+ }
907
+
908
+ /* ---- composer --------------------------------------------------------- */
909
+
910
+ #composer {
911
+ border-top: 1px solid var(--line-soft);
912
+ background: var(--surface);
913
+ padding: 10px 26px 12px;
914
+ z-index: 2;
915
+ }
916
+
917
+ #composer .inner {
918
+ max-width: 920px;
919
+ margin: 0 auto;
920
+ display: flex;
921
+ flex-direction: column;
922
+ gap: 7px;
923
+ }
924
+
925
+ .compose-row {
926
+ display: flex;
927
+ gap: 8px;
928
+ align-items: flex-end;
929
+ }
930
+
931
+ .compose-hint {
932
+ font-size: 10.5px;
933
+ color: var(--text-faint);
934
+ align-self: center;
935
+ }
936
+
937
+ #join-name {
938
+ width: 230px;
939
+ max-width: 45%;
940
+ background: var(--bg);
941
+ border: 1px solid var(--line);
942
+ border-radius: 8px;
943
+ color: var(--text);
944
+ font-family: var(--font-mono);
945
+ font-size: 12.5px;
946
+ padding: 8px 11px;
947
+ }
948
+ #join-name::placeholder {
949
+ color: var(--text-faint);
950
+ }
951
+ #join-name:focus {
952
+ outline: none;
953
+ border-color: var(--accent-line);
954
+ }
955
+
956
+ .btn-accent {
957
+ background: var(--accent);
958
+ color: var(--on-accent);
959
+ font-weight: 600;
960
+ font-size: 12px;
961
+ letter-spacing: 0.02em;
962
+ border-radius: 6px;
963
+ padding: 9px 16px;
964
+ flex: none;
965
+ }
966
+ .btn-accent:hover {
967
+ filter: brightness(1.08);
968
+ }
969
+ .btn-accent:disabled {
970
+ opacity: 0.45;
971
+ cursor: default;
972
+ filter: none;
973
+ }
974
+
975
+ #compose-text {
976
+ flex: 1;
977
+ resize: none;
978
+ background: var(--bg);
979
+ border: 1px solid var(--line);
980
+ border-radius: 8px;
981
+ color: var(--text);
982
+ font-family: var(--font-mono);
983
+ font-size: 13px;
984
+ line-height: 1.5;
985
+ padding: 9px 11px;
986
+ max-height: 160px;
987
+ overflow-y: auto;
988
+ }
989
+ #compose-text::placeholder {
990
+ color: var(--text-faint);
991
+ }
992
+ #compose-text:focus {
993
+ outline: none;
994
+ border-color: var(--accent-line);
995
+ }
996
+
997
+ .compose-meta {
998
+ display: flex;
999
+ align-items: center;
1000
+ gap: 14px;
1001
+ font-size: 10.5px;
1002
+ color: var(--text-faint);
1003
+ min-height: 15px;
1004
+ }
1005
+ .compose-meta .ident {
1006
+ color: var(--text-dim);
1007
+ }
1008
+ .compose-meta .ident b {
1009
+ color: var(--accent);
1010
+ font-weight: 600;
1011
+ }
1012
+ .linkish {
1013
+ color: var(--text-dim);
1014
+ font-size: 10px;
1015
+ letter-spacing: 0.09em;
1016
+ text-transform: uppercase;
1017
+ }
1018
+ .linkish:hover {
1019
+ color: var(--accent);
1020
+ text-decoration: underline;
1021
+ }
1022
+ #compose-error {
1023
+ color: var(--danger);
1024
+ margin-left: auto;
1025
+ }
1026
+ #compose-error:empty {
1027
+ display: none;
1028
+ }
1029
+
1030
+ #reply-target {
1031
+ align-self: flex-start;
1032
+ }
1033
+
1034
+ .msg-meta .act {
1035
+ opacity: 0;
1036
+ color: var(--accent);
1037
+ font-size: 10px;
1038
+ letter-spacing: 0.09em;
1039
+ text-transform: uppercase;
1040
+ transition: opacity 0.15s;
1041
+ }
1042
+ .msg:hover .act,
1043
+ .act:focus-visible {
1044
+ opacity: 0.9;
1045
+ }
1046
+
1047
+ .you-badge {
1048
+ flex: none;
1049
+ font-size: 9.5px;
1050
+ letter-spacing: 0.08em;
1051
+ text-transform: uppercase;
1052
+ color: var(--accent);
1053
+ border: 1px solid var(--accent-line);
1054
+ border-radius: 999px;
1055
+ padding: 0 6px;
1056
+ line-height: 1.5;
1057
+ }
1058
+
1059
+ /* ---- placeholder ---------------------------------------------------- */
1060
+
1061
+ .placeholder {
1062
+ position: absolute;
1063
+ inset: 0;
1064
+ display: flex;
1065
+ flex-direction: column;
1066
+ align-items: center;
1067
+ justify-content: center;
1068
+ gap: 8px;
1069
+ text-align: center;
1070
+ padding: 30px;
1071
+ z-index: 1;
1072
+ }
1073
+ .placeholder .big {
1074
+ font-family: var(--font-ui);
1075
+ font-weight: 600;
1076
+ font-size: 20px;
1077
+ color: var(--text-dim);
1078
+ }
1079
+ .placeholder .hint {
1080
+ font-size: 12px;
1081
+ color: var(--text-faint);
1082
+ max-width: 52ch;
1083
+ line-height: 1.7;
1084
+ }
1085
+
1086
+ /* ---- responsive ------------------------------------------------------ */
1087
+
1088
+ @media (max-width: 860px) {
1089
+ #app {
1090
+ grid-template-columns: 1fr;
1091
+ }
1092
+ #sidebar {
1093
+ position: fixed;
1094
+ inset: 0 25% 0 0;
1095
+ min-width: 280px;
1096
+ z-index: 10;
1097
+ transform: translateX(-102%);
1098
+ transition: transform 0.25s ease-out;
1099
+ box-shadow: 18px 0 40px rgba(0, 0, 0, 0.5);
1100
+ }
1101
+ body.show-rooms #sidebar {
1102
+ transform: translateX(0);
1103
+ }
1104
+ #mobile-bar {
1105
+ display: flex;
1106
+ }
1107
+ #room-head,
1108
+ #stream {
1109
+ padding-left: 16px;
1110
+ padding-right: 16px;
1111
+ }
1112
+ #pinned,
1113
+ #banner {
1114
+ width: calc(100% - 32px);
1115
+ }
1116
+ #composer {
1117
+ padding-left: 16px;
1118
+ padding-right: 16px;
1119
+ }
1120
+ #search {
1121
+ width: 150px;
1122
+ }
1123
+ }
1124
+
1125
+ @media (prefers-reduced-motion: reduce) {
1126
+ *,
1127
+ *::before,
1128
+ *::after {
1129
+ animation: none !important;
1130
+ transition: none !important;
1131
+ }
1132
+ }
1133
+ </style>
1134
+ </head>
1135
+ <body>
1136
+ <div id="app">
1137
+ <aside id="sidebar">
1138
+ <div class="masthead">
1139
+ <div class="wordmark">
1140
+ agent<em>-</em>chat
1141
+ <span id="live-dot" class="dot" title="waiting for first poll"></span>
1142
+ </div>
1143
+ <div class="tagline">live coordination log</div>
1144
+ </div>
1145
+ <div class="filter-wrap">
1146
+ <input id="filter" type="search" placeholder="Filter rooms" aria-label="Filter rooms" autocomplete="off" />
1147
+ </div>
1148
+ <nav id="rooms" aria-label="Rooms"></nav>
1149
+ </aside>
1150
+
1151
+ <main id="main">
1152
+ <div id="mobile-bar">
1153
+ <button id="rooms-toggle" aria-label="Show rooms">Rooms</button>
1154
+ <span id="mobile-title"></span>
1155
+ </div>
1156
+ <header id="room-head" hidden>
1157
+ <div>
1158
+ <h1 id="room-name"></h1>
1159
+ <p id="room-desc"></p>
1160
+ </div>
1161
+ <div id="room-tools">
1162
+ <div id="room-stats"></div>
1163
+ <input
1164
+ id="search"
1165
+ type="search"
1166
+ placeholder="Search room"
1167
+ aria-label="Search room messages"
1168
+ autocomplete="off"
1169
+ spellcheck="false"
1170
+ title="Full-text search (FTS5 syntax: AND by default, OR, NOT, quoted phrases, prefix*)"
1171
+ />
1172
+ <button id="delete-room" class="linkish danger-link">Delete room</button>
1173
+ </div>
1174
+ </header>
1175
+ <div id="delete-confirm" hidden>
1176
+ <span id="delete-info"></span>
1177
+ <button id="delete-yes">Delete permanently</button>
1178
+ <button id="delete-no" class="linkish">Cancel</button>
1179
+ </div>
1180
+ <details id="pinned" hidden>
1181
+ <summary>
1182
+ <span class="pin-label">Pinned</span>
1183
+ <span id="pinned-preview"></span>
1184
+ </summary>
1185
+ <pre id="pinned-body"></pre>
1186
+ </details>
1187
+ <div id="banner" hidden>Connection lost. Retrying.</div>
1188
+ <section id="stream-wrap" hidden>
1189
+ <div id="stream">
1190
+ <div id="search-bar" hidden>
1191
+ <span id="search-info"></span>
1192
+ <button id="search-back">Back to live</button>
1193
+ </div>
1194
+ <div id="results" hidden></div>
1195
+ <button id="older" hidden>Load earlier messages</button>
1196
+ <button id="markall" hidden>Mark all as read</button>
1197
+ <div id="msgs" role="log"></div>
1198
+ </div>
1199
+ <button id="jump" hidden></button>
1200
+ </section>
1201
+ <footer id="composer" hidden>
1202
+ <div class="inner">
1203
+ <div id="compose-join" class="compose-row" hidden>
1204
+ <input
1205
+ id="join-name"
1206
+ placeholder="Your name"
1207
+ aria-label="Your name"
1208
+ maxlength="200"
1209
+ autocomplete="off"
1210
+ spellcheck="false"
1211
+ />
1212
+ <button id="join-btn" class="btn-accent">Join room</button>
1213
+ <span class="compose-hint"
1214
+ >no password: identity is self-asserted, like the agents</span
1215
+ >
1216
+ <span id="join-error" class="compose-hint" style="color: var(--danger)"></span>
1217
+ </div>
1218
+ <div id="compose-box" hidden>
1219
+ <button id="reply-target" class="chip reply" hidden></button>
1220
+ <div class="compose-row">
1221
+ <textarea
1222
+ id="compose-text"
1223
+ rows="1"
1224
+ aria-label="Message text"
1225
+ placeholder="Message the room. Enter sends, Shift+Enter breaks, @name tags an agent"
1226
+ ></textarea>
1227
+ <button id="send" class="btn-accent">Send</button>
1228
+ </div>
1229
+ <div class="compose-meta">
1230
+ <span class="ident">posting as <b id="compose-ident"></b></span>
1231
+ <button id="ident-btn" class="linkish">Change name</button>
1232
+ <button id="leave-btn" class="linkish">Leave room</button>
1233
+ <span id="compose-error"></span>
1234
+ </div>
1235
+ </div>
1236
+ </div>
1237
+ </footer>
1238
+ <div id="placeholder" class="placeholder" hidden></div>
1239
+ </main>
1240
+ </div>
1241
+
1242
+ <script>
1243
+ "use strict";
1244
+
1245
+ const ROOM_KEY = "agent-chat.room";
1246
+ const SEEN_KEY = "agent-chat.seen";
1247
+ const POLL_MS = 1500;
1248
+ const ROOMS_MS = 30000;
1249
+ const PAGE = 400;
1250
+ const MAX_RENDERED_MESSAGES = 2400;
1251
+ const TRIM_RENDERED_TO = 1600;
1252
+ const FETCH_TIMEOUT_MS = 10000;
1253
+ const GROUP_GAP_MS = 3 * 60 * 1000;
1254
+
1255
+ const el = {};
1256
+ for (const id of [
1257
+ "live-dot", "filter", "rooms", "mobile-title", "rooms-toggle",
1258
+ "room-head", "room-name", "room-desc", "room-stats",
1259
+ "pinned", "pinned-preview", "pinned-body",
1260
+ "banner", "stream-wrap", "stream", "older", "markall", "msgs", "jump", "placeholder",
1261
+ "composer", "compose-join", "join-name", "join-btn", "join-error",
1262
+ "compose-box", "reply-target", "compose-text", "send", "compose-ident",
1263
+ "ident-btn", "leave-btn", "compose-error",
1264
+ "search", "search-bar", "search-info", "search-back", "results",
1265
+ "delete-room", "delete-confirm", "delete-info", "delete-yes", "delete-no",
1266
+ ]) el[id.replace(/-(\w)/g, (_, c) => c.toUpperCase())] = document.getElementById(id);
1267
+
1268
+ const state = {
1269
+ rooms: [],
1270
+ current: null, // room object from /api/rooms
1271
+ msgs: [],
1272
+ follow: true,
1273
+ newCount: 0,
1274
+ // An off-window jump is a bounded historical snapshot, not a new live
1275
+ // cursor. Freeze it until the user explicitly returns to the tail so
1276
+ // polling cannot walk and rebuild every intervening 400-row page.
1277
+ historicalWindow: false,
1278
+ failures: 0,
1279
+ loadingMsgs: false,
1280
+ loadingRooms: false,
1281
+ seen: loadSeen(),
1282
+ replyTo: null, // { seq, label } the composer is replying to
1283
+ sending: false,
1284
+ lastReadSent: 0, // highest seq reported to /api/read for this room
1285
+ // An initial load that could not run (a poll fetch held the lock, or it
1286
+ // failed) is deferred here so the next tick retries it with FULL
1287
+ // initial semantics (older-button, empty placeholder, no animations).
1288
+ pendingInitial: false,
1289
+ searchMode: false, // search results are displayed instead of the tail
1290
+ // Server-side read marker for the joined identity in the current room
1291
+ // (-1 = unknown/not fetched yet) and whether the rendered window has a
1292
+ // GAP of unfetched messages between the marker and its oldest row.
1293
+ // While a gap exists, auto read-marking is suspended: advancing the
1294
+ // monotonic marker over unfetched messages would silently erase their
1295
+ // my_mentions entries without them ever being rendered.
1296
+ marker: -1,
1297
+ // True once we know no older history exists below the rendered
1298
+ // window (an exhausted older-page, or an initial load smaller than
1299
+ // PAGE): a marker gap below that floor points at PRUNED messages,
1300
+ // which can never be fetched, so it must not suspend read-marking
1301
+ // forever.
1302
+ noOlderHistory: false,
1303
+ // Which identity `marker`/`lastReadSent` were established for: /api/read
1304
+ // posts are refused when the live identity no longer matches (another
1305
+ // tab renaming would otherwise graft the old identity's marker onto
1306
+ // the new name).
1307
+ markerIdent: null,
1308
+ markerLoading: false,
1309
+ markerRetryKey: "",
1310
+ markerRetryAt: 0,
1311
+ gap: false,
1312
+ drafts: {}, // per-room compose drafts, in memory only
1313
+ // Incremented on every openRoom: async completions capture it so a
1314
+ // response from a PREVIOUS visit to the same room (A -> B -> A) can
1315
+ // be told apart from the current visit (id comparison alone cannot).
1316
+ roomEpoch: 0,
1317
+ searchSeq: 0, // orders concurrent search requests
1318
+ searchController: null, // aborts the superseded/abandoned search request
1319
+ // A rename that lands WHILE search mode hides the stream cannot rebuild
1320
+ // the (hidden) rows' identity-derived decoration then; this defers it
1321
+ // to exitSearch.
1322
+ needsRebuild: false,
1323
+ };
1324
+
1325
+ const IDENT_KEY = "agent-chat.identity";
1326
+ const JOINED_KEY = "agent-chat.joined";
1327
+ const GHOST_KEY = "agent-chat.ghosts";
1328
+
1329
+ // Cross-tab serialization for EVERY membership mutation (identity,
1330
+ // joined map, ghost ledger, and the server calls they mirror). These
1331
+ // are read-modify-write sequences over shared localStorage: two tabs
1332
+ // joining different rooms both read the map, then the second save
1333
+ // erased the first tab's join; concurrent renames left the stored
1334
+ // identity and room mapping naming different names; and a ghost
1335
+ // sweep's in-flight leave could evict a membership another tab had
1336
+ // just re-adopted. Web Locks serialize the whole sequence (awaited
1337
+ // server calls included) across same-origin tabs. localhost is a
1338
+ // secure context, so navigator.locks exists everywhere this runs; the
1339
+ // fallback keeps a degraded single-tab-correct behavior just in case.
1340
+ // Tab-local serialization fallback: a promise chain so that even without
1341
+ // Web Locks (older engines; localhost is a secure context so this is
1342
+ // rare) two membership mutations in the SAME tab cannot interleave their
1343
+ // read-modify-write. It does NOT coordinate across tabs -- only Web Locks
1344
+ // do that -- but it removes the within-tab race the old
1345
+ // Promise.resolve().then(fn) left open.
1346
+ let membershipChain = Promise.resolve();
1347
+ function withMembershipLock(fn) {
1348
+ if (navigator.locks && navigator.locks.request) {
1349
+ return navigator.locks.request("agent-chat.membership", fn);
1350
+ }
1351
+ // membershipChain always fulfills (swallowed below), so fn runs once.
1352
+ const run = membershipChain.then(() => fn());
1353
+ // Keep the chain alive regardless of fn's outcome; swallow here so a
1354
+ // rejection does not poison every later mutation.
1355
+ membershipChain = run.then(
1356
+ () => undefined,
1357
+ () => undefined,
1358
+ );
1359
+ return run;
1360
+ }
1361
+
1362
+ // Memberships whose soft-leave failed (e.g. server briefly down during
1363
+ // a rename): tracked so later joins can retry the leave instead of the
1364
+ // ghost becoming permanently unaddressable.
1365
+ function loadGhosts() {
1366
+ // Type-check, not just parse-check: valid-but-wrong-typed JSON (a
1367
+ // bare number, an object) written by devtools or version skew would
1368
+ // otherwise throw deep inside consumers as an unhandled rejection.
1369
+ try {
1370
+ const v = JSON.parse(localStorage.getItem(GHOST_KEY));
1371
+ return Array.isArray(v)
1372
+ ? v.filter(
1373
+ (g) =>
1374
+ g && typeof g.room === "number" && typeof g.name === "string",
1375
+ )
1376
+ : [];
1377
+ } catch {
1378
+ return [];
1379
+ }
1380
+ }
1381
+ function saveGhosts(g) {
1382
+ try {
1383
+ localStorage.setItem(GHOST_KEY, JSON.stringify(g));
1384
+ } catch {}
1385
+ }
1386
+ let ghostRetryActive = false;
1387
+ async function retryGhosts() {
1388
+ if (ghostRetryActive) return; // overlapping sweeps would race the ledger
1389
+ ghostRetryActive = true;
1390
+ try {
1391
+ // The membership lock is held ACROSS each leave's round trip: the
1392
+ // stale-check below is only race-free if no other tab can re-adopt
1393
+ // the (room, name) while the leave is in flight.
1394
+ await withMembershipLock(async () => {
1395
+ for (const g of loadGhosts()) {
1396
+ // A ghost whose (room, name) is the CURRENT mapping again is not
1397
+ // stale: the user re-adopted the name and rejoined, and leaving
1398
+ // now would evict that live membership seconds after it was
1399
+ // created. Purge the entry instead of retrying it.
1400
+ if (loadJoined()[String(g.room)] === g.name) {
1401
+ saveGhosts(
1402
+ loadGhosts().filter(
1403
+ (x) => !(x.room === g.room && x.name === g.name),
1404
+ ),
1405
+ );
1406
+ continue;
1407
+ }
1408
+ try {
1409
+ await postJson("/api/leave", { room: g.room, name: g.name });
1410
+ } catch {
1411
+ continue; // still unreachable; keep the entry for the next sweep
1412
+ }
1413
+ // Remove exactly this entry from the CURRENT ledger: a wholesale
1414
+ // save of a pre-loop snapshot would erase entries recorded while
1415
+ // these retries were in flight.
1416
+ saveGhosts(
1417
+ loadGhosts().filter(
1418
+ (x) => !(x.room === g.room && x.name === g.name),
1419
+ ),
1420
+ );
1421
+ }
1422
+ });
1423
+ } finally {
1424
+ ghostRetryActive = false;
1425
+ }
1426
+ }
1427
+
1428
+ function identity() {
1429
+ return localStorage.getItem(IDENT_KEY) || "";
1430
+ }
1431
+ function loadJoined() {
1432
+ try {
1433
+ const v = JSON.parse(localStorage.getItem(JOINED_KEY));
1434
+ return v && typeof v === "object" && !Array.isArray(v) ? v : {};
1435
+ } catch {
1436
+ return {};
1437
+ }
1438
+ }
1439
+ // Returns false when the write fails (e.g. QuotaExceededError): the
1440
+ // caller can then surface an error instead of leaving the server joined
1441
+ // while the UI silently believes it is not (the local map is how the
1442
+ // client knows it is joined).
1443
+ function saveJoined(map) {
1444
+ try {
1445
+ localStorage.setItem(JOINED_KEY, JSON.stringify(map));
1446
+ return true;
1447
+ } catch {
1448
+ return false;
1449
+ }
1450
+ }
1451
+ function joinedHere() {
1452
+ return (
1453
+ !!state.current &&
1454
+ !!identity() &&
1455
+ loadJoined()[String(state.current.id)] === identity()
1456
+ );
1457
+ }
1458
+
1459
+ // Compare rooms by id: the periodic /api/rooms refresh REPLACES the
1460
+ // current room object, so object-identity checks wrongly treat a live
1461
+ // room as switched and drop valid in-flight results.
1462
+ function sameRoom(room) {
1463
+ return !!room && !!state.current && state.current.id === room.id;
1464
+ }
1465
+
1466
+ // Same room AND same visit: guards every async completion that mutates
1467
+ // view or marker state.
1468
+ function sameView(room, epoch) {
1469
+ return sameRoom(room) && epoch === state.roomEpoch;
1470
+ }
1471
+
1472
+ async function requestJson(url, options = {}, controller = new AbortController()) {
1473
+ const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
1474
+ try {
1475
+ const r = await fetch(url, { ...options, signal: controller.signal });
1476
+ const data = await r.json().catch(() => ({}));
1477
+ if (!r.ok) throw new Error(data.error || "HTTP " + r.status);
1478
+ return data;
1479
+ } finally {
1480
+ clearTimeout(timeout);
1481
+ }
1482
+ }
1483
+
1484
+ function postJson(url, payload) {
1485
+ return requestJson(url, {
1486
+ method: "POST",
1487
+ headers: { "Content-Type": "application/json" },
1488
+ body: JSON.stringify(payload),
1489
+ });
1490
+ }
1491
+
1492
+ // ---- utilities ------------------------------------------------------
1493
+
1494
+ function loadSeen() {
1495
+ try {
1496
+ const v = JSON.parse(localStorage.getItem(SEEN_KEY));
1497
+ return v && typeof v === "object" && !Array.isArray(v) ? v : {};
1498
+ } catch {
1499
+ return {};
1500
+ }
1501
+ }
1502
+ function saveSeen() {
1503
+ try {
1504
+ localStorage.setItem(SEEN_KEY, JSON.stringify(state.seen));
1505
+ } catch {}
1506
+ }
1507
+
1508
+ // Stable per-agent identity in GRAYSCALE: the FNV-1a hash picks a
1509
+ // lightness step (never a hue), so each agent keeps a distinct gray bar
1510
+ // and name weight while the palette stays strictly monochrome.
1511
+ function hue(id) {
1512
+ let h = 0x811c9dc5;
1513
+ for (let i = 0; i < id.length; i++) {
1514
+ h ^= id.charCodeAt(i);
1515
+ h = Math.imul(h, 0x01000193);
1516
+ }
1517
+ return (h >>> 0) % 360;
1518
+ }
1519
+
1520
+ function applyHue(node, id) {
1521
+ const h = hue(id);
1522
+ const barL = 42 + (h % 46); // 42..87% gray bar
1523
+ const nameL = 74 + (h % 19); // 74..92% keeps names >= 4.5:1 on the bg
1524
+ node.style.setProperty("--hue-name", `oklch(${nameL}% 0 0)`);
1525
+ node.style.setProperty("--hue-bar", `oklch(${barL}% 0 0 / 0.8)`);
1526
+ node.style.setProperty("--hue-bg", `oklch(90% 0 0 / 0.03)`);
1527
+ }
1528
+
1529
+ // `at` is SQLite UTC "YYYY-MM-DD HH:MM:SS".
1530
+ function parseUtc(at) {
1531
+ return new Date(at.replace(" ", "T") + "Z");
1532
+ }
1533
+
1534
+ function fmtTime(d) {
1535
+ return d.toLocaleTimeString([], {
1536
+ hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false,
1537
+ });
1538
+ }
1539
+
1540
+ function fmtDay(d) {
1541
+ const now = new Date();
1542
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
1543
+ const that = new Date(d.getFullYear(), d.getMonth(), d.getDate());
1544
+ const days = Math.round((today - that) / 86400000);
1545
+ if (days === 0) return "Today";
1546
+ if (days === 1) return "Yesterday";
1547
+ return d.toLocaleDateString([], {
1548
+ weekday: "short", year: "numeric", month: "short", day: "numeric",
1549
+ });
1550
+ }
1551
+
1552
+ function fmtRel(at) {
1553
+ const s = Math.max(0, (Date.now() - parseUtc(at).getTime()) / 1000);
1554
+ if (s < 60) return "now";
1555
+ if (s < 3600) return Math.floor(s / 60) + "m";
1556
+ if (s < 86400) return Math.floor(s / 3600) + "h";
1557
+ return Math.floor(s / 86400) + "d";
1558
+ }
1559
+
1560
+ function flat(s, n) {
1561
+ const f = String(s).replace(/\s+/g, " ").trim();
1562
+ if (f.length <= n) return f;
1563
+ // Back off a cut that would split a surrogate pair (renders U+FFFD).
1564
+ const c = f.charCodeAt(n - 1);
1565
+ return f.slice(0, c >= 0xd800 && c <= 0xdbff ? n - 1 : n) + "...";
1566
+ }
1567
+
1568
+ function label(m) {
1569
+ // Humans have no role and type 'human'; their name IS the identity.
1570
+ if (m.type === "human") return m.from;
1571
+ return m.role || m.type || m.from;
1572
+ }
1573
+
1574
+ // Append text with http(s) URLs as clickable links. Everything goes in
1575
+ // via textContent/append (never innerHTML); href is only ever the
1576
+ // regex-matched http(s) token, so no javascript: vectors.
1577
+ function linkify(node, text) {
1578
+ for (const part of text.split(/(https?:\/\/[^\s<>"')\]]+)/g)) {
1579
+ if (/^https?:\/\//.test(part)) {
1580
+ const trail = part.match(/[.,;:!?]+$/)?.[0] ?? "";
1581
+ const url = trail ? part.slice(0, -trail.length) : part;
1582
+ const a = document.createElement("a");
1583
+ a.href = url;
1584
+ a.textContent = url;
1585
+ a.target = "_blank";
1586
+ a.rel = "noopener noreferrer";
1587
+ node.append(a);
1588
+ if (trail) node.append(trail);
1589
+ } else if (part) {
1590
+ node.append(part);
1591
+ }
1592
+ }
1593
+ }
1594
+
1595
+ /** True when the message is aimed at this human: an @mention of their
1596
+ * identity or a reply to one of their messages. */
1597
+ function directedAtMe(m) {
1598
+ const me = identity();
1599
+ if (!me || m.from === me) return false;
1600
+ if (Array.isArray(m.mentions) && m.mentions.includes(me)) return true;
1601
+ // The stored reply author decides (it survives parent pruning and
1602
+ // needs no parent row loaded); the in-memory parent lookup remains
1603
+ // as a fallback for rows written before the column existed.
1604
+ if (m.reply_to_agent) return m.reply_to_agent === me;
1605
+ if (m.reply_to_seq) {
1606
+ const parent = state.msgs.find((x) => x.seq === m.reply_to_seq);
1607
+ if (parent && parent.from === me) return true;
1608
+ }
1609
+ return false;
1610
+ }
1611
+
1612
+ async function fetchJson(url) {
1613
+ return requestJson(url);
1614
+ }
1615
+
1616
+ function noteOk() {
1617
+ state.failures = 0;
1618
+ el.banner.hidden = true;
1619
+ el.liveDot.className = "dot ok";
1620
+ el.liveDot.title = "connected, polling";
1621
+ }
1622
+ function noteFail() {
1623
+ state.failures++;
1624
+ if (state.failures >= 2) {
1625
+ el.banner.hidden = false;
1626
+ el.liveDot.className = "dot bad";
1627
+ el.liveDot.title = "connection lost";
1628
+ }
1629
+ }
1630
+
1631
+ // ---- sidebar --------------------------------------------------------
1632
+
1633
+ let firstRoomsRender = true;
1634
+
1635
+ function renderRooms() {
1636
+ const q = el.filter.value.trim().toLowerCase();
1637
+ const scroll = el.rooms.scrollTop;
1638
+ el.rooms.textContent = "";
1639
+ // Most recently active first (UTC strings compare lexicographically);
1640
+ // quiet rooms keep id order at the bottom.
1641
+ const sorted = [...state.rooms].sort((a, b) => {
1642
+ const A = a.last_activity || "";
1643
+ const B = b.last_activity || "";
1644
+ if (A !== B) return A < B ? 1 : -1;
1645
+ return a.id - b.id;
1646
+ });
1647
+ const shown = sorted.filter(
1648
+ (r) =>
1649
+ !q ||
1650
+ r.name.toLowerCase().includes(q) ||
1651
+ (r.description || "").toLowerCase().includes(q),
1652
+ );
1653
+ if (state.rooms.length === 0) {
1654
+ const note = document.createElement("div");
1655
+ note.className = "side-note";
1656
+ note.textContent =
1657
+ "No rooms yet. Agents create rooms with create_room; they will appear here.";
1658
+ el.rooms.append(note);
1659
+ return;
1660
+ }
1661
+ if (shown.length === 0) {
1662
+ const note = document.createElement("div");
1663
+ note.className = "side-note";
1664
+ note.textContent = "No rooms match the filter.";
1665
+ el.rooms.append(note);
1666
+ return;
1667
+ }
1668
+ shown.forEach((room, i) => {
1669
+ const card = document.createElement("button");
1670
+ card.className = "room-card";
1671
+ if (firstRoomsRender) {
1672
+ card.classList.add("card-in");
1673
+ card.style.animationDelay = Math.min(i * 30, 300) + "ms";
1674
+ }
1675
+ if (state.current && state.current.id === room.id) {
1676
+ card.setAttribute("aria-current", "true");
1677
+ }
1678
+
1679
+ const name = document.createElement("div");
1680
+ name.className = "name";
1681
+ name.textContent = room.name;
1682
+ card.append(name);
1683
+
1684
+ const meta = document.createElement("div");
1685
+ meta.className = "meta";
1686
+ const bits = [room.members + " present"];
1687
+ if (room.last_activity) bits.push(fmtRel(room.last_activity));
1688
+ meta.textContent = bits.join(" / ");
1689
+ card.append(meta);
1690
+
1691
+ if (room.description) {
1692
+ const desc = document.createElement("div");
1693
+ desc.className = "desc";
1694
+ desc.textContent = room.description;
1695
+ card.append(desc);
1696
+ }
1697
+
1698
+ const isOpen = state.current && state.current.id === room.id;
1699
+ if (
1700
+ !isOpen &&
1701
+ room.last_activity &&
1702
+ state.seen[room.id] !== room.last_activity
1703
+ ) {
1704
+ const dot = document.createElement("span");
1705
+ dot.className = "activity";
1706
+ dot.title = "new activity since you last viewed this room";
1707
+ card.append(dot);
1708
+ }
1709
+
1710
+ card.addEventListener("click", () => openRoom(room));
1711
+ el.rooms.append(card);
1712
+ });
1713
+ firstRoomsRender = false;
1714
+ el.rooms.scrollTop = scroll;
1715
+ }
1716
+
1717
+ // Confirm a room really is deleted (not just displaced from the capped
1718
+ // /api/rooms list) before tearing down its view. Only acts if the room
1719
+ // is STILL the open one when the check returns.
1720
+ async function confirmRoomGone(room) {
1721
+ const epoch = state.roomEpoch;
1722
+ let gone = false;
1723
+ try {
1724
+ const data = await fetchJson(`/api/room-exists?id=${room.id}`);
1725
+ gone = data.exists === false && !data.error;
1726
+ } catch {
1727
+ return; // server unreachable: keep the room, retry next refresh
1728
+ }
1729
+ if (!gone || !sameView(room, epoch)) return;
1730
+ state.current = null;
1731
+ state.searchMode = false;
1732
+ el.streamWrap.hidden = true;
1733
+ el.roomHead.hidden = true;
1734
+ el.pinned.hidden = true;
1735
+ el.deleteConfirm.hidden = true;
1736
+ renderComposer();
1737
+ updateTitle();
1738
+ showPlaceholder(
1739
+ "This room was deleted.",
1740
+ `"${room.name}" no longer exists; pick another room on the left.`,
1741
+ true,
1742
+ );
1743
+ }
1744
+
1745
+ async function loadRooms() {
1746
+ if (state.loadingRooms) return;
1747
+ state.loadingRooms = true;
1748
+ try {
1749
+ const data = await fetchJson("/api/rooms");
1750
+ noteOk();
1751
+ if (data.error && !data.rooms.length) {
1752
+ showPlaceholder("No database yet.", data.error);
1753
+ }
1754
+ state.rooms = data.rooms || [];
1755
+ if (state.current) {
1756
+ const cur = state.rooms.find((r) => r.id === state.current.id);
1757
+ if (cur) {
1758
+ // Carry the full pinned/description (from /api/room) across the
1759
+ // refresh: the list version has only a short description snippet
1760
+ // and no pinned, so replacing wholesale would blank the header.
1761
+ const pinned = state.current.pinned;
1762
+ const fullDesc = state.current.description;
1763
+ state.current = {
1764
+ ...cur,
1765
+ pinned,
1766
+ description: fullDesc ?? cur.description,
1767
+ };
1768
+ state.seen[cur.id] = cur.last_activity;
1769
+ saveSeen();
1770
+ renderRoomHead();
1771
+ } else {
1772
+ // The open room is ABSENT from the (capped, most-recently-active)
1773
+ // list. That does NOT prove deletion -- 1000+ newer rooms could
1774
+ // have displaced it. Confirm with a lean existence probe,
1775
+ // so a still-existing open room is never falsely reported gone.
1776
+ confirmRoomGone(state.current);
1777
+ }
1778
+ } else if (
1779
+ state.rooms.length &&
1780
+ el.placeholder.hidden === false &&
1781
+ !stickyPlaceholder
1782
+ ) {
1783
+ // Recovered from "Loading..."/error with no room selected.
1784
+ showPlaceholder(
1785
+ "Pick a room.",
1786
+ "Rooms are listed on the left; the amber dot marks activity you have not seen.",
1787
+ );
1788
+ }
1789
+ renderRooms();
1790
+ } catch {
1791
+ noteFail();
1792
+ if (!state.current) {
1793
+ showPlaceholder(
1794
+ "Cannot reach the viewer server.",
1795
+ "Retrying automatically.",
1796
+ );
1797
+ }
1798
+ } finally {
1799
+ state.loadingRooms = false;
1800
+ }
1801
+ }
1802
+
1803
+ // ---- room head / pinned ----------------------------------------------
1804
+
1805
+ function renderRoomHead() {
1806
+ const r = state.current;
1807
+ el.roomHead.hidden = !r;
1808
+ if (!r) return;
1809
+ el.roomName.textContent = r.name;
1810
+ el.roomDesc.textContent = r.description || "";
1811
+ el.roomStats.textContent = r.members + " present";
1812
+ el.mobileTitle.textContent = r.name;
1813
+ if (r.pinned) {
1814
+ el.pinned.hidden = false;
1815
+ el.pinnedPreview.textContent = flat(r.pinned, 110);
1816
+ el.pinnedBody.textContent = r.pinned;
1817
+ } else {
1818
+ el.pinned.hidden = true;
1819
+ }
1820
+ }
1821
+
1822
+ // ---- stream ----------------------------------------------------------
1823
+
1824
+ // sticky placeholders (deletion notices) survive the periodic rooms
1825
+ // refresh instead of being overwritten by the generic prompt.
1826
+ let stickyPlaceholder = false;
1827
+ function showPlaceholder(big, hint, sticky = false) {
1828
+ stickyPlaceholder = sticky;
1829
+ el.placeholder.hidden = false;
1830
+ el.placeholder.textContent = "";
1831
+ const b = document.createElement("div");
1832
+ b.className = "big";
1833
+ b.textContent = big;
1834
+ el.placeholder.append(b);
1835
+ if (hint) {
1836
+ const h = document.createElement("div");
1837
+ h.className = "hint";
1838
+ h.textContent = hint;
1839
+ el.placeholder.append(h);
1840
+ }
1841
+ }
1842
+ function hidePlaceholder() {
1843
+ stickyPlaceholder = false;
1844
+ el.placeholder.hidden = true;
1845
+ }
1846
+
1847
+ function nearBottom() {
1848
+ const s = el.stream;
1849
+ return s.scrollHeight - s.scrollTop - s.clientHeight < 120;
1850
+ }
1851
+ function scrollBottom() {
1852
+ el.stream.scrollTop = el.stream.scrollHeight;
1853
+ }
1854
+
1855
+ function renderBody(container, m) {
1856
+ if (m.format === "json") {
1857
+ let parsed;
1858
+ try {
1859
+ parsed = JSON.parse(m.body);
1860
+ } catch {
1861
+ parsed = undefined;
1862
+ }
1863
+ if (parsed !== undefined) {
1864
+ const pretty = JSON.stringify(parsed, null, 2);
1865
+ const pre = document.createElement("pre");
1866
+ pre.className = "json";
1867
+ pre.textContent = pretty;
1868
+ if (pretty.length > 600) {
1869
+ const fold = document.createElement("details");
1870
+ fold.className = "json-fold";
1871
+ const sum = document.createElement("summary");
1872
+ sum.textContent = "json / " + pretty.length + " chars";
1873
+ fold.append(sum, pre);
1874
+ container.append(fold);
1875
+ } else {
1876
+ container.append(pre);
1877
+ }
1878
+ return;
1879
+ }
1880
+ }
1881
+ const wrap = document.createElement("div");
1882
+ wrap.className = "body-wrap";
1883
+ const body = document.createElement("div");
1884
+ body.className = "body";
1885
+ linkify(body, m.body);
1886
+ wrap.append(body);
1887
+ const lines = (m.body.match(/\n/g) || []).length + 1;
1888
+ if (m.body.length > 1600 || lines > 20) {
1889
+ wrap.classList.add("clamped");
1890
+ const btn = document.createElement("button");
1891
+ btn.className = "expand";
1892
+ btn.textContent = "Show all (" + m.body.length + " chars)";
1893
+ btn.addEventListener("click", () => {
1894
+ const clamped = wrap.classList.toggle("clamped");
1895
+ btn.textContent = clamped
1896
+ ? "Show all (" + m.body.length + " chars)"
1897
+ : "Collapse";
1898
+ });
1899
+ wrap.append(btn);
1900
+ }
1901
+ if (m.body_truncated) {
1902
+ // The server caps very large bodies per page; label the cut so the
1903
+ // rendered text is not mistaken for the whole message.
1904
+ const note = document.createElement("div");
1905
+ note.className = "trunc-note";
1906
+ // body_length is exact (same UTF-16 unit as body.length) when the
1907
+ // row carries a stamped length; rows from old builds fall back to
1908
+ // a codepoint count, which can undershoot the shown length for
1909
+ // astral-heavy text. Guard against emitting an impossible
1910
+ // "showing X of fewer-than-X" notice in that case.
1911
+ const shown = m.body.length;
1912
+ const total = Number(m.body_length);
1913
+ note.textContent =
1914
+ total > shown
1915
+ ? "[showing the first " +
1916
+ shown.toLocaleString() +
1917
+ " of " +
1918
+ total.toLocaleString() +
1919
+ " characters]"
1920
+ : "[truncated: the full message is longer than shown]";
1921
+ wrap.append(note);
1922
+ }
1923
+ container.append(wrap);
1924
+ }
1925
+
1926
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
1927
+
1928
+ // Scroll a message into view. If it is outside the bounded DOM window,
1929
+ // fetch a page starting at that seq directly; never walk/rebuild
1930
+ // thousands of intermediate pages.
1931
+ async function jumpToSeq(seq) {
1932
+ const room = state.current;
1933
+ const epoch = state.roomEpoch;
1934
+ if (!room || !Number.isSafeInteger(seq) || seq < 1) return;
1935
+ let target = el.msgs.querySelector(`[data-seq="${seq}"]`);
1936
+ if (!target) {
1937
+ // A search exit may have one ordinary tail refresh in flight. Wait a
1938
+ // short bounded period for its lock, then give up rather than overlap
1939
+ // mutations of the same DOM/state window.
1940
+ for (let i = 0; i < 20 && state.loadingMsgs; i++) await sleep(100);
1941
+ if (!sameView(room, epoch) || state.loadingMsgs) return;
1942
+ state.loadingMsgs = true;
1943
+ try {
1944
+ const anchor = seq === 1 ? "before=2&limit=1" : `after=${seq - 1}&limit=${PAGE}`;
1945
+ const data = await fetchJson(`/api/messages?room=${room.id}&${anchor}`);
1946
+ if (!sameView(room, epoch) || data.error || !data.messages) return;
1947
+ state.msgs = data.messages;
1948
+ state.historicalWindow = true;
1949
+ state.follow = false;
1950
+ state.newCount = 0;
1951
+ state.noOlderHistory = false;
1952
+ rebuildStream();
1953
+ updateOlderButton();
1954
+ computeGap();
1955
+ hidePlaceholder();
1956
+ updateJump();
1957
+ } catch {
1958
+ noteFail();
1959
+ return;
1960
+ } finally {
1961
+ state.loadingMsgs = false;
1962
+ }
1963
+ target = el.msgs.querySelector(`[data-seq="${seq}"]`);
1964
+ }
1965
+ if (!sameView(room, epoch)) return;
1966
+ if (!target) return;
1967
+ target.scrollIntoView({ block: "center" });
1968
+ target.classList.remove("flash");
1969
+ void target.offsetWidth; // restart the animation
1970
+ target.classList.add("flash");
1971
+ }
1972
+
1973
+ // Build the DOM nodes for one message (day separator included when the
1974
+ // calendar day changes against `prev`).
1975
+ function buildMsg(m, prev, fresh) {
1976
+ const nodes = [];
1977
+ const d = parseUtc(m.at);
1978
+ const prevD = prev ? parseUtc(prev.at) : null;
1979
+ const newDay = !prevD || prevD.toDateString() !== d.toDateString();
1980
+ if (newDay) {
1981
+ const sep = document.createElement("div");
1982
+ sep.className = "day-sep";
1983
+ sep.textContent = fmtDay(d);
1984
+ nodes.push(sep);
1985
+ }
1986
+
1987
+ const grouped =
1988
+ !newDay &&
1989
+ prev &&
1990
+ prev.from === m.from &&
1991
+ d - prevD < GROUP_GAP_MS &&
1992
+ !prev.reply_to_seq === !m.reply_to_seq;
1993
+
1994
+ const row = document.createElement("article");
1995
+ row.className =
1996
+ "msg" +
1997
+ (grouped ? " grouped" : "") +
1998
+ (fresh ? " fresh" : "") +
1999
+ (directedAtMe(m) ? " directed" : "");
2000
+ row.dataset.seq = m.seq;
2001
+ applyHue(row, m.from);
2002
+
2003
+ const head = document.createElement("div");
2004
+ head.className = "msg-head";
2005
+ if (!grouped) {
2006
+ const who = document.createElement("span");
2007
+ who.className = "who";
2008
+ who.textContent = label(m);
2009
+ head.append(who);
2010
+ if (label(m) !== m.from) {
2011
+ const id = document.createElement("span");
2012
+ id.className = "who-id";
2013
+ id.textContent = m.from;
2014
+ head.append(id);
2015
+ }
2016
+ if (identity() && m.from === identity()) {
2017
+ const you = document.createElement("span");
2018
+ you.className = "you-badge";
2019
+ you.textContent = "you";
2020
+ head.append(you);
2021
+ }
2022
+ }
2023
+ const meta = document.createElement("span");
2024
+ meta.className = "msg-meta";
2025
+ const seq = document.createElement("span");
2026
+ seq.className = "seq";
2027
+ seq.textContent = "#" + m.seq;
2028
+ const time = document.createElement("span");
2029
+ time.textContent = fmtTime(d);
2030
+ time.title = m.at + " UTC";
2031
+ const act = document.createElement("button");
2032
+ act.className = "act";
2033
+ act.textContent = "reply";
2034
+ act.title = "reply to #" + m.seq;
2035
+ act.addEventListener("click", () => beginReply(m));
2036
+ meta.append(seq, time, act);
2037
+ head.append(meta);
2038
+ row.append(head);
2039
+
2040
+ renderBody(row, m);
2041
+
2042
+ const chips = document.createElement("div");
2043
+ if (m.reply_to_seq) {
2044
+ const chip = document.createElement("button");
2045
+ chip.className = "chip reply";
2046
+ const parent = state.msgs.find((x) => x.seq === m.reply_to_seq);
2047
+ chip.textContent =
2048
+ "reply to #" + m.reply_to_seq + (parent ? " " + label(parent) : "");
2049
+ chip.title = "jump to message #" + m.reply_to_seq;
2050
+ chip.addEventListener("click", () => jumpToSeq(m.reply_to_seq));
2051
+ chips.append(chip);
2052
+ }
2053
+ if (Array.isArray(m.mentions)) {
2054
+ for (const id of m.mentions) {
2055
+ const chip = document.createElement("span");
2056
+ chip.className = "chip";
2057
+ chip.textContent = "@" + id;
2058
+ chips.append(chip);
2059
+ }
2060
+ }
2061
+ if (m.priority) {
2062
+ const chip = document.createElement("span");
2063
+ chip.className = "chip";
2064
+ chip.textContent = "priority";
2065
+ chip.title = "durable checkpoint included in priority-only catch-up";
2066
+ chips.append(chip);
2067
+ }
2068
+ if (m.supersedes_seq) {
2069
+ const chip = document.createElement("button");
2070
+ chip.className = "chip reply";
2071
+ chip.textContent = "supersedes #" + m.supersedes_seq;
2072
+ chip.title = "this message corrects #" + m.supersedes_seq;
2073
+ chip.addEventListener("click", () => jumpToSeq(m.supersedes_seq));
2074
+ chips.append(chip);
2075
+ }
2076
+ if (m.superseded_by) {
2077
+ row.classList.add("superseded");
2078
+ const chip = document.createElement("button");
2079
+ chip.className = "chip super";
2080
+ chip.textContent = "superseded by #" + m.superseded_by;
2081
+ chip.title = "a later message corrects this one";
2082
+ chip.addEventListener("click", () => jumpToSeq(m.superseded_by));
2083
+ chips.append(chip);
2084
+ }
2085
+ if (chips.childNodes.length) {
2086
+ chips.className = "chips";
2087
+ row.append(chips);
2088
+ }
2089
+
2090
+ nodes.push(row);
2091
+ return nodes;
2092
+ }
2093
+
2094
+ function rebuildStream() {
2095
+ el.msgs.textContent = "";
2096
+ const frag = document.createDocumentFragment();
2097
+ let prev = null;
2098
+ for (const m of state.msgs) {
2099
+ for (const n of buildMsg(m, prev, false)) frag.append(n);
2100
+ prev = m;
2101
+ }
2102
+ el.msgs.append(frag);
2103
+ }
2104
+
2105
+ // Live supersession patch: when a fresh message corrects one that is
2106
+ // already rendered, annotate the old row in place. Full loads carry the
2107
+ // annotation from the server; incremental polling must patch the DOM.
2108
+ function patchSuperseded(targetSeq, bySeq) {
2109
+ const entry = state.msgs.find((x) => x.seq === targetSeq);
2110
+ if (entry) entry.superseded_by = bySeq;
2111
+ const row = el.msgs.querySelector(`[data-seq="${targetSeq}"]`);
2112
+ if (!row) return;
2113
+ row.classList.add("superseded");
2114
+ const old = row.querySelector(".chip.super");
2115
+ if (old) old.remove(); // a newer superseder replaces the marker
2116
+ let chips = row.querySelector(".chips");
2117
+ if (!chips) {
2118
+ chips = document.createElement("div");
2119
+ chips.className = "chips";
2120
+ row.append(chips);
2121
+ }
2122
+ const chip = document.createElement("button");
2123
+ chip.className = "chip super";
2124
+ chip.textContent = "superseded by #" + bySeq;
2125
+ chip.title = "a later message corrects this one";
2126
+ chip.addEventListener("click", () => jumpToSeq(bySeq));
2127
+ chips.append(chip);
2128
+ }
2129
+
2130
+ function appendMsgs(rows, fresh) {
2131
+ let prev = state.msgs[state.msgs.length - 1] || null;
2132
+ const frag = document.createDocumentFragment();
2133
+ for (const m of rows) {
2134
+ for (const n of buildMsg(m, prev, fresh)) frag.append(n);
2135
+ state.msgs.push(m);
2136
+ prev = m;
2137
+ }
2138
+ el.msgs.append(frag);
2139
+ if (state.msgs.length > MAX_RENDERED_MESSAGES) {
2140
+ state.msgs = state.msgs.slice(-TRIM_RENDERED_TO);
2141
+ state.noOlderHistory = false;
2142
+ rebuildStream();
2143
+ updateOlderButton();
2144
+ }
2145
+ if (fresh) {
2146
+ for (const m of rows) {
2147
+ if (m.supersedes_seq) patchSuperseded(m.supersedes_seq, m.seq);
2148
+ }
2149
+ }
2150
+ }
2151
+
2152
+ function updateOlderButton() {
2153
+ const first = state.msgs[0];
2154
+ // Hide when the oldest rendered message is seq 1 (nothing older exists)
2155
+ // OR paging already proved the floor empty (older messages were pruned,
2156
+ // so the button would fetch nothing but still invite a dead click).
2157
+ el.older.hidden =
2158
+ !first ||
2159
+ first.seq <= 1 ||
2160
+ state.noOlderHistory ||
2161
+ state.msgs.length >= MAX_RENDERED_MESSAGES;
2162
+ }
2163
+
2164
+ function updateTitle() {
2165
+ const badge = state.newCount > 0 ? `(${state.newCount}) ` : "";
2166
+ const room = state.current ? state.current.name + " - " : "";
2167
+ document.title = badge + room + "agent-chat";
2168
+ }
2169
+
2170
+ function updateJump() {
2171
+ if (state.historicalWindow) {
2172
+ el.jump.hidden = false;
2173
+ el.jump.textContent = "return to latest";
2174
+ } else if (state.newCount > 0 && !state.follow) {
2175
+ el.jump.hidden = false;
2176
+ el.jump.textContent =
2177
+ state.newCount + " new / jump to latest";
2178
+ } else {
2179
+ el.jump.hidden = true;
2180
+ }
2181
+ updateTitle();
2182
+ }
2183
+
2184
+ async function loadNew(initial) {
2185
+ const room = state.current;
2186
+ const epoch = state.roomEpoch;
2187
+ if (!room || state.searchMode) return;
2188
+ if (state.historicalWindow && !initial) return;
2189
+ if (state.loadingMsgs) {
2190
+ if (initial) state.pendingInitial = true;
2191
+ return;
2192
+ }
2193
+ if (state.pendingInitial) {
2194
+ initial = true;
2195
+ state.pendingInitial = false;
2196
+ }
2197
+ state.loadingMsgs = true;
2198
+ try {
2199
+ const last = state.msgs.length
2200
+ ? state.msgs[state.msgs.length - 1].seq
2201
+ : 0;
2202
+ const data = await fetchJson(
2203
+ `/api/messages?room=${room.id}&after=${initial ? 0 : last}&limit=${PAGE}`,
2204
+ );
2205
+ noteOk();
2206
+ // Re-check searchMode at COMPLETION: a search entered while this
2207
+ // fetch was in flight hid the stream, and appending + scrolling +
2208
+ // marking against the hidden layout claims unseen messages read.
2209
+ if (
2210
+ !sameView(room, epoch) ||
2211
+ state.searchMode ||
2212
+ data.error ||
2213
+ !data.messages
2214
+ ) {
2215
+ // A discarded INITIAL load for the room still shown (epoch bumped
2216
+ // by a rename) must re-arm, or the stream stays empty till a
2217
+ // failure path happens to set pendingInitial.
2218
+ if (initial && sameRoom(room)) state.pendingInitial = true;
2219
+ return;
2220
+ }
2221
+ const rows = initial
2222
+ ? data.messages
2223
+ : data.messages.filter((m) => m.seq > last);
2224
+ if (initial) {
2225
+ state.msgs = [];
2226
+ el.msgs.textContent = "";
2227
+ appendMsgs(rows, false);
2228
+ // Fewer rows than the fetch limit = the room's ENTIRE history is
2229
+ // rendered; anything below the window's floor was pruned. A page
2230
+ // the server cut short for SIZE (trimmed) proves nothing about
2231
+ // history and must not disable the gap guard.
2232
+ if (rows.length < PAGE && !data.trimmed) state.noOlderHistory = true;
2233
+ updateOlderButton();
2234
+ computeGap();
2235
+ if (rows.length === 0) {
2236
+ showPlaceholder(
2237
+ "Nothing here yet.",
2238
+ "Messages agents post in this room will stream in live.",
2239
+ );
2240
+ } else {
2241
+ hidePlaceholder();
2242
+ }
2243
+ requestAnimationFrame(scrollBottom);
2244
+ } else if (rows.length) {
2245
+ hidePlaceholder();
2246
+ const stick = state.follow || nearBottom();
2247
+ appendMsgs(rows, true);
2248
+ if (stick) {
2249
+ requestAnimationFrame(scrollBottom);
2250
+ } else {
2251
+ state.newCount += rows.length;
2252
+ updateJump();
2253
+ }
2254
+ }
2255
+ // A transient /api/me failure must not freeze this visit's read
2256
+ // receipt forever. Retry only while the baseline is unknown;
2257
+ // fetchMarker supplies an in-flight guard and five-second backoff.
2258
+ if (state.marker < 0 || state.markerIdent !== identity()) fetchMarker();
2259
+ maybeMarkRead();
2260
+ } catch {
2261
+ if (initial) state.pendingInitial = true; // retry with initial semantics
2262
+ noteFail();
2263
+ } finally {
2264
+ state.loadingMsgs = false;
2265
+ }
2266
+ }
2267
+
2268
+ // Returns true when it prepended messages (used by jumpToSeq to detect
2269
+ // progress); false on busy, error, or exhausted history.
2270
+ async function loadOlder() {
2271
+ const room = state.current;
2272
+ const epoch = state.roomEpoch;
2273
+ const first = state.msgs[0];
2274
+ if (
2275
+ !room ||
2276
+ !first ||
2277
+ state.loadingMsgs ||
2278
+ state.msgs.length >= MAX_RENDERED_MESSAGES
2279
+ ) return false;
2280
+ state.loadingMsgs = true;
2281
+ el.older.disabled = true;
2282
+ let changed = false;
2283
+ try {
2284
+ const pageLimit = Math.min(
2285
+ PAGE,
2286
+ MAX_RENDERED_MESSAGES - state.msgs.length,
2287
+ );
2288
+ const data = await fetchJson(
2289
+ `/api/messages?room=${room.id}&before=${first.seq}&limit=${pageLimit}`,
2290
+ );
2291
+ if (!sameView(room, epoch) || data.error || !data.messages) return false;
2292
+ if (data.messages.length) {
2293
+ const prevHeight = el.stream.scrollHeight;
2294
+ const prevTop = el.stream.scrollTop;
2295
+ state.msgs = data.messages.concat(state.msgs);
2296
+ rebuildStream();
2297
+ el.stream.scrollTop =
2298
+ el.stream.scrollHeight - prevHeight + prevTop;
2299
+ changed = true;
2300
+ }
2301
+ updateOlderButton();
2302
+ if (!data.messages.length) {
2303
+ el.older.hidden = true;
2304
+ state.noOlderHistory = true; // floor reached: the rest was pruned
2305
+ }
2306
+ const hadGap = state.gap;
2307
+ computeGap();
2308
+ if (hadGap && !state.gap) maybeMarkRead(); // gap closed by paging
2309
+ } catch {
2310
+ noteFail();
2311
+ } finally {
2312
+ el.older.disabled = false;
2313
+ state.loadingMsgs = false;
2314
+ }
2315
+ return changed;
2316
+ }
2317
+
2318
+ // ---- search mode --------------------------------------------------------
2319
+
2320
+ function renderResult(m) {
2321
+ const row = document.createElement("article");
2322
+ row.className = "msg result";
2323
+ applyHue(row, m.from);
2324
+
2325
+ const head = document.createElement("div");
2326
+ head.className = "msg-head";
2327
+ const who = document.createElement("span");
2328
+ who.className = "who";
2329
+ who.textContent = label(m);
2330
+ head.append(who);
2331
+ if (label(m) !== m.from) {
2332
+ const id = document.createElement("span");
2333
+ id.className = "who-id";
2334
+ id.textContent = m.from;
2335
+ head.append(id);
2336
+ }
2337
+ const meta = document.createElement("span");
2338
+ meta.className = "msg-meta";
2339
+ const seq = document.createElement("span");
2340
+ seq.className = "seq";
2341
+ seq.textContent = "#" + m.seq;
2342
+ const time = document.createElement("span");
2343
+ time.textContent = fmtTime(parseUtc(m.at));
2344
+ const go = document.createElement("span");
2345
+ go.className = "go";
2346
+ go.textContent = "view in stream";
2347
+ if (m.superseded_by) {
2348
+ row.classList.add("superseded");
2349
+ const sup = document.createElement("span");
2350
+ sup.textContent = "superseded by #" + m.superseded_by;
2351
+ sup.style.color = "var(--danger)";
2352
+ meta.append(sup);
2353
+ }
2354
+ if (m.supersedes_seq) {
2355
+ const cor = document.createElement("span");
2356
+ cor.textContent = "supersedes #" + m.supersedes_seq;
2357
+ meta.append(cor);
2358
+ }
2359
+ meta.append(seq, time, go);
2360
+ head.append(meta);
2361
+ row.append(head);
2362
+
2363
+ const body = document.createElement("div");
2364
+ body.className = "body";
2365
+ body.textContent = flat(m.body, 320);
2366
+ row.append(body);
2367
+
2368
+ row.addEventListener("click", () => {
2369
+ exitSearch(false);
2370
+ jumpToSeq(m.seq);
2371
+ });
2372
+ return row;
2373
+ }
2374
+
2375
+ async function enterSearch() {
2376
+ const room = state.current;
2377
+ const epoch = state.roomEpoch;
2378
+ const q = el.search.value.trim();
2379
+ if (!room) return;
2380
+ if (!q) return exitSearch();
2381
+ // Order concurrent searches: a slow older query must not overwrite a
2382
+ // newer query's already-rendered results.
2383
+ const mySeq = ++state.searchSeq;
2384
+ state.searchController?.abort();
2385
+ const controller = new AbortController();
2386
+ state.searchController = controller;
2387
+ let data;
2388
+ try {
2389
+ data = await requestJson(
2390
+ `/api/search?room=${room.id}&q=${encodeURIComponent(q)}&limit=50`,
2391
+ {},
2392
+ controller,
2393
+ );
2394
+ if (data.error) throw new Error(data.error);
2395
+ } catch (e) {
2396
+ data = { matches: [], failed: e.message };
2397
+ } finally {
2398
+ if (state.searchController === controller) state.searchController = null;
2399
+ }
2400
+ if (!sameView(room, epoch) || mySeq !== state.searchSeq) return;
2401
+ state.searchMode = true;
2402
+ el.msgs.hidden = true;
2403
+ el.older.hidden = true;
2404
+ // A gapped room otherwise leaves a stray "Mark all as read" button
2405
+ // floating above the results, marking from within search.
2406
+ el.markall.hidden = true;
2407
+ hidePlaceholder();
2408
+ el.searchBar.hidden = false;
2409
+ el.results.hidden = false;
2410
+ el.results.textContent = "";
2411
+ el.searchInfo.textContent = data.failed
2412
+ ? "Search failed: " + data.failed
2413
+ : data.matches.length
2414
+ ? `${data.matches.length} match(es) for "${q}", best first. Click one to view it in the stream.` +
2415
+ (data.trimmed
2416
+ ? " (more matches exist beyond those shown; narrow your query.)"
2417
+ : "")
2418
+ : `No matches for "${q}".`;
2419
+ for (const m of data.matches || []) el.results.append(renderResult(m));
2420
+ el.stream.scrollTop = 0;
2421
+ }
2422
+
2423
+ function exitSearch(scrollToBottom = true) {
2424
+ const wasSearching = state.searchMode;
2425
+ // Invalidate any search still in flight: without this, a delayed
2426
+ // response re-opens search mode over the freshly restored stream.
2427
+ state.searchSeq += 1;
2428
+ state.searchController?.abort();
2429
+ state.searchController = null;
2430
+ state.searchMode = false;
2431
+ el.searchBar.hidden = true;
2432
+ el.results.hidden = true;
2433
+ el.results.textContent = "";
2434
+ el.msgs.hidden = false;
2435
+ // A rename that landed during search left the (hidden) rows' identity
2436
+ // decoration stale; rebuild now that the stream is visible again.
2437
+ if (state.needsRebuild) {
2438
+ state.needsRebuild = false;
2439
+ rebuildStream();
2440
+ }
2441
+ updateOlderButton();
2442
+ computeGap(); // restore the markall button hidden by enterSearch
2443
+ if (!wasSearching) return;
2444
+ loadNew(false); // catch up on the tail that was paused during search
2445
+ if (scrollToBottom) {
2446
+ state.follow = true;
2447
+ requestAnimationFrame(scrollBottom);
2448
+ }
2449
+ }
2450
+
2451
+ // ---- participation ------------------------------------------------------
2452
+
2453
+ function renderComposer() {
2454
+ el.composer.hidden = !state.current;
2455
+ if (!state.current) return;
2456
+ const joined = joinedHere();
2457
+ el.composeJoin.hidden = joined;
2458
+ el.composeBox.hidden = !joined;
2459
+ el.joinError.textContent = "";
2460
+ el.composeError.textContent = "";
2461
+ if (joined) {
2462
+ el.composeIdent.textContent = identity();
2463
+ } else {
2464
+ if (!el.joinName.value) el.joinName.value = identity();
2465
+ el.joinBtn.textContent = "Join " + state.current.name;
2466
+ }
2467
+ renderReplyTarget();
2468
+ }
2469
+
2470
+ function renderReplyTarget() {
2471
+ if (state.replyTo) {
2472
+ el.replyTarget.hidden = false;
2473
+ el.replyTarget.textContent =
2474
+ "replying to #" + state.replyTo.seq + " " + state.replyTo.label + " (cancel)";
2475
+ } else {
2476
+ el.replyTarget.hidden = true;
2477
+ }
2478
+ }
2479
+
2480
+ function beginReply(m) {
2481
+ if (!state.current) return;
2482
+ if (!joinedHere()) {
2483
+ renderComposer();
2484
+ el.joinError.textContent = "join the room to reply";
2485
+ el.joinName.focus();
2486
+ return;
2487
+ }
2488
+ state.replyTo = { seq: m.seq, label: label(m) };
2489
+ renderReplyTarget();
2490
+ el.composeText.focus();
2491
+ }
2492
+
2493
+ function clearReply() {
2494
+ state.replyTo = null;
2495
+ renderReplyTarget();
2496
+ }
2497
+
2498
+ async function joinCurrent() {
2499
+ // The Enter key in the name field bypasses the button's disabled
2500
+ // state; gate on it here so two rename loops can never interleave
2501
+ // over the shared localStorage maps.
2502
+ if (el.joinBtn.disabled) return;
2503
+ const room = state.current;
2504
+ const name = el.joinName.value.trim().replace(/^@/, "");
2505
+ if (!room || !name) {
2506
+ el.joinError.textContent = "pick a name first";
2507
+ el.joinName.focus();
2508
+ return;
2509
+ }
2510
+ el.joinBtn.disabled = true;
2511
+ const epoch = state.roomEpoch;
2512
+ try {
2513
+ // The whole join/rename sequence (server calls INCLUDED) runs under
2514
+ // the cross-tab membership lock: its localStorage read-modify-write
2515
+ // steps are only merge-safe against writes that happen strictly
2516
+ // before or after, not interleaved between read and save.
2517
+ const { j, prevIdentity, saved } = await withMembershipLock(async () => {
2518
+ const prevIdentity = identity();
2519
+ // Join FIRST: the server validates the new name, and an invalid
2520
+ // rename must not have already torn down the old memberships.
2521
+ const j = await postJson("/api/join", { room: room.id, name });
2522
+ try {
2523
+ localStorage.setItem(IDENT_KEY, name);
2524
+ } catch {}
2525
+ // Renaming: soft-leave EVERY membership recorded under another name
2526
+ // (else the abandoned identity lingers as a present ghost member,
2527
+ // blocking prune's unread-refusal). Failed leaves go to the ghost
2528
+ // ledger for retry on later joins, INCLUDING the current room's
2529
+ // old name, which the map overwrite below would otherwise orphan.
2530
+ const joinedMap = loadJoined();
2531
+ const newGhosts = [];
2532
+ const removed = [];
2533
+ for (const [rid, storedName] of Object.entries(joinedMap)) {
2534
+ if (!storedName || storedName === name) continue;
2535
+ try {
2536
+ await postJson("/api/leave", { room: Number(rid), name: storedName });
2537
+ } catch {
2538
+ newGhosts.push({ room: Number(rid), name: storedName });
2539
+ }
2540
+ removed.push([rid, storedName]);
2541
+ }
2542
+ // Merge the new failures into the CURRENT ledger at save time: a
2543
+ // snapshot-wholesale save would erase entries another tab (or a
2544
+ // concurrent retry sweep) touched since this function started.
2545
+ if (newGhosts.length) {
2546
+ const ledger = loadGhosts();
2547
+ for (const g of newGhosts) {
2548
+ if (!ledger.some((x) => x.room === g.room && x.name === g.name)) {
2549
+ ledger.push(g);
2550
+ }
2551
+ }
2552
+ saveGhosts(ledger);
2553
+ }
2554
+ // Same merge discipline for the joined map: re-read it and delete
2555
+ // only the entries we actually left AND that still hold the value
2556
+ // we left under (defense in depth under the lock).
2557
+ const freshMap = loadJoined();
2558
+ for (const [rid, storedName] of removed) {
2559
+ if (freshMap[rid] === storedName) delete freshMap[rid];
2560
+ }
2561
+ freshMap[String(room.id)] = name;
2562
+ const saved = saveJoined(freshMap);
2563
+ // Re-adopting a previously-ghosted (room, name): the mapping above
2564
+ // makes that pair live again, so drop any stale ledger entry for it
2565
+ // before the next sweep can leave the room we just joined.
2566
+ const ledgerNow = loadGhosts();
2567
+ const keptGhosts = ledgerNow.filter(
2568
+ (x) => !(x.room === room.id && x.name === name),
2569
+ );
2570
+ if (keptGhosts.length !== ledgerNow.length) saveGhosts(keptGhosts);
2571
+ return { j, prevIdentity, saved };
2572
+ });
2573
+ // Fire-and-forget AFTER the lock releases: retryGhosts takes the
2574
+ // same (non-reentrant) lock, so awaiting it here would deadlock.
2575
+ retryGhosts();
2576
+ // Local storage write failed (e.g. quota): the server join committed
2577
+ // but the client cannot record it, so joinedHere() will read false
2578
+ // and the composer would silently show "not joined". Surface it
2579
+ // instead of leaving the user confused.
2580
+ if (!saved) {
2581
+ el.joinError.textContent =
2582
+ "joined on the server, but this browser could not save it locally (storage full?); free space and rejoin";
2583
+ }
2584
+ // The composer reflects the GLOBAL identity for whatever room is
2585
+ // shown now; re-render it even after a room switch so a stale
2586
+ // successful rename cannot leave it showing the old identity.
2587
+ renderComposer();
2588
+ // A rename invalidates identity-derived DECORATION on already-
2589
+ // rendered rows ("you" badges, directed-at-me styling); rebuild
2590
+ // whatever stream is currently shown from its cached rows, or defer
2591
+ // to exitSearch when the stream is hidden behind search results.
2592
+ if (prevIdentity !== name) {
2593
+ if (state.searchMode) state.needsRebuild = true;
2594
+ else rebuildStream();
2595
+ }
2596
+ // A delayed response must not mutate another visit's marker/UI --
2597
+ // but a RENAME was global, so the room shown now still carries the
2598
+ // old identity's marker state: reset it and re-baseline (fetchMarker
2599
+ // no-ops unless the new identity is joined there).
2600
+ if (!sameView(room, epoch)) {
2601
+ if (prevIdentity !== name) {
2602
+ state.lastReadSent = 0;
2603
+ state.marker = -1;
2604
+ state.markerIdent = null;
2605
+ computeGap();
2606
+ fetchMarker();
2607
+ }
2608
+ return;
2609
+ }
2610
+ // An identity change invalidates every in-flight operation that
2611
+ // was issued under the OLD identity in this same room (their
2612
+ // markers belong to the old name); a first join must not, or it
2613
+ // would drop its own visit's in-flight initial load.
2614
+ if (prevIdentity && prevIdentity !== name) state.roomEpoch += 1;
2615
+ state.lastReadSent = 0;
2616
+ state.marker = j.last_read_seq ?? 0;
2617
+ state.markerIdent = name;
2618
+ computeGap();
2619
+ maybeMarkRead();
2620
+ el.composeText.focus();
2621
+ } catch (e) {
2622
+ if (sameView(room, epoch)) el.joinError.textContent = e.message;
2623
+ } finally {
2624
+ el.joinBtn.disabled = false;
2625
+ }
2626
+ }
2627
+
2628
+ async function leaveCurrent() {
2629
+ const room = state.current;
2630
+ const epoch = state.roomEpoch;
2631
+ const name = identity();
2632
+ if (!room || !name) return;
2633
+ try {
2634
+ // Same lock as join/rename: the leave plus its map update must not
2635
+ // interleave with another tab's read-modify-write.
2636
+ await withMembershipLock(async () => {
2637
+ await postJson("/api/leave", { room: room.id, name });
2638
+ // Drop the mapping only while it still records THIS leave's
2639
+ // identity: a rename in another tab must not lose its mapping.
2640
+ const joined = loadJoined();
2641
+ if (joined[String(room.id)] === name) {
2642
+ delete joined[String(room.id)];
2643
+ saveJoined(joined);
2644
+ }
2645
+ });
2646
+ } catch (e) {
2647
+ if (sameView(room, epoch)) el.composeError.textContent = e.message;
2648
+ return;
2649
+ }
2650
+ // Composer/reply belong to whatever room is shown NOW.
2651
+ if (!sameView(room, epoch)) return;
2652
+ clearReply();
2653
+ renderComposer();
2654
+ }
2655
+
2656
+ async function sendMessage() {
2657
+ const room = state.current;
2658
+ const epoch = state.roomEpoch;
2659
+ const body = el.composeText.value.trim();
2660
+ if (!room || !joinedHere() || !body || state.sending) return;
2661
+ let refreshFromTail = false;
2662
+ state.sending = true;
2663
+ el.send.disabled = true;
2664
+ el.composeError.textContent = "";
2665
+ try {
2666
+ await postJson("/api/post", {
2667
+ room: room.id,
2668
+ name: identity(),
2669
+ body,
2670
+ reply_to_seq: state.replyTo ? state.replyTo.seq : null,
2671
+ });
2672
+ delete state.drafts[room.id];
2673
+ if (sameView(room, epoch)) {
2674
+ // Only touch the shared composer/stream state if the user is
2675
+ // still in this room; after a switch, the textarea, reply chip,
2676
+ // search mode, and follow flag all belong to the OTHER room.
2677
+ el.composeText.value = "";
2678
+ autosize();
2679
+ clearReply();
2680
+ if (state.searchMode) exitSearch();
2681
+ refreshFromTail = state.historicalWindow;
2682
+ state.historicalWindow = false;
2683
+ state.follow = true;
2684
+ state.newCount = 0;
2685
+ updateJump();
2686
+ }
2687
+ } catch (e) {
2688
+ if (sameView(room, epoch)) el.composeError.textContent = e.message;
2689
+ } finally {
2690
+ state.sending = false;
2691
+ el.send.disabled = false;
2692
+ if (sameView(room, epoch)) el.composeText.focus();
2693
+ }
2694
+ // Posting from a historical snapshot returns with one bounded tail
2695
+ // refresh; an incremental read would otherwise begin walking the gap.
2696
+ if (sameView(room, epoch)) loadNew(refreshFromTail);
2697
+ }
2698
+
2699
+ function autosize() {
2700
+ const t = el.composeText;
2701
+ t.style.height = "auto";
2702
+ t.style.height = Math.min(t.scrollHeight, 160) + "px";
2703
+ }
2704
+
2705
+ // Report how far this human has actually read (rendered while the tab is
2706
+ // visible), monotonically. Keeps agents' read receipts on us truthful and
2707
+ // keeps prune's unread-refusal from wedging on a joined-but-idle human.
2708
+ function computeGap() {
2709
+ const first = state.msgs[0];
2710
+ // noOlderHistory: a gap below a floor that paging proved empty points
2711
+ // at pruned (unfetchable) messages; suspending marking for them would
2712
+ // wedge a fresh join to a pruned room forever.
2713
+ state.gap =
2714
+ joinedHere() &&
2715
+ !!first &&
2716
+ state.marker >= 0 &&
2717
+ first.seq > state.marker + 1 &&
2718
+ !state.noOlderHistory;
2719
+ el.markall.hidden = !state.gap;
2720
+ el.older.classList.toggle("gapped", state.gap);
2721
+ el.older.textContent = state.gap
2722
+ ? "Load earlier messages (unread above)"
2723
+ : "Load earlier messages";
2724
+ }
2725
+
2726
+ // Learn the server-side marker (after reload/room switch) so gap logic
2727
+ // has a baseline before any auto-marking is allowed.
2728
+ async function fetchMarker() {
2729
+ const room = state.current;
2730
+ const epoch = state.roomEpoch;
2731
+ const name = identity();
2732
+ if (!room || !name || !joinedHere()) return;
2733
+ const retryKey = `${room.id}:${epoch}:${name}`;
2734
+ if (state.markerLoading) return;
2735
+ if (
2736
+ state.markerRetryKey === retryKey &&
2737
+ Date.now() < state.markerRetryAt
2738
+ ) {
2739
+ return;
2740
+ }
2741
+ state.markerLoading = true;
2742
+ try {
2743
+ const me = await fetchJson(
2744
+ `/api/me?room=${room.id}&name=${encodeURIComponent(name)}`,
2745
+ );
2746
+ if (me.error) throw new Error(me.error);
2747
+ if (!sameView(room, epoch)) return;
2748
+ // The identity may have moved (another tab renaming) between the
2749
+ // request and this completion; the epoch bump from the storage
2750
+ // listener usually catches that, but its event delivery is async,
2751
+ // so re-check before baselining the wrong identity's marker.
2752
+ if (identity() !== name) return;
2753
+ // Never LOWER a marker a same-epoch same-identity writer (join,
2754
+ // markall) advanced while this response was in flight: the server
2755
+ // marker is monotonic, so the max is the truth; a stale value
2756
+ // would resurrect a cleared gap and suspend auto-marking.
2757
+ state.marker = me.joined
2758
+ ? state.markerIdent === name && state.marker >= 0
2759
+ ? Math.max(state.marker, me.last_read_seq)
2760
+ : me.last_read_seq
2761
+ : -1;
2762
+ state.markerIdent = name;
2763
+ state.markerRetryKey = "";
2764
+ state.markerRetryAt = 0;
2765
+ computeGap();
2766
+ maybeMarkRead();
2767
+ } catch {
2768
+ if (sameView(room, epoch) && identity() === name) {
2769
+ state.markerRetryKey = retryKey;
2770
+ state.markerRetryAt = Date.now() + 5_000;
2771
+ }
2772
+ } finally {
2773
+ state.markerLoading = false;
2774
+ }
2775
+ }
2776
+
2777
+ function maybeMarkRead() {
2778
+ const room = state.current;
2779
+ const epoch = state.roomEpoch;
2780
+ const name = identity();
2781
+ if (!room || !name || !joinedHere()) return;
2782
+ // Search mode hides the live stream: follow/scroll state describes
2783
+ // the RESULTS layout, and the stream tail was never on screen, so
2784
+ // marking it read here would claim unseen messages (monotonically,
2785
+ // unrecoverably) as read.
2786
+ if (state.searchMode) return;
2787
+ // The marker baseline must belong to THIS identity: after another
2788
+ // tab renames, marker/gap state still describes the old name until
2789
+ // fetchMarker re-baselines; posting meanwhile would graft the old
2790
+ // identity's read position onto the new name.
2791
+ if (state.markerIdent !== name) return;
2792
+ if (document.visibilityState !== "visible") return;
2793
+ // Only claim messages as read while pinned to the bottom: arrivals
2794
+ // above the jump pill have not been seen, and agents treat this
2795
+ // marker as a read receipt.
2796
+ if (!state.follow) return;
2797
+ // Re-derive the gap from current state rather than trusting a stale
2798
+ // flag: non-initial appends (including the degraded recovery path
2799
+ // after a dropped initial load) never ran computeGap.
2800
+ computeGap();
2801
+ // Never auto-advance over a gap of unfetched messages, and never
2802
+ // before the server-side marker is known (marker is monotonic, so a
2803
+ // premature advance is unrecoverable).
2804
+ if (state.marker < 0 || state.gap) return;
2805
+ const last = state.msgs.length
2806
+ ? state.msgs[state.msgs.length - 1].seq
2807
+ : 0;
2808
+ if (last <= state.lastReadSent) return;
2809
+ state.lastReadSent = last;
2810
+ postJson("/api/read", { room: room.id, name, seq: last })
2811
+ .then((r) => {
2812
+ // A delayed response must not mutate another visit's state.
2813
+ if (sameView(room, epoch)) state.marker = r.last_read_seq;
2814
+ })
2815
+ .catch(() => {
2816
+ if (sameView(room, epoch)) state.lastReadSent = 0; // retry next batch
2817
+ });
2818
+ }
2819
+
2820
+ // ---- room deletion ------------------------------------------------------
2821
+
2822
+ function offerDelete() {
2823
+ const room = state.current;
2824
+ if (!room) return;
2825
+ el.deleteInfo.textContent =
2826
+ `Permanently delete "${room.name}" with all messages` +
2827
+ ", its memberships, read positions, and claims? This cannot be undone.";
2828
+ el.deleteConfirm.hidden = false;
2829
+ el.deleteYes.focus();
2830
+ }
2831
+
2832
+ async function confirmDelete() {
2833
+ const room = state.current;
2834
+ const epoch = state.roomEpoch;
2835
+ if (!room) return;
2836
+ el.deleteYes.disabled = true;
2837
+ try {
2838
+ await postJson("/api/delete-room", { room: room.id, confirm: true });
2839
+ } catch (e) {
2840
+ el.deleteInfo.textContent = "Delete failed: " + e.message;
2841
+ el.deleteYes.disabled = false;
2842
+ return;
2843
+ }
2844
+ el.deleteYes.disabled = false;
2845
+ // Forget every local trace of the deleted room regardless of where
2846
+ // the user is now (map edit under the cross-tab lock).
2847
+ await withMembershipLock(() => {
2848
+ const joined = loadJoined();
2849
+ delete joined[String(room.id)];
2850
+ saveJoined(joined);
2851
+ });
2852
+ delete state.seen[room.id];
2853
+ saveSeen();
2854
+ delete state.drafts[room.id];
2855
+ try {
2856
+ const saved = JSON.parse(localStorage.getItem(ROOM_KEY));
2857
+ if (saved && saved.id === room.id) localStorage.removeItem(ROOM_KEY);
2858
+ } catch {}
2859
+ // Tear down the UI only if the deleted room is still the one shown;
2860
+ // after a switch, the current room's view (and draft) must survive.
2861
+ if (sameView(room, epoch)) {
2862
+ el.deleteConfirm.hidden = true;
2863
+ state.current = null;
2864
+ state.searchMode = false;
2865
+ el.streamWrap.hidden = true;
2866
+ el.roomHead.hidden = true;
2867
+ el.pinned.hidden = true;
2868
+ renderComposer();
2869
+ updateTitle();
2870
+ showPlaceholder(
2871
+ "Room deleted.",
2872
+ `"${room.name}" and all of its data are gone. Pick another room on the left.`,
2873
+ true,
2874
+ );
2875
+ }
2876
+ loadRooms();
2877
+ }
2878
+
2879
+ // ---- room switching ---------------------------------------------------
2880
+
2881
+ function openRoom(room) {
2882
+ // Preserve the draft being typed in the room we are leaving.
2883
+ if (state.current) state.drafts[state.current.id] = el.composeText.value;
2884
+ state.searchController?.abort();
2885
+ state.searchController = null;
2886
+ state.current = room;
2887
+ state.msgs = [];
2888
+ state.newCount = 0;
2889
+ state.follow = true;
2890
+ state.historicalWindow = false;
2891
+ state.replyTo = null;
2892
+ state.lastReadSent = 0;
2893
+ state.marker = -1;
2894
+ state.markerIdent = null;
2895
+ state.gap = false;
2896
+ state.noOlderHistory = false;
2897
+ state.needsRebuild = false; // a deferred rebuild belongs to the old room
2898
+ state.roomEpoch += 1;
2899
+ try {
2900
+ localStorage.setItem(ROOM_KEY, JSON.stringify({ id: room.id }));
2901
+ } catch {}
2902
+ state.seen[room.id] = room.last_activity;
2903
+ saveSeen();
2904
+ el.msgs.textContent = "";
2905
+ el.older.hidden = true;
2906
+ el.streamWrap.hidden = false;
2907
+ el.pinned.open = false;
2908
+ state.searchMode = false;
2909
+ el.search.value = "";
2910
+ el.searchBar.hidden = true;
2911
+ el.results.hidden = true;
2912
+ el.results.textContent = "";
2913
+ el.msgs.hidden = false;
2914
+ updateJump();
2915
+ renderRoomHead();
2916
+ renderComposer();
2917
+ renderRooms();
2918
+ el.composeText.value = state.drafts[room.id] || "";
2919
+ el.deleteConfirm.hidden = true;
2920
+ autosize();
2921
+ computeGap();
2922
+ hidePlaceholder();
2923
+ document.body.classList.remove("show-rooms");
2924
+ loadNew(true);
2925
+ fetchMarker();
2926
+ fetchRoomDetail(room, state.roomEpoch); // full pinned/description
2927
+ }
2928
+
2929
+ // The recurring sidebar list carries only selection metadata. Fetch the
2930
+ // full intro once when a room is opened; decorative history counts are
2931
+ // intentionally omitted rather than repeatedly recomputed.
2932
+ async function fetchRoomDetail(room, epoch) {
2933
+ try {
2934
+ const data = await fetchJson(`/api/room?id=${room.id}`);
2935
+ if (!sameView(room, epoch) || !data.room) return;
2936
+ state.current = { ...state.current, ...data.room };
2937
+ renderRoomHead();
2938
+ } catch {}
2939
+ }
2940
+
2941
+ // ---- wiring ------------------------------------------------------------
2942
+
2943
+ el.stream.addEventListener("scroll", () => {
2944
+ // Search results share this scroll container with the (hidden) live
2945
+ // stream: recomputing follow against the results layout flipped it
2946
+ // true at the results' bottom, zeroed the new-count, and marked the
2947
+ // never-displayed stream tail as read.
2948
+ if (state.searchMode) return;
2949
+ const was = state.follow;
2950
+ state.follow = nearBottom();
2951
+ if (state.follow && state.newCount > 0) {
2952
+ state.newCount = 0;
2953
+ updateJump();
2954
+ }
2955
+ if (!was && state.follow) maybeMarkRead(); // caught up by scrolling
2956
+ });
2957
+
2958
+ el.jump.addEventListener("click", () => {
2959
+ if (state.historicalWindow) {
2960
+ // The pill remains above search results. Exit search while the
2961
+ // historical freeze is still set, so exitSearch's incremental
2962
+ // refresh is a no-op; then issue exactly one full tail refresh.
2963
+ if (state.searchMode) exitSearch(false);
2964
+ state.historicalWindow = false;
2965
+ state.follow = true;
2966
+ state.newCount = 0;
2967
+ updateJump();
2968
+ loadNew(true);
2969
+ return;
2970
+ }
2971
+ state.follow = true;
2972
+ state.newCount = 0;
2973
+ updateJump();
2974
+ scrollBottom();
2975
+ maybeMarkRead();
2976
+ });
2977
+
2978
+ el.older.addEventListener("click", loadOlder);
2979
+ el.markall.addEventListener("click", async () => {
2980
+ // Deliberate user action: skip the unfetched backlog and mark the
2981
+ // whole room read (same semantics as an agent's bare mark_read).
2982
+ const room = state.current;
2983
+ const epoch = state.roomEpoch;
2984
+ const name = identity();
2985
+ const last = state.msgs.length
2986
+ ? state.msgs[state.msgs.length - 1].seq
2987
+ : 0;
2988
+ if (!room || !name || !joinedHere() || !last) return;
2989
+ // Same identity guard as maybeMarkRead: never advance a marker whose
2990
+ // baseline was fetched for a different (pre-rename) identity.
2991
+ if (state.markerIdent !== name) return;
2992
+ try {
2993
+ const r = await postJson("/api/read", {
2994
+ room: room.id,
2995
+ name,
2996
+ seq: last,
2997
+ });
2998
+ // A delayed response must not mutate another visit's state.
2999
+ if (!sameView(room, epoch)) return;
3000
+ state.marker = r.last_read_seq;
3001
+ state.lastReadSent = Math.max(state.lastReadSent, last);
3002
+ computeGap();
3003
+ } catch {}
3004
+ });
3005
+ el.filter.addEventListener("input", renderRooms);
3006
+ el.roomsToggle.addEventListener("click", () =>
3007
+ document.body.classList.toggle("show-rooms"),
3008
+ );
3009
+
3010
+ el.joinBtn.addEventListener("click", joinCurrent);
3011
+ el.joinName.addEventListener("keydown", (e) => {
3012
+ if (e.key === "Enter") {
3013
+ e.preventDefault();
3014
+ joinCurrent();
3015
+ }
3016
+ });
3017
+ el.leaveBtn.addEventListener("click", leaveCurrent);
3018
+ el.identBtn.addEventListener("click", () => {
3019
+ el.composeBox.hidden = true;
3020
+ el.composeJoin.hidden = false;
3021
+ el.joinName.value = identity();
3022
+ el.joinName.focus();
3023
+ el.joinName.select();
3024
+ });
3025
+ el.replyTarget.addEventListener("click", clearReply);
3026
+ el.send.addEventListener("click", sendMessage);
3027
+ el.composeText.addEventListener("input", autosize);
3028
+ el.composeText.addEventListener("keydown", (e) => {
3029
+ if (e.key === "Enter" && !e.shiftKey) {
3030
+ e.preventDefault();
3031
+ sendMessage();
3032
+ }
3033
+ if (e.key === "Escape") clearReply();
3034
+ });
3035
+ el.joinName.addEventListener("keydown", (e) => {
3036
+ // Escape backs out of "Change name" when already joined.
3037
+ if (e.key === "Escape" && joinedHere()) renderComposer();
3038
+ });
3039
+ el.search.addEventListener("keydown", (e) => {
3040
+ if (e.key === "Enter") {
3041
+ e.preventDefault();
3042
+ enterSearch();
3043
+ }
3044
+ if (e.key === "Escape") {
3045
+ el.search.value = "";
3046
+ exitSearch();
3047
+ }
3048
+ });
3049
+ el.searchBack.addEventListener("click", () => exitSearch());
3050
+ el.deleteRoom.addEventListener("click", offerDelete);
3051
+ el.deleteYes.addEventListener("click", confirmDelete);
3052
+ el.deleteNo.addEventListener("click", () => {
3053
+ el.deleteConfirm.hidden = true;
3054
+ });
3055
+ document.addEventListener("visibilitychange", () => {
3056
+ if (document.hidden) return;
3057
+ loadNew(false);
3058
+ loadRooms();
3059
+ maybeMarkRead();
3060
+ });
3061
+
3062
+ // Another tab changing the identity or the membership map invalidates
3063
+ // every in-flight operation issued under the old identity in THIS tab:
3064
+ // its marker state belongs to the old name, and a delayed /api/read
3065
+ // would otherwise graft it onto the new identity. (storage events fire
3066
+ // only for OTHER documents, so same-tab writes never loop through here;
3067
+ // a discarded in-flight initial load re-arms via pendingInitial.)
3068
+ window.addEventListener("storage", (e) => {
3069
+ if (e.key !== IDENT_KEY && e.key !== JOINED_KEY) return;
3070
+ state.roomEpoch += 1;
3071
+ state.lastReadSent = 0;
3072
+ state.marker = -1;
3073
+ state.markerIdent = null;
3074
+ state.gap = false;
3075
+ renderComposer();
3076
+ if (state.current) {
3077
+ // Rendered rows carry identity-derived decoration ("you" badges,
3078
+ // directed styling); a cross-tab rename stales them in place. Defer
3079
+ // when search hides the stream (exitSearch will rebuild).
3080
+ if (e.key === IDENT_KEY) {
3081
+ if (state.searchMode) state.needsRebuild = true;
3082
+ else rebuildStream();
3083
+ }
3084
+ computeGap();
3085
+ fetchMarker();
3086
+ }
3087
+ });
3088
+
3089
+ setInterval(() => {
3090
+ if (!document.hidden) loadNew(false);
3091
+ }, POLL_MS);
3092
+ setInterval(() => {
3093
+ if (!document.hidden) loadRooms();
3094
+ }, ROOMS_MS);
3095
+
3096
+ (async function init() {
3097
+ showPlaceholder("Loading...", "");
3098
+ retryGhosts(); // clean up any leaves that failed in a prior session
3099
+ await loadRooms();
3100
+ let savedId = null;
3101
+ try {
3102
+ const saved = JSON.parse(localStorage.getItem(ROOM_KEY));
3103
+ if (saved && saved.id) savedId = saved.id;
3104
+ } catch {}
3105
+ const room = state.rooms.find((r) => r.id === savedId);
3106
+ if (room) {
3107
+ openRoom(room);
3108
+ } else if (state.rooms.length) {
3109
+ showPlaceholder(
3110
+ "Pick a room.",
3111
+ "Rooms are listed on the left; the amber dot marks activity you have not seen.",
3112
+ );
3113
+ }
3114
+ })();
3115
+ </script>
3116
+ </body>
3117
+ </html>