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/LICENSE +202 -0
- package/README.md +217 -0
- package/dist/bounded-lines.js +78 -0
- package/dist/build-info.json +1 -0
- package/dist/check.js +428 -0
- package/dist/db.js +3102 -0
- package/dist/index.js +2241 -0
- package/dist/poller.js +419 -0
- package/dist/unicode.js +78 -0
- package/package.json +51 -0
- package/scripts/prepare.mjs +82 -0
- package/scripts/refresh-mcp.sh +228 -0
- package/scripts/stamp-build.mjs +93 -0
- package/scripts/wait-for-updates.sh +39 -0
- package/web/index.html +3117 -0
- package/web/server.mjs +1016 -0
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>
|