readyroom 0.1.6 → 0.1.8
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/README.md +84 -1
- package/dist/server.mjs +16 -753
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -50,6 +50,54 @@ share one place to ask and one place for you to answer.
|
|
|
50
50
|
- **Three ways to run it, one program.** `npx` for one machine, Docker for an always-on box,
|
|
51
51
|
a Cloudflare Worker for when your agents cannot reach either.
|
|
52
52
|
|
|
53
|
+
## Screenshots
|
|
54
|
+
|
|
55
|
+
The page is one flow — the human draining the queue — and one authenticated role, so every
|
|
56
|
+
capture is a *state* of that single flow rather than a role of its own. A few representative
|
|
57
|
+
moments, desktop first:
|
|
58
|
+
|
|
59
|
+
<table>
|
|
60
|
+
<tr>
|
|
61
|
+
<td align="center"><b>Queue, grouped by stream</b></td>
|
|
62
|
+
<td align="center"><b>Options and free text</b></td>
|
|
63
|
+
<td align="center"><b>Option selected</b></td>
|
|
64
|
+
<td align="center"><b>Recent (undoable 5 min)</b></td>
|
|
65
|
+
</tr>
|
|
66
|
+
<tr>
|
|
67
|
+
<td><img src="docs/screenshots/pending-multi-stream.png" width="280" alt="Pending queue grouped by stream heading, with dotted low, double blocking, and solid normal urgency badges."></td>
|
|
68
|
+
<td><img src="docs/screenshots/pending-options.png" width="280" alt="A blocking-urgency ticket with option buttons above a second free-text ticket."></td>
|
|
69
|
+
<td><img src="docs/screenshots/option-selected.png" width="280" alt="A ticket with an option picked, showing a thick left border, a SELECTED marker, and the option button pressed."></td>
|
|
70
|
+
<td><img src="docs/screenshots/recent-answered.png" width="280" alt="An answered ticket in the Recent section with a dashed border, STATUS: ANSWERED badge, the recorded answer, and an Undo button."></td>
|
|
71
|
+
</tr>
|
|
72
|
+
</table>
|
|
73
|
+
|
|
74
|
+
<table>
|
|
75
|
+
<tr>
|
|
76
|
+
<td align="center"><b>Token gate</b></td>
|
|
77
|
+
<td align="center"><b>Token rejected (401)</b></td>
|
|
78
|
+
<td align="center"><b>Polling fallback</b></td>
|
|
79
|
+
<td align="center"><b>Phone width</b></td>
|
|
80
|
+
</tr>
|
|
81
|
+
<tr>
|
|
82
|
+
<td><img src="docs/screenshots/token-gate.png" width="280" alt="First paint with no token: the token bar is visible and the status reads waiting for token."></td>
|
|
83
|
+
<td><img src="docs/screenshots/token-rejected.png" width="280" alt="A wrong token was saved: a Token rejected (401) error banner with the token bar shown again."></td>
|
|
84
|
+
<td><img src="docs/screenshots/status-polling.png" width="280" alt="The SSE endpoint is unavailable, so the status reads polling every 5s."></td>
|
|
85
|
+
<td><img src="docs/screenshots/mobile-pending.png" width="150" alt="The populated multi-stream queue at phone width."></td>
|
|
86
|
+
</tr>
|
|
87
|
+
</table>
|
|
88
|
+
|
|
89
|
+
There is also an empty board, an inline per-card validation error, and a failed-load banner —
|
|
90
|
+
the page is captured at `1280x800` (desktop) and `390x844` (the two `mobile-*` shots). A full set
|
|
91
|
+
of every screen and state, with a one-line description of each, is in
|
|
92
|
+
[`docs/screenshots/MANIFEST.md`](docs/screenshots/MANIFEST.md) — regenerate the lot with
|
|
93
|
+
|
|
94
|
+
```sh
|
|
95
|
+
npm run screenshots
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
which starts its own isolated readyroom on `:8972`, drives each state, and tears it down. For how
|
|
99
|
+
to actually use each of these screens, see [`docs/USAGE.md`](docs/USAGE.md).
|
|
100
|
+
|
|
53
101
|
## Vocabulary
|
|
54
102
|
|
|
55
103
|
These words are used exactly, in code, comments, tool descriptions, and docs. No synonyms.
|
|
@@ -213,6 +261,10 @@ services:
|
|
|
213
261
|
- ./readyroom-state:/app/.state
|
|
214
262
|
```
|
|
215
263
|
|
|
264
|
+
Copy this file to get a runnable setup without retyping it:
|
|
265
|
+
[`examples/docker-compose.yml`](examples/docker-compose.yml) — set `READYROOM_TOKEN`, then
|
|
266
|
+
`docker compose up -d`.
|
|
267
|
+
|
|
216
268
|
The container runs the same Node entry as npx, in stdio-plus-HTTP mode, as a non-root user, and
|
|
217
269
|
handles `SIGTERM`.
|
|
218
270
|
|
|
@@ -368,6 +420,34 @@ The stream is the path segment, so each agent gets its own URL.
|
|
|
368
420
|
}
|
|
369
421
|
```
|
|
370
422
|
|
|
423
|
+
### Directing an agent to use readyroom (`AGENTS.md`)
|
|
424
|
+
|
|
425
|
+
Paste the block below into your project's `AGENTS.md` (or `CLAUDE.md`) so the agent knows
|
|
426
|
+
readyroom is available and how to reach it. It goes at the top level of that file, alongside
|
|
427
|
+
your other project instructions.
|
|
428
|
+
|
|
429
|
+
````markdown
|
|
430
|
+
## Asking a human
|
|
431
|
+
|
|
432
|
+
When you reach a decision you cannot make alone — a choice with real consequences, a
|
|
433
|
+
preference only the operator holds, or anything you would otherwise guess at — ask the
|
|
434
|
+
operator through the `readyroom` MCP server instead of guessing or blocking.
|
|
435
|
+
|
|
436
|
+
- `ask_human` — enqueue a question and return immediately. It never waits. Pass `question`
|
|
437
|
+
(one sentence), and optionally `options` (2-5 mutually exclusive choices), `context`,
|
|
438
|
+
`default_answer`, `timeout_seconds`, `urgency`, and `stream`. It returns
|
|
439
|
+
`{ id, status: "pending" }`.
|
|
440
|
+
- `wait_for_answer` — block for the human's answer to a ticket you enqueued. Pass the `id`.
|
|
441
|
+
It returns immediately if the ticket is already answered or expired; if it is still
|
|
442
|
+
pending, continue with independent work and call it again later. Do not poll in a tight loop.
|
|
443
|
+
- `list_pending` — list the tickets still pending across every agent stream. Takes no arguments.
|
|
444
|
+
- `cancel_ticket` — withdraw a ticket you no longer need answered. Pass the `id`.
|
|
445
|
+
|
|
446
|
+
A delivered human answer is prefixed with `HUMAN ANSWER:`. An answer applied from
|
|
447
|
+
`default_answer` on expiry is prefixed `DEFAULT ANSWER (applied on expiry; no human
|
|
448
|
+
responded):` — never treat that as a human answer.
|
|
449
|
+
````
|
|
450
|
+
|
|
371
451
|
### Auth and secrets
|
|
372
452
|
|
|
373
453
|
`src/security.ts` holds both guards.
|
|
@@ -537,7 +617,9 @@ The Worker has no state dir, no bind address, and no daemon role. Three conseque
|
|
|
537
617
|
## The readyroom page
|
|
538
618
|
|
|
539
619
|
The page is a **status board**, not a chat log. It shows what is waiting and clears when you
|
|
540
|
-
have answered — which is why there is no markdown parser and no message history.
|
|
620
|
+
have answered — which is why there is no markdown parser and no message history. For a
|
|
621
|
+
walkthrough of opening it, reading a ticket, and answering one, see
|
|
622
|
+
[`docs/USAGE.md`](docs/USAGE.md).
|
|
541
623
|
|
|
542
624
|
`public/index.html`. One file, shared by all three targets. All CSS and JS inline. No CDN, no
|
|
543
625
|
external origin, no build step. It renders correctly when opened from the server with the
|
|
@@ -621,6 +703,7 @@ npm run typecheck # tsc --noEmit
|
|
|
621
703
|
npm run build # esbuild -> dist/server.mjs (single file, HTML inlined)
|
|
622
704
|
npm run build:worker # wrangler deploy --dry-run --outdir dist/worker
|
|
623
705
|
npm run dev:worker # wrangler dev
|
|
706
|
+
npm run screenshots # capture every page state into docs/screenshots/
|
|
624
707
|
```
|
|
625
708
|
|
|
626
709
|
Repo conventions:
|
package/dist/server.mjs
CHANGED
|
@@ -7501,750 +7501,7 @@ async function acquireDaemonRole(stateDir, port) {
|
|
|
7501
7501
|
}
|
|
7502
7502
|
|
|
7503
7503
|
// public/index.html
|
|
7504
|
-
var public_default = `<!DOCTYPE html>
|
|
7505
|
-
<html lang="en">
|
|
7506
|
-
<head>
|
|
7507
|
-
<meta charset="utf-8">
|
|
7508
|
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
7509
|
-
<title>readyroom</title>
|
|
7510
|
-
<style>
|
|
7511
|
-
/* No external fonts, no external origins, no images fetched over the network.
|
|
7512
|
-
Everything below is self-contained so the page renders with the network cut. */
|
|
7513
|
-
|
|
7514
|
-
:root {
|
|
7515
|
-
--ink: #111;
|
|
7516
|
-
--paper: #fff;
|
|
7517
|
-
--muted: #555;
|
|
7518
|
-
--line: #111;
|
|
7519
|
-
--line-soft: #999;
|
|
7520
|
-
--blocking: #000;
|
|
7521
|
-
--low: #666;
|
|
7522
|
-
}
|
|
7523
|
-
|
|
7524
|
-
* { box-sizing: border-box; }
|
|
7525
|
-
|
|
7526
|
-
html, body {
|
|
7527
|
-
margin: 0;
|
|
7528
|
-
padding: 0;
|
|
7529
|
-
background: var(--paper);
|
|
7530
|
-
color: var(--ink);
|
|
7531
|
-
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
|
|
7532
|
-
font-size: 16px;
|
|
7533
|
-
line-height: 1.45;
|
|
7534
|
-
}
|
|
7535
|
-
|
|
7536
|
-
body { padding: 16px; max-width: 1100px; margin: 0 auto; }
|
|
7537
|
-
|
|
7538
|
-
h1 { font-size: 1.25rem; margin: 0 0 4px 0; letter-spacing: 0.02em; }
|
|
7539
|
-
h2 { font-size: 1rem; margin: 24px 0 8px 0; text-transform: uppercase; letter-spacing: 0.08em; border-bottom: 2px solid var(--line); padding-bottom: 4px; }
|
|
7540
|
-
h3 { font-size: 0.95rem; margin: 0 0 8px 0; }
|
|
7541
|
-
|
|
7542
|
-
.sub { color: var(--muted); font-size: 0.85rem; margin: 0 0 12px 0; }
|
|
7543
|
-
|
|
7544
|
-
/* Status + error banners: text carries the meaning, not colour. */
|
|
7545
|
-
.banner {
|
|
7546
|
-
border: 2px solid var(--line);
|
|
7547
|
-
padding: 8px 10px;
|
|
7548
|
-
margin: 8px 0;
|
|
7549
|
-
font-size: 0.85rem;
|
|
7550
|
-
}
|
|
7551
|
-
.banner[hidden] { display: none; }
|
|
7552
|
-
.banner .tag { font-weight: bold; text-transform: uppercase; letter-spacing: 0.06em; }
|
|
7553
|
-
|
|
7554
|
-
#tokenbar { border: 2px solid var(--line); padding: 12px; margin: 12px 0; }
|
|
7555
|
-
#tokenbar[hidden] { display: none; }
|
|
7556
|
-
#tokenbar label { display: block; font-weight: bold; margin-bottom: 6px; }
|
|
7557
|
-
#tokenbar .row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
|
7558
|
-
#tokenbar input { flex: 1 1 240px; min-height: 44px; padding: 8px; font: inherit; border: 2px solid var(--line); background: var(--paper); color: var(--ink); }
|
|
7559
|
-
|
|
7560
|
-
button {
|
|
7561
|
-
font: inherit;
|
|
7562
|
-
min-height: 44px;
|
|
7563
|
-
min-width: 44px;
|
|
7564
|
-
padding: 8px 12px;
|
|
7565
|
-
border: 2px solid var(--line);
|
|
7566
|
-
background: var(--paper);
|
|
7567
|
-
color: var(--ink);
|
|
7568
|
-
cursor: pointer;
|
|
7569
|
-
}
|
|
7570
|
-
button:focus-visible, input:focus-visible, a:focus-visible {
|
|
7571
|
-
outline: 3px solid var(--ink);
|
|
7572
|
-
outline-offset: 2px;
|
|
7573
|
-
}
|
|
7574
|
-
/* No hover-only affordances: hover is a bonus, never the only signal. */
|
|
7575
|
-
button:hover { background: #eee; }
|
|
7576
|
-
|
|
7577
|
-
.stream-group { margin: 0 0 20px 0; }
|
|
7578
|
-
.stream-head { display: flex; gap: 10px; align-items: baseline; flex-wrap: wrap; }
|
|
7579
|
-
.stream-name { font-weight: bold; }
|
|
7580
|
-
.stream-count { color: var(--muted); font-size: 0.85rem; }
|
|
7581
|
-
|
|
7582
|
-
.card {
|
|
7583
|
-
border: 2px solid var(--line);
|
|
7584
|
-
padding: 12px;
|
|
7585
|
-
margin: 10px 0;
|
|
7586
|
-
background: var(--paper);
|
|
7587
|
-
}
|
|
7588
|
-
/* Selection is carried by a thick left border AND a text marker, not colour alone. */
|
|
7589
|
-
.card.selected { border-left-width: 10px; }
|
|
7590
|
-
.card .selmark { display: none; font-weight: bold; text-transform: uppercase; letter-spacing: 0.06em; font-size: 0.75rem; }
|
|
7591
|
-
.card.selected .selmark { display: inline; }
|
|
7592
|
-
|
|
7593
|
-
.card-head { display: flex; gap: 10px; align-items: baseline; flex-wrap: wrap; margin-bottom: 6px; }
|
|
7594
|
-
.age { color: var(--muted); font-size: 0.85rem; }
|
|
7595
|
-
|
|
7596
|
-
.badge {
|
|
7597
|
-
display: inline-block;
|
|
7598
|
-
border: 2px solid var(--line);
|
|
7599
|
-
padding: 1px 6px;
|
|
7600
|
-
font-size: 0.75rem;
|
|
7601
|
-
text-transform: uppercase;
|
|
7602
|
-
letter-spacing: 0.06em;
|
|
7603
|
-
font-weight: bold;
|
|
7604
|
-
}
|
|
7605
|
-
.badge.urgency-blocking { border-style: double; border-width: 4px; }
|
|
7606
|
-
.badge.urgency-low { border-style: dotted; }
|
|
7607
|
-
.badge.urgency-normal { border-style: solid; }
|
|
7608
|
-
.badge.status-answered { border-style: dashed; }
|
|
7609
|
-
|
|
7610
|
-
.question { font-weight: bold; margin: 6px 0; }
|
|
7611
|
-
.context {
|
|
7612
|
-
white-space: pre-wrap;
|
|
7613
|
-
border-left: 3px solid var(--line-soft);
|
|
7614
|
-
padding: 6px 10px;
|
|
7615
|
-
margin: 8px 0;
|
|
7616
|
-
color: var(--ink);
|
|
7617
|
-
font-size: 0.9rem;
|
|
7618
|
-
}
|
|
7619
|
-
.context[hidden] { display: none; }
|
|
7620
|
-
|
|
7621
|
-
.options { display: flex; flex-wrap: wrap; gap: 8px; margin: 8px 0; }
|
|
7622
|
-
.options button[aria-pressed="true"] { border-style: double; border-width: 4px; font-weight: bold; }
|
|
7623
|
-
|
|
7624
|
-
.answer-row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin-top: 8px; }
|
|
7625
|
-
.answer-row label { font-size: 0.85rem; }
|
|
7626
|
-
.answer-row input[type="text"] {
|
|
7627
|
-
flex: 1 1 240px;
|
|
7628
|
-
min-height: 44px;
|
|
7629
|
-
padding: 8px;
|
|
7630
|
-
font: inherit;
|
|
7631
|
-
border: 2px solid var(--line);
|
|
7632
|
-
background: var(--paper);
|
|
7633
|
-
color: var(--ink);
|
|
7634
|
-
}
|
|
7635
|
-
|
|
7636
|
-
.card-error { border: 2px solid var(--line); padding: 6px 8px; margin-top: 8px; font-size: 0.85rem; }
|
|
7637
|
-
.card-error[hidden] { display: none; }
|
|
7638
|
-
.card-error .tag { font-weight: bold; text-transform: uppercase; }
|
|
7639
|
-
|
|
7640
|
-
.recent .card { border-style: dashed; }
|
|
7641
|
-
.empty { color: var(--muted); font-style: italic; }
|
|
7642
|
-
|
|
7643
|
-
.hint { color: var(--muted); font-size: 0.8rem; margin-top: 24px; border-top: 1px solid var(--line-soft); padding-top: 8px; }
|
|
7644
|
-
kbd { border: 1px solid var(--line); padding: 0 4px; font: inherit; font-size: 0.8rem; }
|
|
7645
|
-
|
|
7646
|
-
/* No animation on the critical path. Respect reduced motion. */
|
|
7647
|
-
@media (prefers-reduced-motion: reduce) {
|
|
7648
|
-
* { animation: none !important; transition: none !important; }
|
|
7649
|
-
}
|
|
7650
|
-
@media (prefers-reduced-motion: no-preference) {
|
|
7651
|
-
.card.selected { transition: border-left-width 80ms linear; }
|
|
7652
|
-
}
|
|
7653
|
-
|
|
7654
|
-
@media print {
|
|
7655
|
-
body { max-width: none; }
|
|
7656
|
-
button { display: none; }
|
|
7657
|
-
.card { break-inside: avoid; }
|
|
7658
|
-
}
|
|
7659
|
-
</style>
|
|
7660
|
-
</head>
|
|
7661
|
-
<body>
|
|
7662
|
-
<header>
|
|
7663
|
-
<h1>readyroom</h1>
|
|
7664
|
-
<p class="sub">Drain the queue. <kbd>j</kbd>/<kbd>k</kbd> move · <kbd>1</kbd>-<kbd>5</kbd> pick · <kbd>Enter</kbd> answer · <kbd>u</kbd> undo</p>
|
|
7665
|
-
<div id="status" class="banner" role="status" aria-live="polite">
|
|
7666
|
-
<span class="tag">Status:</span> <span id="status-text">starting</span>
|
|
7667
|
-
</div>
|
|
7668
|
-
<div id="error" class="banner" role="alert" hidden>
|
|
7669
|
-
<span class="tag">Error:</span> <span id="error-text"></span>
|
|
7670
|
-
</div>
|
|
7671
|
-
</header>
|
|
7672
|
-
|
|
7673
|
-
<section id="tokenbar" hidden aria-labelledby="token-label">
|
|
7674
|
-
<label id="token-label" for="token-input">Access token (READYROOM_TOKEN)</label>
|
|
7675
|
-
<div class="row">
|
|
7676
|
-
<input id="token-input" type="password" autocomplete="off" spellcheck="false" aria-describedby="token-help">
|
|
7677
|
-
<button id="token-save" type="button">Save token</button>
|
|
7678
|
-
</div>
|
|
7679
|
-
<p id="token-help" class="sub">Stored in this browser only. Sent as <code>Authorization: Bearer</code> on every request.</p>
|
|
7680
|
-
</section>
|
|
7681
|
-
|
|
7682
|
-
<main id="app">
|
|
7683
|
-
<section id="pending-section" aria-labelledby="pending-heading">
|
|
7684
|
-
<h2 id="pending-heading">Pending</h2>
|
|
7685
|
-
<div id="pending"></div>
|
|
7686
|
-
</section>
|
|
7687
|
-
|
|
7688
|
-
<section id="recent-section" class="recent" aria-labelledby="recent-heading">
|
|
7689
|
-
<h2 id="recent-heading">Recent (undoable for 5 minutes)</h2>
|
|
7690
|
-
<div id="recent"></div>
|
|
7691
|
-
</section>
|
|
7692
|
-
</main>
|
|
7693
|
-
|
|
7694
|
-
<p class="hint">No markdown rendering. Context is shown as plain text with line breaks preserved.</p>
|
|
7695
|
-
|
|
7696
|
-
<noscript>
|
|
7697
|
-
<div class="banner"><span class="tag">Error:</span> This page needs JavaScript to drain the queue.</div>
|
|
7698
|
-
</noscript>
|
|
7699
|
-
|
|
7700
|
-
<script>
|
|
7701
|
-
(function () {
|
|
7702
|
-
"use strict";
|
|
7703
|
-
|
|
7704
|
-
var TOKEN_KEY = "readyroom_token";
|
|
7705
|
-
var POLL_MS = 5000;
|
|
7706
|
-
var UNDO_WINDOW_MS = 5 * 60 * 1000;
|
|
7707
|
-
|
|
7708
|
-
var state = {
|
|
7709
|
-
tickets: [],
|
|
7710
|
-
selectedId: null,
|
|
7711
|
-
token: "",
|
|
7712
|
-
abort: null,
|
|
7713
|
-
pollTimer: null,
|
|
7714
|
-
live: false,
|
|
7715
|
-
error: "",
|
|
7716
|
-
choices: {},
|
|
7717
|
-
drafts: {}
|
|
7718
|
-
};
|
|
7719
|
-
|
|
7720
|
-
var els = {
|
|
7721
|
-
status: document.getElementById("status"),
|
|
7722
|
-
statusText: document.getElementById("status-text"),
|
|
7723
|
-
error: document.getElementById("error"),
|
|
7724
|
-
errorText: document.getElementById("error-text"),
|
|
7725
|
-
tokenbar: document.getElementById("tokenbar"),
|
|
7726
|
-
tokenInput: document.getElementById("token-input"),
|
|
7727
|
-
tokenSave: document.getElementById("token-save"),
|
|
7728
|
-
pending: document.getElementById("pending"),
|
|
7729
|
-
recent: document.getElementById("recent")
|
|
7730
|
-
};
|
|
7731
|
-
|
|
7732
|
-
/* ---------------- token handling (requirement 11) ---------------- */
|
|
7733
|
-
|
|
7734
|
-
function readToken() {
|
|
7735
|
-
try { return localStorage.getItem(TOKEN_KEY) || ""; } catch (e) { return ""; }
|
|
7736
|
-
}
|
|
7737
|
-
function writeToken(t) {
|
|
7738
|
-
try { localStorage.setItem(TOKEN_KEY, t); } catch (e) { /* storage may be blocked */ }
|
|
7739
|
-
}
|
|
7740
|
-
function dropToken() {
|
|
7741
|
-
try { localStorage.removeItem(TOKEN_KEY); } catch (e) { /* ignore */ }
|
|
7742
|
-
}
|
|
7743
|
-
function showTokenBar(show) {
|
|
7744
|
-
els.tokenbar.hidden = !show;
|
|
7745
|
-
if (show) { try { els.tokenInput.focus(); } catch (e) {} }
|
|
7746
|
-
}
|
|
7747
|
-
|
|
7748
|
-
/* ---------------- HTTP with Bearer on every request ---------------- */
|
|
7749
|
-
|
|
7750
|
-
function api(path, opts) {
|
|
7751
|
-
opts = opts || {};
|
|
7752
|
-
var headers = { "Authorization": "Bearer " + state.token };
|
|
7753
|
-
if (opts.body) { headers["Content-Type"] = "application/json"; }
|
|
7754
|
-
return fetch(path, {
|
|
7755
|
-
method: opts.method || "GET",
|
|
7756
|
-
headers: headers,
|
|
7757
|
-
body: opts.body
|
|
7758
|
-
}).then(function (res) {
|
|
7759
|
-
if (res.status === 401) {
|
|
7760
|
-
dropToken();
|
|
7761
|
-
state.token = "";
|
|
7762
|
-
state.error = "Token rejected (401). Enter it again.";
|
|
7763
|
-
showTokenBar(true);
|
|
7764
|
-
render();
|
|
7765
|
-
var err = new Error("unauthorized");
|
|
7766
|
-
err.unauthorized = true;
|
|
7767
|
-
throw err;
|
|
7768
|
-
}
|
|
7769
|
-
return res;
|
|
7770
|
-
});
|
|
7771
|
-
}
|
|
7772
|
-
|
|
7773
|
-
function fetchTickets() {
|
|
7774
|
-
if (!state.token) { return Promise.resolve(); }
|
|
7775
|
-
return api("/api/tickets").then(function (res) {
|
|
7776
|
-
if (!res.ok) { throw new Error("tickets " + res.status); }
|
|
7777
|
-
return res.json();
|
|
7778
|
-
}).then(function (data) {
|
|
7779
|
-
state.tickets = (data && data.tickets) || [];
|
|
7780
|
-
state.error = "";
|
|
7781
|
-
render();
|
|
7782
|
-
}).catch(function (err) {
|
|
7783
|
-
if (err && err.unauthorized) { return; }
|
|
7784
|
-
state.error = "Could not load tickets: " + (err && err.message ? err.message : "network error");
|
|
7785
|
-
render();
|
|
7786
|
-
});
|
|
7787
|
-
}
|
|
7788
|
-
|
|
7789
|
-
/* ---------------- live updates (requirement 1) ---------------- */
|
|
7790
|
-
|
|
7791
|
-
function startPolling() {
|
|
7792
|
-
if (state.pollTimer) { return; }
|
|
7793
|
-
state.live = false;
|
|
7794
|
-
fetchTickets();
|
|
7795
|
-
state.pollTimer = setInterval(fetchTickets, POLL_MS);
|
|
7796
|
-
render();
|
|
7797
|
-
}
|
|
7798
|
-
function stopPolling() {
|
|
7799
|
-
if (state.pollTimer) { clearInterval(state.pollTimer); state.pollTimer = null; }
|
|
7800
|
-
}
|
|
7801
|
-
|
|
7802
|
-
/* Fetch-based SSE reader.
|
|
7803
|
-
EventSource cannot attach an Authorization header, so a Bearer-protected
|
|
7804
|
-
/api/events is unreachable through it. Instead we fetch the endpoint with the
|
|
7805
|
-
token, read response.body through a ReadableStream reader, decode with a
|
|
7806
|
-
TextDecoder, and parse text/event-stream frames: frames are separated by a blank
|
|
7807
|
-
line, and the payload rides on \`data:\` lines. Any failure \u2014 a network error, a
|
|
7808
|
-
non-200 status, or a Worker's 501 \u2014 falls back to polling /api/tickets every 5 s,
|
|
7809
|
-
which is the Worker target's live-update path. */
|
|
7810
|
-
function handleFrame(frame) {
|
|
7811
|
-
var lines = frame.split("\\n");
|
|
7812
|
-
var sawData = false;
|
|
7813
|
-
for (var i = 0; i < lines.length; i++) {
|
|
7814
|
-
if (lines[i].indexOf("data:") === 0) { sawData = true; }
|
|
7815
|
-
}
|
|
7816
|
-
if (sawData) { fetchTickets(); }
|
|
7817
|
-
}
|
|
7818
|
-
|
|
7819
|
-
function readSseStream(body) {
|
|
7820
|
-
var reader = body.getReader();
|
|
7821
|
-
var decoder = new TextDecoder();
|
|
7822
|
-
var buffer = "";
|
|
7823
|
-
function pump() {
|
|
7824
|
-
return reader.read().then(function (chunk) {
|
|
7825
|
-
if (chunk.done) { throw new Error("sse stream closed"); }
|
|
7826
|
-
buffer += decoder.decode(chunk.value, { stream: true });
|
|
7827
|
-
var idx;
|
|
7828
|
-
while ((idx = buffer.indexOf("\\n\\n")) !== -1) {
|
|
7829
|
-
var frame = buffer.slice(0, idx);
|
|
7830
|
-
buffer = buffer.slice(idx + 2);
|
|
7831
|
-
handleFrame(frame);
|
|
7832
|
-
}
|
|
7833
|
-
return pump();
|
|
7834
|
-
});
|
|
7835
|
-
}
|
|
7836
|
-
return pump();
|
|
7837
|
-
}
|
|
7838
|
-
|
|
7839
|
-
function startLive() {
|
|
7840
|
-
stopLive();
|
|
7841
|
-
if (!state.token) { return; }
|
|
7842
|
-
if (typeof fetch === "undefined" || typeof TextDecoder === "undefined") { startPolling(); return; }
|
|
7843
|
-
|
|
7844
|
-
var controller = null;
|
|
7845
|
-
try { controller = new AbortController(); } catch (e) { controller = null; }
|
|
7846
|
-
if (controller) { state.abort = controller; }
|
|
7847
|
-
|
|
7848
|
-
var options = { headers: { "Authorization": "Bearer " + state.token } };
|
|
7849
|
-
if (controller) { options.signal = controller.signal; }
|
|
7850
|
-
|
|
7851
|
-
fetch("/api/events", options).then(function (res) {
|
|
7852
|
-
if (!res.ok || !res.body) {
|
|
7853
|
-
/* 401, 404, a Worker's 501, or a body we cannot stream: poll instead. */
|
|
7854
|
-
startPolling();
|
|
7855
|
-
return;
|
|
7856
|
-
}
|
|
7857
|
-
state.live = true;
|
|
7858
|
-
stopPolling();
|
|
7859
|
-
render();
|
|
7860
|
-
return readSseStream(res.body);
|
|
7861
|
-
}).catch(function () {
|
|
7862
|
-
if (controller && controller.signal.aborted) { return; }
|
|
7863
|
-
state.live = false;
|
|
7864
|
-
startPolling();
|
|
7865
|
-
});
|
|
7866
|
-
}
|
|
7867
|
-
function stopLive() {
|
|
7868
|
-
if (state.abort) {
|
|
7869
|
-
try { state.abort.abort(); } catch (e) { /* ignore */ }
|
|
7870
|
-
state.abort = null;
|
|
7871
|
-
}
|
|
7872
|
-
state.live = false;
|
|
7873
|
-
}
|
|
7874
|
-
|
|
7875
|
-
/* ---------------- derived data ---------------- */
|
|
7876
|
-
|
|
7877
|
-
function pendingTickets() {
|
|
7878
|
-
return state.tickets.filter(function (t) { return t.status === "pending"; });
|
|
7879
|
-
}
|
|
7880
|
-
function recentTickets() {
|
|
7881
|
-
var now = Date.now();
|
|
7882
|
-
return state.tickets.filter(function (t) {
|
|
7883
|
-
if (t.status !== "answered" || !t.answered_at) { return false; }
|
|
7884
|
-
return (now - new Date(t.answered_at).getTime()) <= UNDO_WINDOW_MS;
|
|
7885
|
-
}).sort(function (a, b) {
|
|
7886
|
-
return new Date(b.answered_at).getTime() - new Date(a.answered_at).getTime();
|
|
7887
|
-
});
|
|
7888
|
-
}
|
|
7889
|
-
|
|
7890
|
-
function groupByStream(tickets) {
|
|
7891
|
-
var groups = {};
|
|
7892
|
-
var order = [];
|
|
7893
|
-
tickets.forEach(function (t) {
|
|
7894
|
-
if (!groups[t.stream]) { groups[t.stream] = []; order.push(t.stream); }
|
|
7895
|
-
groups[t.stream].push(t);
|
|
7896
|
-
});
|
|
7897
|
-
order.forEach(function (s) {
|
|
7898
|
-
groups[s].sort(function (a, b) {
|
|
7899
|
-
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
|
|
7900
|
-
});
|
|
7901
|
-
});
|
|
7902
|
-
order.sort(function (a, b) {
|
|
7903
|
-
return new Date(groups[b][0].created_at).getTime() - new Date(groups[a][0].created_at).getTime();
|
|
7904
|
-
});
|
|
7905
|
-
return order.map(function (s) { return { stream: s, tickets: groups[s] }; });
|
|
7906
|
-
}
|
|
7907
|
-
|
|
7908
|
-
function orderedPending() {
|
|
7909
|
-
var out = [];
|
|
7910
|
-
groupByStream(pendingTickets()).forEach(function (g) {
|
|
7911
|
-
g.tickets.forEach(function (t) { out.push(t); });
|
|
7912
|
-
});
|
|
7913
|
-
return out;
|
|
7914
|
-
}
|
|
7915
|
-
|
|
7916
|
-
function age(iso) {
|
|
7917
|
-
var ms = Date.now() - new Date(iso).getTime();
|
|
7918
|
-
if (!isFinite(ms) || ms < 0) { ms = 0; }
|
|
7919
|
-
var s = Math.floor(ms / 1000);
|
|
7920
|
-
if (s < 60) { return s + "s"; }
|
|
7921
|
-
var m = Math.floor(s / 60);
|
|
7922
|
-
if (m < 60) { return m + "m"; }
|
|
7923
|
-
var h = Math.floor(m / 60);
|
|
7924
|
-
if (h < 24) { return h + "h"; }
|
|
7925
|
-
return Math.floor(h / 24) + "d";
|
|
7926
|
-
}
|
|
7927
|
-
|
|
7928
|
-
/* ---------------- actions ---------------- */
|
|
7929
|
-
|
|
7930
|
-
function setCardError(id, msg) {
|
|
7931
|
-
var el = document.getElementById("err-" + id);
|
|
7932
|
-
if (el) {
|
|
7933
|
-
el.hidden = false;
|
|
7934
|
-
var span = el.querySelector(".err-text");
|
|
7935
|
-
if (span) { span.textContent = msg; }
|
|
7936
|
-
}
|
|
7937
|
-
}
|
|
7938
|
-
|
|
7939
|
-
function submitAnswer(ticket) {
|
|
7940
|
-
var input = document.getElementById("free-" + ticket.id);
|
|
7941
|
-
var text = input ? input.value.trim() : (state.drafts[ticket.id] || "").trim();
|
|
7942
|
-
var choice = state.choices[ticket.id] || "";
|
|
7943
|
-
var answer = text || choice;
|
|
7944
|
-
if (!answer) {
|
|
7945
|
-
setCardError(ticket.id, "Type an answer or pick an option first.");
|
|
7946
|
-
return;
|
|
7947
|
-
}
|
|
7948
|
-
api("/api/answer", {
|
|
7949
|
-
method: "POST",
|
|
7950
|
-
body: JSON.stringify({ id: ticket.id, answer: answer, answered_by: "human" })
|
|
7951
|
-
}).then(function (res) {
|
|
7952
|
-
if (res.status === 404) { setCardError(ticket.id, "Ticket not found (404)."); return; }
|
|
7953
|
-
if (res.status === 409) { setCardError(ticket.id, "Already answered (409)."); return; }
|
|
7954
|
-
if (!res.ok) { setCardError(ticket.id, "Answer failed (" + res.status + ")."); return; }
|
|
7955
|
-
delete state.choices[ticket.id];
|
|
7956
|
-
delete state.drafts[ticket.id];
|
|
7957
|
-
return fetchTickets();
|
|
7958
|
-
}).catch(function (err) {
|
|
7959
|
-
if (err && err.unauthorized) { return; }
|
|
7960
|
-
setCardError(ticket.id, "Answer failed: " + (err && err.message ? err.message : "network error"));
|
|
7961
|
-
});
|
|
7962
|
-
}
|
|
7963
|
-
|
|
7964
|
-
function undo(ticketId) {
|
|
7965
|
-
api("/api/undo", { method: "POST", body: JSON.stringify({ id: ticketId }) })
|
|
7966
|
-
.then(function (res) {
|
|
7967
|
-
if (res.status === 409) { state.error = "Undo window has passed (409)."; render(); return; }
|
|
7968
|
-
if (!res.ok) { state.error = "Undo failed (" + res.status + ")."; render(); return; }
|
|
7969
|
-
return fetchTickets();
|
|
7970
|
-
})
|
|
7971
|
-
.catch(function (err) {
|
|
7972
|
-
if (err && err.unauthorized) { return; }
|
|
7973
|
-
state.error = "Undo failed: " + (err && err.message ? err.message : "network error");
|
|
7974
|
-
render();
|
|
7975
|
-
});
|
|
7976
|
-
}
|
|
7977
|
-
|
|
7978
|
-
function undoLast() {
|
|
7979
|
-
var recent = recentTickets();
|
|
7980
|
-
if (recent.length === 0) { return; }
|
|
7981
|
-
undo(recent[0].id);
|
|
7982
|
-
}
|
|
7983
|
-
|
|
7984
|
-
/* ---------------- keyboard (requirement 4) ---------------- */
|
|
7985
|
-
|
|
7986
|
-
function isTypingTarget(el) {
|
|
7987
|
-
if (!el) { return false; }
|
|
7988
|
-
var tag = el.tagName;
|
|
7989
|
-
return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || el.isContentEditable === true;
|
|
7990
|
-
}
|
|
7991
|
-
|
|
7992
|
-
function moveSelection(delta) {
|
|
7993
|
-
var list = orderedPending();
|
|
7994
|
-
if (list.length === 0) { return; }
|
|
7995
|
-
var idx = -1;
|
|
7996
|
-
for (var i = 0; i < list.length; i++) { if (list[i].id === state.selectedId) { idx = i; break; } }
|
|
7997
|
-
if (idx === -1) { idx = delta > 0 ? -1 : 0; }
|
|
7998
|
-
var next = idx + delta;
|
|
7999
|
-
if (next < 0) { next = 0; }
|
|
8000
|
-
if (next > list.length - 1) { next = list.length - 1; }
|
|
8001
|
-
state.selectedId = list[next].id;
|
|
8002
|
-
render();
|
|
8003
|
-
var card = document.getElementById("card-" + state.selectedId);
|
|
8004
|
-
if (card && card.scrollIntoView) { card.scrollIntoView({ block: "nearest" }); }
|
|
8005
|
-
}
|
|
8006
|
-
|
|
8007
|
-
function pickOption(n) {
|
|
8008
|
-
var list = orderedPending();
|
|
8009
|
-
var ticket = null;
|
|
8010
|
-
for (var i = 0; i < list.length; i++) { if (list[i].id === state.selectedId) { ticket = list[i]; break; } }
|
|
8011
|
-
if (!ticket || !ticket.options || !ticket.options[n - 1]) { return; }
|
|
8012
|
-
state.choices[ticket.id] = ticket.options[n - 1];
|
|
8013
|
-
render();
|
|
8014
|
-
}
|
|
8015
|
-
|
|
8016
|
-
function submitSelected() {
|
|
8017
|
-
var list = orderedPending();
|
|
8018
|
-
for (var i = 0; i < list.length; i++) {
|
|
8019
|
-
if (list[i].id === state.selectedId) { submitAnswer(list[i]); return; }
|
|
8020
|
-
}
|
|
8021
|
-
}
|
|
8022
|
-
|
|
8023
|
-
document.addEventListener("keydown", function (e) {
|
|
8024
|
-
if (isTypingTarget(e.target)) { return; }
|
|
8025
|
-
if (e.metaKey || e.ctrlKey || e.altKey) { return; }
|
|
8026
|
-
if (e.key === "j") { e.preventDefault(); moveSelection(1); }
|
|
8027
|
-
else if (e.key === "k") { e.preventDefault(); moveSelection(-1); }
|
|
8028
|
-
else if (e.key >= "1" && e.key <= "5") { e.preventDefault(); pickOption(parseInt(e.key, 10)); }
|
|
8029
|
-
else if (e.key === "Enter") { e.preventDefault(); submitSelected(); }
|
|
8030
|
-
else if (e.key === "u") { e.preventDefault(); undoLast(); }
|
|
8031
|
-
});
|
|
8032
|
-
|
|
8033
|
-
/* ---------------- rendering ---------------- */
|
|
8034
|
-
|
|
8035
|
-
function captureDrafts() {
|
|
8036
|
-
var inputs = document.querySelectorAll("[data-draft]");
|
|
8037
|
-
for (var i = 0; i < inputs.length; i++) {
|
|
8038
|
-
state.drafts[inputs[i].getAttribute("data-draft")] = inputs[i].value;
|
|
8039
|
-
}
|
|
8040
|
-
}
|
|
8041
|
-
|
|
8042
|
-
function el(tag, cls, text) {
|
|
8043
|
-
var node = document.createElement(tag);
|
|
8044
|
-
if (cls) { node.className = cls; }
|
|
8045
|
-
if (text !== undefined && text !== null) { node.textContent = text; }
|
|
8046
|
-
return node;
|
|
8047
|
-
}
|
|
8048
|
-
|
|
8049
|
-
function urgencyBadge(urgency) {
|
|
8050
|
-
var b = el("span", "badge urgency-" + urgency, "urgency: " + urgency);
|
|
8051
|
-
return b;
|
|
8052
|
-
}
|
|
8053
|
-
|
|
8054
|
-
function buildCard(ticket, isRecent) {
|
|
8055
|
-
var card = el("article", "card" + (ticket.id === state.selectedId ? " selected" : ""));
|
|
8056
|
-
card.id = "card-" + ticket.id;
|
|
8057
|
-
card.setAttribute("aria-label", "Ticket from " + ticket.stream + ": " + ticket.question);
|
|
8058
|
-
|
|
8059
|
-
var head = el("div", "card-head");
|
|
8060
|
-
head.appendChild(el("span", "selmark", "selected"));
|
|
8061
|
-
head.appendChild(el("span", "stream-name", ticket.stream));
|
|
8062
|
-
head.appendChild(el("span", "age", age(ticket.created_at) + " old"));
|
|
8063
|
-
head.appendChild(urgencyBadge(ticket.urgency));
|
|
8064
|
-
if (isRecent) {
|
|
8065
|
-
head.appendChild(el("span", "badge status-answered", "status: answered"));
|
|
8066
|
-
if (ticket.answered_by) { head.appendChild(el("span", "age", "by " + ticket.answered_by)); }
|
|
8067
|
-
}
|
|
8068
|
-
card.appendChild(head);
|
|
8069
|
-
|
|
8070
|
-
card.appendChild(el("p", "question", ticket.question));
|
|
8071
|
-
|
|
8072
|
-
if (ticket.context) {
|
|
8073
|
-
var ctx = el("div", "context", ticket.context);
|
|
8074
|
-
card.appendChild(ctx);
|
|
8075
|
-
}
|
|
8076
|
-
|
|
8077
|
-
if (isRecent) {
|
|
8078
|
-
var ans = el("p", "question", "Answer: " + (ticket.answer || "(none)"));
|
|
8079
|
-
card.appendChild(ans);
|
|
8080
|
-
var undoBtn = el("button", null, "Undo");
|
|
8081
|
-
undoBtn.type = "button";
|
|
8082
|
-
undoBtn.setAttribute("aria-label", "Undo answer for ticket from " + ticket.stream);
|
|
8083
|
-
undoBtn.addEventListener("click", function () { undo(ticket.id); });
|
|
8084
|
-
var row = el("div", "answer-row");
|
|
8085
|
-
row.appendChild(undoBtn);
|
|
8086
|
-
card.appendChild(row);
|
|
8087
|
-
return card;
|
|
8088
|
-
}
|
|
8089
|
-
|
|
8090
|
-
if (ticket.options && ticket.options.length) {
|
|
8091
|
-
var opts = el("div", "options");
|
|
8092
|
-
opts.setAttribute("role", "group");
|
|
8093
|
-
opts.setAttribute("aria-label", "Options for ticket from " + ticket.stream);
|
|
8094
|
-
ticket.options.forEach(function (opt, i) {
|
|
8095
|
-
var b = el("button", null, (i + 1) + ". " + opt);
|
|
8096
|
-
b.type = "button";
|
|
8097
|
-
b.setAttribute("aria-pressed", state.choices[ticket.id] === opt ? "true" : "false");
|
|
8098
|
-
b.addEventListener("click", function () {
|
|
8099
|
-
state.choices[ticket.id] = opt;
|
|
8100
|
-
state.selectedId = ticket.id;
|
|
8101
|
-
render();
|
|
8102
|
-
});
|
|
8103
|
-
opts.appendChild(b);
|
|
8104
|
-
});
|
|
8105
|
-
card.appendChild(opts);
|
|
8106
|
-
}
|
|
8107
|
-
|
|
8108
|
-
var form = el("form", "answer-row");
|
|
8109
|
-
form.setAttribute("novalidate", "novalidate");
|
|
8110
|
-
var label = el("label", null, "Answer");
|
|
8111
|
-
label.setAttribute("for", "free-" + ticket.id);
|
|
8112
|
-
form.appendChild(label);
|
|
8113
|
-
var input = document.createElement("input");
|
|
8114
|
-
input.type = "text";
|
|
8115
|
-
input.id = "free-" + ticket.id;
|
|
8116
|
-
input.setAttribute("data-draft", ticket.id);
|
|
8117
|
-
input.setAttribute("autocomplete", "off");
|
|
8118
|
-
input.value = state.drafts[ticket.id] || "";
|
|
8119
|
-
input.addEventListener("input", function () { state.drafts[ticket.id] = input.value; });
|
|
8120
|
-
input.addEventListener("focus", function () { state.selectedId = ticket.id; });
|
|
8121
|
-
form.appendChild(input);
|
|
8122
|
-
var submit = el("button", null, "Answer");
|
|
8123
|
-
submit.type = "submit";
|
|
8124
|
-
form.appendChild(submit);
|
|
8125
|
-
form.addEventListener("submit", function (e) {
|
|
8126
|
-
e.preventDefault();
|
|
8127
|
-
submitAnswer(ticket);
|
|
8128
|
-
});
|
|
8129
|
-
card.appendChild(form);
|
|
8130
|
-
|
|
8131
|
-
var err = el("div", "card-error");
|
|
8132
|
-
err.id = "err-" + ticket.id;
|
|
8133
|
-
err.hidden = true;
|
|
8134
|
-
err.setAttribute("role", "alert");
|
|
8135
|
-
err.appendChild(el("span", "tag", "Error: "));
|
|
8136
|
-
err.appendChild(el("span", "err-text", ""));
|
|
8137
|
-
card.appendChild(err);
|
|
8138
|
-
|
|
8139
|
-
return card;
|
|
8140
|
-
}
|
|
8141
|
-
|
|
8142
|
-
function render() {
|
|
8143
|
-
captureDrafts();
|
|
8144
|
-
|
|
8145
|
-
var activeId = document.activeElement && document.activeElement.id ? document.activeElement.id : null;
|
|
8146
|
-
var selStart = null, selEnd = null;
|
|
8147
|
-
if (activeId && document.activeElement.setSelectionRange) {
|
|
8148
|
-
try {
|
|
8149
|
-
selStart = document.activeElement.selectionStart;
|
|
8150
|
-
selEnd = document.activeElement.selectionEnd;
|
|
8151
|
-
} catch (e) { /* not a text input */ }
|
|
8152
|
-
}
|
|
8153
|
-
|
|
8154
|
-
var pending = pendingTickets();
|
|
8155
|
-
var recent = recentTickets();
|
|
8156
|
-
|
|
8157
|
-
/* requirement 5: tab title carries the count */
|
|
8158
|
-
document.title = pending.length > 0 ? "(" + pending.length + ") readyroom" : "readyroom";
|
|
8159
|
-
|
|
8160
|
-
/* status line */
|
|
8161
|
-
var statusText = "idle";
|
|
8162
|
-
if (state.live) { statusText = "live (server-sent events)"; }
|
|
8163
|
-
else if (state.pollTimer) { statusText = "polling every 5s"; }
|
|
8164
|
-
else if (!state.token) { statusText = "waiting for token"; }
|
|
8165
|
-
els.statusText.textContent = statusText;
|
|
8166
|
-
|
|
8167
|
-
if (state.error) {
|
|
8168
|
-
els.error.hidden = false;
|
|
8169
|
-
els.errorText.textContent = state.error;
|
|
8170
|
-
} else {
|
|
8171
|
-
els.error.hidden = true;
|
|
8172
|
-
els.errorText.textContent = "";
|
|
8173
|
-
}
|
|
8174
|
-
|
|
8175
|
-
/* pending, grouped by stream, newest stream first */
|
|
8176
|
-
els.pending.textContent = "";
|
|
8177
|
-
if (pending.length === 0) {
|
|
8178
|
-
els.pending.appendChild(el("p", "empty", "No pending tickets."));
|
|
8179
|
-
} else {
|
|
8180
|
-
groupByStream(pending).forEach(function (group) {
|
|
8181
|
-
var section = el("section", "stream-group");
|
|
8182
|
-
section.setAttribute("aria-label", "Stream " + group.stream);
|
|
8183
|
-
var head = el("div", "stream-head");
|
|
8184
|
-
head.appendChild(el("h3", "stream-name", group.stream));
|
|
8185
|
-
head.appendChild(el("span", "stream-count", group.tickets.length + " pending"));
|
|
8186
|
-
section.appendChild(head);
|
|
8187
|
-
group.tickets.forEach(function (t) { section.appendChild(buildCard(t, false)); });
|
|
8188
|
-
els.pending.appendChild(section);
|
|
8189
|
-
});
|
|
8190
|
-
}
|
|
8191
|
-
|
|
8192
|
-
/* recent, undoable for 5 minutes */
|
|
8193
|
-
els.recent.textContent = "";
|
|
8194
|
-
if (recent.length === 0) {
|
|
8195
|
-
els.recent.appendChild(el("p", "empty", "Nothing answered in the last 5 minutes."));
|
|
8196
|
-
} else {
|
|
8197
|
-
recent.forEach(function (t) { els.recent.appendChild(buildCard(t, true)); });
|
|
8198
|
-
}
|
|
8199
|
-
|
|
8200
|
-
/* restore focus + caret so polling re-renders do not interrupt typing */
|
|
8201
|
-
if (activeId) {
|
|
8202
|
-
var again = document.getElementById(activeId);
|
|
8203
|
-
if (again && again.focus) {
|
|
8204
|
-
try {
|
|
8205
|
-
again.focus();
|
|
8206
|
-
if (selStart !== null && again.setSelectionRange) { again.setSelectionRange(selStart, selEnd); }
|
|
8207
|
-
} catch (e) { /* ignore */ }
|
|
8208
|
-
}
|
|
8209
|
-
}
|
|
8210
|
-
}
|
|
8211
|
-
|
|
8212
|
-
/* ---------------- wiring ---------------- */
|
|
8213
|
-
|
|
8214
|
-
els.tokenSave.addEventListener("click", function () {
|
|
8215
|
-
var t = els.tokenInput.value.trim();
|
|
8216
|
-
if (!t) { return; }
|
|
8217
|
-
state.token = t;
|
|
8218
|
-
writeToken(t);
|
|
8219
|
-
state.error = "";
|
|
8220
|
-
showTokenBar(false);
|
|
8221
|
-
startLive();
|
|
8222
|
-
fetchTickets();
|
|
8223
|
-
});
|
|
8224
|
-
els.tokenInput.addEventListener("keydown", function (e) {
|
|
8225
|
-
if (e.key === "Enter") { e.preventDefault(); els.tokenSave.click(); }
|
|
8226
|
-
});
|
|
8227
|
-
|
|
8228
|
-
/* requirement 9: re-render on window load */
|
|
8229
|
-
window.addEventListener("load", render);
|
|
8230
|
-
|
|
8231
|
-
/* boot */
|
|
8232
|
-
state.token = readToken();
|
|
8233
|
-
if (state.token) {
|
|
8234
|
-
showTokenBar(false);
|
|
8235
|
-
startLive();
|
|
8236
|
-
fetchTickets();
|
|
8237
|
-
} else {
|
|
8238
|
-
showTokenBar(true);
|
|
8239
|
-
}
|
|
8240
|
-
|
|
8241
|
-
/* requirement 9: first paint is synchronous, at the end of the script, not in rAF */
|
|
8242
|
-
render();
|
|
8243
|
-
})();
|
|
8244
|
-
</script>
|
|
8245
|
-
</body>
|
|
8246
|
-
</html>
|
|
8247
|
-
`;
|
|
7504
|
+
var public_default = '<!DOCTYPE html>\n<html lang="en">\n<head>\n<meta charset="utf-8">\n<meta name="viewport" content="width=device-width, initial-scale=1">\n<title>readyroom</title>\n<!-- Suppress the /favicon.ico request. The page ships no icon and fetches nothing\n over the network; an inline empty data URI keeps the console free of a 404. -->\n<link rel="icon" href="data:,">\n<style>\n /* No external fonts, no external origins, no images fetched over the network.\n Everything below is self-contained so the page renders with the network cut. */\n\n :root {\n --ink: #111;\n --paper: #fff;\n --muted: #555;\n --line: #111;\n --line-soft: #999;\n --blocking: #000;\n --low: #666;\n }\n\n * { box-sizing: border-box; }\n\n html, body {\n margin: 0;\n padding: 0;\n background: var(--paper);\n color: var(--ink);\n font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;\n font-size: 16px;\n line-height: 1.45;\n }\n\n body { padding: 16px; max-width: 1100px; margin: 0 auto; }\n\n h1 { font-size: 1.25rem; margin: 0 0 4px 0; letter-spacing: 0.02em; }\n h2 { font-size: 1rem; margin: 24px 0 8px 0; text-transform: uppercase; letter-spacing: 0.08em; border-bottom: 2px solid var(--line); padding-bottom: 4px; }\n h3 { font-size: 0.95rem; margin: 0 0 8px 0; }\n\n .sub { color: var(--muted); font-size: 0.85rem; margin: 0 0 12px 0; }\n\n /* Status + error banners: text carries the meaning, not colour. */\n .banner {\n border: 2px solid var(--line);\n padding: 8px 10px;\n margin: 8px 0;\n font-size: 0.85rem;\n }\n .banner[hidden] { display: none; }\n .banner .tag { font-weight: bold; text-transform: uppercase; letter-spacing: 0.06em; }\n\n #tokenbar { border: 2px solid var(--line); padding: 12px; margin: 12px 0; }\n #tokenbar[hidden] { display: none; }\n #tokenbar label { display: block; font-weight: bold; margin-bottom: 6px; }\n #tokenbar .row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }\n #tokenbar input { flex: 1 1 240px; min-height: 44px; padding: 8px; font: inherit; border: 2px solid var(--line); background: var(--paper); color: var(--ink); }\n\n button {\n font: inherit;\n min-height: 44px;\n min-width: 44px;\n padding: 8px 12px;\n border: 2px solid var(--line);\n background: var(--paper);\n color: var(--ink);\n cursor: pointer;\n }\n button:focus-visible, input:focus-visible, a:focus-visible {\n outline: 3px solid var(--ink);\n outline-offset: 2px;\n }\n /* No hover-only affordances: hover is a bonus, never the only signal. */\n button:hover { background: #eee; }\n\n .stream-group { margin: 0 0 20px 0; }\n .stream-head { display: flex; gap: 10px; align-items: baseline; flex-wrap: wrap; }\n .stream-name { font-weight: bold; }\n .stream-count { color: var(--muted); font-size: 0.85rem; }\n\n .card {\n border: 2px solid var(--line);\n padding: 12px;\n margin: 10px 0;\n background: var(--paper);\n }\n /* Selection is carried by a thick left border AND a text marker, not colour alone. */\n .card.selected { border-left-width: 10px; }\n .card .selmark { display: none; font-weight: bold; text-transform: uppercase; letter-spacing: 0.06em; font-size: 0.75rem; }\n .card.selected .selmark { display: inline; }\n\n .card-head { display: flex; gap: 10px; align-items: baseline; flex-wrap: wrap; margin-bottom: 6px; }\n .age { color: var(--muted); font-size: 0.85rem; }\n\n .badge {\n display: inline-block;\n border: 2px solid var(--line);\n padding: 1px 6px;\n font-size: 0.75rem;\n text-transform: uppercase;\n letter-spacing: 0.06em;\n font-weight: bold;\n }\n .badge.urgency-blocking { border-style: double; border-width: 4px; }\n .badge.urgency-low { border-style: dotted; }\n .badge.urgency-normal { border-style: solid; }\n .badge.status-answered { border-style: dashed; }\n\n .question { font-weight: bold; margin: 6px 0; }\n .context {\n white-space: pre-wrap;\n border-left: 3px solid var(--line-soft);\n padding: 6px 10px;\n margin: 8px 0;\n color: var(--ink);\n font-size: 0.9rem;\n }\n .context[hidden] { display: none; }\n\n .options { display: flex; flex-wrap: wrap; gap: 8px; margin: 8px 0; }\n .options button[aria-pressed="true"] { border-style: double; border-width: 4px; font-weight: bold; }\n\n .answer-row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin-top: 8px; }\n .answer-row label { font-size: 0.85rem; }\n .answer-row input[type="text"] {\n flex: 1 1 240px;\n min-height: 44px;\n padding: 8px;\n font: inherit;\n border: 2px solid var(--line);\n background: var(--paper);\n color: var(--ink);\n }\n\n .card-error { border: 2px solid var(--line); padding: 6px 8px; margin-top: 8px; font-size: 0.85rem; }\n .card-error[hidden] { display: none; }\n .card-error .tag { font-weight: bold; text-transform: uppercase; }\n\n .recent .card { border-style: dashed; }\n .empty { color: var(--muted); font-style: italic; }\n\n .hint { color: var(--muted); font-size: 0.8rem; margin-top: 24px; border-top: 1px solid var(--line-soft); padding-top: 8px; }\n kbd { border: 1px solid var(--line); padding: 0 4px; font: inherit; font-size: 0.8rem; }\n\n /* No animation on the critical path. Respect reduced motion. */\n @media (prefers-reduced-motion: reduce) {\n * { animation: none !important; transition: none !important; }\n }\n @media (prefers-reduced-motion: no-preference) {\n .card.selected { transition: border-left-width 80ms linear; }\n }\n\n @media print {\n body { max-width: none; }\n button { display: none; }\n .card { break-inside: avoid; }\n }\n</style>\n</head>\n<body>\n <header>\n <h1>readyroom</h1>\n <p class="sub">Drain the queue. <kbd>j</kbd>/<kbd>k</kbd> move · <kbd>1</kbd>-<kbd>5</kbd> pick · <kbd>Enter</kbd> answer · <kbd>u</kbd> undo</p>\n <div id="status" class="banner" role="status" aria-live="polite">\n <span class="tag">Status:</span> <span id="status-text">starting</span>\n </div>\n <div id="error" class="banner" role="alert" hidden>\n <span class="tag">Error:</span> <span id="error-text"></span>\n </div>\n </header>\n\n <section id="tokenbar" hidden aria-labelledby="token-label">\n <label id="token-label" for="token-input">Access token (READYROOM_TOKEN)</label>\n <div class="row">\n <input id="token-input" type="password" autocomplete="off" spellcheck="false" aria-describedby="token-help">\n <button id="token-save" type="button">Save token</button>\n </div>\n <p id="token-help" class="sub">Stored in this browser only. Sent as <code>Authorization: Bearer</code> on every request.</p>\n </section>\n\n <main id="app">\n <section id="pending-section" aria-labelledby="pending-heading">\n <h2 id="pending-heading">Pending</h2>\n <div id="pending"></div>\n </section>\n\n <section id="recent-section" class="recent" aria-labelledby="recent-heading">\n <h2 id="recent-heading">Recent (undoable for 5 minutes)</h2>\n <div id="recent"></div>\n </section>\n </main>\n\n <p class="hint">No markdown rendering. Context is shown as plain text with line breaks preserved.</p>\n\n <noscript>\n <div class="banner"><span class="tag">Error:</span> This page needs JavaScript to drain the queue.</div>\n </noscript>\n\n<script>\n(function () {\n "use strict";\n\n var TOKEN_KEY = "readyroom_token";\n var POLL_MS = 5000;\n var UNDO_WINDOW_MS = 5 * 60 * 1000;\n\n var state = {\n tickets: [],\n selectedId: null,\n token: "",\n abort: null,\n pollTimer: null,\n live: false,\n error: "",\n choices: {},\n drafts: {}\n };\n\n var els = {\n status: document.getElementById("status"),\n statusText: document.getElementById("status-text"),\n error: document.getElementById("error"),\n errorText: document.getElementById("error-text"),\n tokenbar: document.getElementById("tokenbar"),\n tokenInput: document.getElementById("token-input"),\n tokenSave: document.getElementById("token-save"),\n pending: document.getElementById("pending"),\n recent: document.getElementById("recent")\n };\n\n /* ---------------- token handling (requirement 11) ---------------- */\n\n function readToken() {\n try { return localStorage.getItem(TOKEN_KEY) || ""; } catch (e) { return ""; }\n }\n function writeToken(t) {\n try { localStorage.setItem(TOKEN_KEY, t); } catch (e) { /* storage may be blocked */ }\n }\n function dropToken() {\n try { localStorage.removeItem(TOKEN_KEY); } catch (e) { /* ignore */ }\n }\n function showTokenBar(show) {\n els.tokenbar.hidden = !show;\n if (show) { try { els.tokenInput.focus(); } catch (e) {} }\n }\n\n /* ---------------- HTTP with Bearer on every request ---------------- */\n\n function api(path, opts) {\n opts = opts || {};\n var headers = { "Authorization": "Bearer " + state.token };\n if (opts.body) { headers["Content-Type"] = "application/json"; }\n return fetch(path, {\n method: opts.method || "GET",\n headers: headers,\n body: opts.body\n }).then(function (res) {\n if (res.status === 401) {\n dropToken();\n state.token = "";\n state.error = "Token rejected (401). Enter it again.";\n showTokenBar(true);\n render();\n var err = new Error("unauthorized");\n err.unauthorized = true;\n throw err;\n }\n return res;\n });\n }\n\n function fetchTickets() {\n if (!state.token) { return Promise.resolve(); }\n return api("/api/tickets").then(function (res) {\n if (!res.ok) { throw new Error("tickets " + res.status); }\n return res.json();\n }).then(function (data) {\n state.tickets = (data && data.tickets) || [];\n state.error = "";\n render();\n }).catch(function (err) {\n if (err && err.unauthorized) { return; }\n state.error = "Could not load tickets: " + (err && err.message ? err.message : "network error");\n render();\n });\n }\n\n /* ---------------- live updates (requirement 1) ---------------- */\n\n function startPolling() {\n if (state.pollTimer) { return; }\n state.live = false;\n fetchTickets();\n state.pollTimer = setInterval(fetchTickets, POLL_MS);\n render();\n }\n function stopPolling() {\n if (state.pollTimer) { clearInterval(state.pollTimer); state.pollTimer = null; }\n }\n\n /* Fetch-based SSE reader.\n EventSource cannot attach an Authorization header, so a Bearer-protected\n /api/events is unreachable through it. Instead we fetch the endpoint with the\n token, read response.body through a ReadableStream reader, decode with a\n TextDecoder, and parse text/event-stream frames: frames are separated by a blank\n line, and the payload rides on `data:` lines. Any failure \u2014 a network error, a\n non-200 status, or a Worker\'s 501 \u2014 falls back to polling /api/tickets every 5 s,\n which is the Worker target\'s live-update path. */\n function handleFrame(frame) {\n var lines = frame.split("\\n");\n var sawData = false;\n for (var i = 0; i < lines.length; i++) {\n if (lines[i].indexOf("data:") === 0) { sawData = true; }\n }\n if (sawData) { fetchTickets(); }\n }\n\n function readSseStream(body) {\n var reader = body.getReader();\n var decoder = new TextDecoder();\n var buffer = "";\n function pump() {\n return reader.read().then(function (chunk) {\n if (chunk.done) { throw new Error("sse stream closed"); }\n buffer += decoder.decode(chunk.value, { stream: true });\n var idx;\n while ((idx = buffer.indexOf("\\n\\n")) !== -1) {\n var frame = buffer.slice(0, idx);\n buffer = buffer.slice(idx + 2);\n handleFrame(frame);\n }\n return pump();\n });\n }\n return pump();\n }\n\n function startLive() {\n stopLive();\n if (!state.token) { return; }\n if (typeof fetch === "undefined" || typeof TextDecoder === "undefined") { startPolling(); return; }\n\n var controller = null;\n try { controller = new AbortController(); } catch (e) { controller = null; }\n if (controller) { state.abort = controller; }\n\n var options = { headers: { "Authorization": "Bearer " + state.token } };\n if (controller) { options.signal = controller.signal; }\n\n fetch("/api/events", options).then(function (res) {\n if (!res.ok || !res.body) {\n /* 401, 404, a Worker\'s 501, or a body we cannot stream: poll instead. */\n startPolling();\n return;\n }\n state.live = true;\n stopPolling();\n render();\n return readSseStream(res.body);\n }).catch(function () {\n if (controller && controller.signal.aborted) { return; }\n state.live = false;\n startPolling();\n });\n }\n function stopLive() {\n if (state.abort) {\n try { state.abort.abort(); } catch (e) { /* ignore */ }\n state.abort = null;\n }\n state.live = false;\n }\n\n /* ---------------- derived data ---------------- */\n\n function pendingTickets() {\n return state.tickets.filter(function (t) { return t.status === "pending"; });\n }\n function recentTickets() {\n var now = Date.now();\n return state.tickets.filter(function (t) {\n if (t.status !== "answered" || !t.answered_at) { return false; }\n return (now - new Date(t.answered_at).getTime()) <= UNDO_WINDOW_MS;\n }).sort(function (a, b) {\n return new Date(b.answered_at).getTime() - new Date(a.answered_at).getTime();\n });\n }\n\n function groupByStream(tickets) {\n var groups = {};\n var order = [];\n tickets.forEach(function (t) {\n if (!groups[t.stream]) { groups[t.stream] = []; order.push(t.stream); }\n groups[t.stream].push(t);\n });\n order.forEach(function (s) {\n groups[s].sort(function (a, b) {\n return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();\n });\n });\n order.sort(function (a, b) {\n return new Date(groups[b][0].created_at).getTime() - new Date(groups[a][0].created_at).getTime();\n });\n return order.map(function (s) { return { stream: s, tickets: groups[s] }; });\n }\n\n function orderedPending() {\n var out = [];\n groupByStream(pendingTickets()).forEach(function (g) {\n g.tickets.forEach(function (t) { out.push(t); });\n });\n return out;\n }\n\n function age(iso) {\n var ms = Date.now() - new Date(iso).getTime();\n if (!isFinite(ms) || ms < 0) { ms = 0; }\n var s = Math.floor(ms / 1000);\n if (s < 60) { return s + "s"; }\n var m = Math.floor(s / 60);\n if (m < 60) { return m + "m"; }\n var h = Math.floor(m / 60);\n if (h < 24) { return h + "h"; }\n return Math.floor(h / 24) + "d";\n }\n\n /* ---------------- actions ---------------- */\n\n function setCardError(id, msg) {\n var el = document.getElementById("err-" + id);\n if (el) {\n el.hidden = false;\n var span = el.querySelector(".err-text");\n if (span) { span.textContent = msg; }\n }\n }\n\n function submitAnswer(ticket) {\n var input = document.getElementById("free-" + ticket.id);\n var text = input ? input.value.trim() : (state.drafts[ticket.id] || "").trim();\n var choice = state.choices[ticket.id] || "";\n var answer = text || choice;\n if (!answer) {\n setCardError(ticket.id, "Type an answer or pick an option first.");\n return;\n }\n api("/api/answer", {\n method: "POST",\n body: JSON.stringify({ id: ticket.id, answer: answer, answered_by: "human" })\n }).then(function (res) {\n if (res.status === 404) { setCardError(ticket.id, "Ticket not found (404)."); return; }\n if (res.status === 409) { setCardError(ticket.id, "Already answered (409)."); return; }\n if (!res.ok) { setCardError(ticket.id, "Answer failed (" + res.status + ")."); return; }\n delete state.choices[ticket.id];\n delete state.drafts[ticket.id];\n return fetchTickets();\n }).catch(function (err) {\n if (err && err.unauthorized) { return; }\n setCardError(ticket.id, "Answer failed: " + (err && err.message ? err.message : "network error"));\n });\n }\n\n function undo(ticketId) {\n api("/api/undo", { method: "POST", body: JSON.stringify({ id: ticketId }) })\n .then(function (res) {\n if (res.status === 409) { state.error = "Undo window has passed (409)."; render(); return; }\n if (!res.ok) { state.error = "Undo failed (" + res.status + ")."; render(); return; }\n return fetchTickets();\n })\n .catch(function (err) {\n if (err && err.unauthorized) { return; }\n state.error = "Undo failed: " + (err && err.message ? err.message : "network error");\n render();\n });\n }\n\n function undoLast() {\n var recent = recentTickets();\n if (recent.length === 0) { return; }\n undo(recent[0].id);\n }\n\n /* ---------------- keyboard (requirement 4) ---------------- */\n\n function isTypingTarget(el) {\n if (!el) { return false; }\n var tag = el.tagName;\n return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || el.isContentEditable === true;\n }\n\n function moveSelection(delta) {\n var list = orderedPending();\n if (list.length === 0) { return; }\n var idx = -1;\n for (var i = 0; i < list.length; i++) { if (list[i].id === state.selectedId) { idx = i; break; } }\n if (idx === -1) { idx = delta > 0 ? -1 : 0; }\n var next = idx + delta;\n if (next < 0) { next = 0; }\n if (next > list.length - 1) { next = list.length - 1; }\n state.selectedId = list[next].id;\n render();\n var card = document.getElementById("card-" + state.selectedId);\n if (card && card.scrollIntoView) { card.scrollIntoView({ block: "nearest" }); }\n }\n\n function pickOption(n) {\n var list = orderedPending();\n var ticket = null;\n for (var i = 0; i < list.length; i++) { if (list[i].id === state.selectedId) { ticket = list[i]; break; } }\n if (!ticket || !ticket.options || !ticket.options[n - 1]) { return; }\n state.choices[ticket.id] = ticket.options[n - 1];\n render();\n }\n\n function submitSelected() {\n var list = orderedPending();\n for (var i = 0; i < list.length; i++) {\n if (list[i].id === state.selectedId) { submitAnswer(list[i]); return; }\n }\n }\n\n document.addEventListener("keydown", function (e) {\n if (isTypingTarget(e.target)) { return; }\n if (e.metaKey || e.ctrlKey || e.altKey) { return; }\n if (e.key === "j") { e.preventDefault(); moveSelection(1); }\n else if (e.key === "k") { e.preventDefault(); moveSelection(-1); }\n else if (e.key >= "1" && e.key <= "5") { e.preventDefault(); pickOption(parseInt(e.key, 10)); }\n else if (e.key === "Enter") { e.preventDefault(); submitSelected(); }\n else if (e.key === "u") { e.preventDefault(); undoLast(); }\n });\n\n /* ---------------- rendering ---------------- */\n\n function captureDrafts() {\n var inputs = document.querySelectorAll("[data-draft]");\n for (var i = 0; i < inputs.length; i++) {\n state.drafts[inputs[i].getAttribute("data-draft")] = inputs[i].value;\n }\n }\n\n function el(tag, cls, text) {\n var node = document.createElement(tag);\n if (cls) { node.className = cls; }\n if (text !== undefined && text !== null) { node.textContent = text; }\n return node;\n }\n\n function urgencyBadge(urgency) {\n var b = el("span", "badge urgency-" + urgency, "urgency: " + urgency);\n return b;\n }\n\n function buildCard(ticket, isRecent) {\n // The `selected` marker is a keyboard-navigation affordance for the pending queue\n // only. An answered card in "recent" carries no selection target (`j`/`k`/`Enter`\n // never act on it), so marking it "selected" would be misleading.\n var card = el("article", "card" + (!isRecent && ticket.id === state.selectedId ? " selected" : ""));\n card.id = "card-" + ticket.id;\n card.setAttribute("aria-label", "Ticket from " + ticket.stream + ": " + ticket.question);\n\n var head = el("div", "card-head");\n head.appendChild(el("span", "selmark", "selected"));\n head.appendChild(el("span", "stream-name", ticket.stream));\n head.appendChild(el("span", "age", age(ticket.created_at) + " old"));\n head.appendChild(urgencyBadge(ticket.urgency));\n if (isRecent) {\n head.appendChild(el("span", "badge status-answered", "status: answered"));\n if (ticket.answered_by) { head.appendChild(el("span", "age", "by " + ticket.answered_by)); }\n }\n card.appendChild(head);\n\n card.appendChild(el("p", "question", ticket.question));\n\n if (ticket.context) {\n var ctx = el("div", "context", ticket.context);\n card.appendChild(ctx);\n }\n\n if (isRecent) {\n var ans = el("p", "question", "Answer: " + (ticket.answer || "(none)"));\n card.appendChild(ans);\n var undoBtn = el("button", null, "Undo");\n undoBtn.type = "button";\n undoBtn.setAttribute("aria-label", "Undo answer for ticket from " + ticket.stream);\n undoBtn.addEventListener("click", function () { undo(ticket.id); });\n var row = el("div", "answer-row");\n row.appendChild(undoBtn);\n card.appendChild(row);\n return card;\n }\n\n if (ticket.options && ticket.options.length) {\n var opts = el("div", "options");\n opts.setAttribute("role", "group");\n opts.setAttribute("aria-label", "Options for ticket from " + ticket.stream);\n ticket.options.forEach(function (opt, i) {\n var b = el("button", null, (i + 1) + ". " + opt);\n b.type = "button";\n b.setAttribute("aria-pressed", state.choices[ticket.id] === opt ? "true" : "false");\n b.addEventListener("click", function () {\n state.choices[ticket.id] = opt;\n state.selectedId = ticket.id;\n render();\n });\n opts.appendChild(b);\n });\n card.appendChild(opts);\n }\n\n var form = el("form", "answer-row");\n form.setAttribute("novalidate", "novalidate");\n var label = el("label", null, "Answer");\n label.setAttribute("for", "free-" + ticket.id);\n form.appendChild(label);\n var input = document.createElement("input");\n input.type = "text";\n input.id = "free-" + ticket.id;\n input.setAttribute("data-draft", ticket.id);\n input.setAttribute("autocomplete", "off");\n input.value = state.drafts[ticket.id] || "";\n input.addEventListener("input", function () { state.drafts[ticket.id] = input.value; });\n input.addEventListener("focus", function () { state.selectedId = ticket.id; });\n form.appendChild(input);\n var submit = el("button", null, "Answer");\n submit.type = "submit";\n form.appendChild(submit);\n form.addEventListener("submit", function (e) {\n e.preventDefault();\n submitAnswer(ticket);\n });\n card.appendChild(form);\n\n var err = el("div", "card-error");\n err.id = "err-" + ticket.id;\n err.hidden = true;\n err.setAttribute("role", "alert");\n err.appendChild(el("span", "tag", "Error: "));\n err.appendChild(el("span", "err-text", ""));\n card.appendChild(err);\n\n return card;\n }\n\n function render() {\n captureDrafts();\n\n var activeId = document.activeElement && document.activeElement.id ? document.activeElement.id : null;\n var selStart = null, selEnd = null;\n if (activeId && document.activeElement.setSelectionRange) {\n try {\n selStart = document.activeElement.selectionStart;\n selEnd = document.activeElement.selectionEnd;\n } catch (e) { /* not a text input */ }\n }\n\n var pending = pendingTickets();\n var recent = recentTickets();\n\n /* requirement 5: tab title carries the count */\n document.title = pending.length > 0 ? "(" + pending.length + ") readyroom" : "readyroom";\n\n /* status line */\n var statusText = "idle";\n if (state.live) { statusText = "live (server-sent events)"; }\n else if (state.pollTimer) { statusText = "polling every 5s"; }\n else if (!state.token) { statusText = "waiting for token"; }\n els.statusText.textContent = statusText;\n\n if (state.error) {\n els.error.hidden = false;\n els.errorText.textContent = state.error;\n } else {\n els.error.hidden = true;\n els.errorText.textContent = "";\n }\n\n /* pending, grouped by stream, newest stream first */\n els.pending.textContent = "";\n if (pending.length === 0) {\n els.pending.appendChild(el("p", "empty", "No pending tickets."));\n } else {\n groupByStream(pending).forEach(function (group) {\n var section = el("section", "stream-group");\n section.setAttribute("aria-label", "Stream " + group.stream);\n var head = el("div", "stream-head");\n head.appendChild(el("h3", "stream-name", group.stream));\n head.appendChild(el("span", "stream-count", group.tickets.length + " pending"));\n section.appendChild(head);\n group.tickets.forEach(function (t) { section.appendChild(buildCard(t, false)); });\n els.pending.appendChild(section);\n });\n }\n\n /* recent, undoable for 5 minutes */\n els.recent.textContent = "";\n if (recent.length === 0) {\n els.recent.appendChild(el("p", "empty", "Nothing answered in the last 5 minutes."));\n } else {\n recent.forEach(function (t) { els.recent.appendChild(buildCard(t, true)); });\n }\n\n /* restore focus + caret so polling re-renders do not interrupt typing */\n if (activeId) {\n var again = document.getElementById(activeId);\n if (again && again.focus) {\n try {\n again.focus();\n if (selStart !== null && again.setSelectionRange) { again.setSelectionRange(selStart, selEnd); }\n } catch (e) { /* ignore */ }\n }\n }\n }\n\n /* ---------------- wiring ---------------- */\n\n els.tokenSave.addEventListener("click", function () {\n var t = els.tokenInput.value.trim();\n if (!t) { return; }\n state.token = t;\n writeToken(t);\n state.error = "";\n showTokenBar(false);\n startLive();\n fetchTickets();\n });\n els.tokenInput.addEventListener("keydown", function (e) {\n if (e.key === "Enter") { e.preventDefault(); els.tokenSave.click(); }\n });\n\n /* requirement 9: re-render on window load */\n window.addEventListener("load", render);\n\n /* boot */\n state.token = readToken();\n if (state.token) {\n showTokenBar(false);\n startLive();\n fetchTickets();\n } else {\n showTokenBar(true);\n }\n\n /* requirement 9: first paint is synchronous, at the end of the script, not in rAF */\n render();\n})();\n</script>\n</body>\n</html>\n';
|
|
8248
7505
|
|
|
8249
7506
|
// src/core/stream.ts
|
|
8250
7507
|
function resolveStream(explicit, defaultStream) {
|
|
@@ -21259,21 +20516,22 @@ async function attachStdio(server) {
|
|
|
21259
20516
|
await server.connect(transport);
|
|
21260
20517
|
return transport;
|
|
21261
20518
|
}
|
|
21262
|
-
async function attachStreamableHttp(
|
|
20519
|
+
async function attachStreamableHttp(serverFactory, options) {
|
|
21263
20520
|
const log4 = options.log ?? ((message) => process.stderr.write(`readyroom: ${message}
|
|
21264
20521
|
`));
|
|
21265
|
-
const transport = new StreamableHTTPServerTransport({
|
|
21266
|
-
sessionIdGenerator: void 0,
|
|
21267
|
-
enableJsonResponse: true
|
|
21268
|
-
});
|
|
21269
|
-
await server.connect(transport);
|
|
21270
20522
|
const handle = async (req, res) => {
|
|
21271
20523
|
if (!checkOrigin(req, options.allowedOrigins)) {
|
|
21272
20524
|
res.writeHead(403, { "content-type": "text/plain; charset=utf-8" });
|
|
21273
20525
|
res.end("Forbidden: missing or disallowed Origin");
|
|
21274
20526
|
return;
|
|
21275
20527
|
}
|
|
20528
|
+
const server = serverFactory();
|
|
20529
|
+
const transport = new StreamableHTTPServerTransport({
|
|
20530
|
+
sessionIdGenerator: void 0,
|
|
20531
|
+
enableJsonResponse: true
|
|
20532
|
+
});
|
|
21276
20533
|
try {
|
|
20534
|
+
await server.connect(transport);
|
|
21277
20535
|
await transport.handleRequest(req, res);
|
|
21278
20536
|
} catch (error2) {
|
|
21279
20537
|
log4(`mcp transport error on port ${options.port}: ${error2 instanceof Error ? error2.message : String(error2)}`);
|
|
@@ -21281,12 +20539,17 @@ async function attachStreamableHttp(server, options) {
|
|
|
21281
20539
|
res.writeHead(500, { "content-type": "text/plain; charset=utf-8" });
|
|
21282
20540
|
res.end("Internal Server Error");
|
|
21283
20541
|
}
|
|
20542
|
+
} finally {
|
|
20543
|
+
await transport.close().catch(() => {
|
|
20544
|
+
});
|
|
20545
|
+
await server.close().catch(() => {
|
|
20546
|
+
});
|
|
21284
20547
|
}
|
|
21285
20548
|
};
|
|
21286
20549
|
return {
|
|
21287
|
-
transport,
|
|
21288
20550
|
handle,
|
|
21289
|
-
close: () =>
|
|
20551
|
+
close: async () => {
|
|
20552
|
+
}
|
|
21290
20553
|
};
|
|
21291
20554
|
}
|
|
21292
20555
|
|
|
@@ -21462,7 +20725,7 @@ async function runDaemon(config2, defaultStream) {
|
|
|
21462
20725
|
await store.load();
|
|
21463
20726
|
const deps = { store, defaultStream, now };
|
|
21464
20727
|
const allowedOrigins = buildAllowedOrigins(config2.port, config2.allowedOrigins);
|
|
21465
|
-
const mcp = await attachStreamableHttp(createMcpServer(deps), {
|
|
20728
|
+
const mcp = await attachStreamableHttp(() => createMcpServer(deps), {
|
|
21466
20729
|
port: config2.port,
|
|
21467
20730
|
allowedOrigins,
|
|
21468
20731
|
log: log3
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "readyroom",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
4
4
|
"description": "An MCP server that lets coding agents request a human decision without hijacking whichever window is focused. Agents enqueue a ticket; the human drains the queue from one page when they choose to.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
"deploy:worker": "wrangler deploy",
|
|
41
41
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
42
42
|
"test": "node --import ./scripts/html-loader-register.mjs --import tsx --test test/*.test.ts test/core/*.test.ts test/storage/*.test.ts",
|
|
43
|
+
"screenshots": "bash scripts/capture-screenshots.sh",
|
|
43
44
|
"docker:build": "docker build -t ghcr.io/therebelrobot/readyroom:local .",
|
|
44
45
|
"docker:run": "docker run --rm -d -p 127.0.0.1:8971:8971 -v $(pwd)/.state:/app/.state --env-file .env ghcr.io/therebelrobot/readyroom:local",
|
|
45
46
|
"docker:push": "docker buildx build --platform linux/amd64,linux/arm64 -t ghcr.io/therebelrobot/readyroom:latest --push .",
|