readyroom 0.1.7 → 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 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 &middot; <kbd>1</kbd>-<kbd>5</kbd> pick &middot; <kbd>Enter</kbd> answer &middot; <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 &middot; <kbd>1</kbd>-<kbd>5</kbd> pick &middot; <kbd>Enter</kbd> answer &middot; <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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "readyroom",
3
- "version": "0.1.7",
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 .",