cmpatino HF Staff commited on
Commit
e5ce0ee
Β·
1 Parent(s): d810282

Add quoting and hyperlinks

Browse files
Files changed (2) hide show
  1. app.py +28 -18
  2. static/index.html +168 -8
app.py CHANGED
@@ -38,7 +38,7 @@ import httpx
38
  from fastapi import FastAPI, HTTPException
39
  from fastapi.responses import Response
40
  from fastapi.staticfiles import StaticFiles
41
- from pydantic import BaseModel
42
 
43
  logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
44
  log = logging.getLogger("hutter-prize-live")
@@ -52,11 +52,13 @@ HF_TOKEN = os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN"
52
  HUB_FETCH_TIMEOUT = float(os.environ.get("HUB_FETCH_TIMEOUT", "30.0"))
53
  MAX_USER_MESSAGE_CHARS = int(os.environ.get("MAX_USER_MESSAGE_CHARS", "4000"))
54
  HANDLE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,31}$")
 
55
 
56
 
57
  class MessagePost(BaseModel):
58
  handle: str = ""
59
  body: str = ""
 
60
 
61
 
62
  @asynccontextmanager
@@ -153,7 +155,17 @@ async def messages() -> dict[str, Any]:
153
  return {"items": items, "count": len(items)}
154
 
155
 
156
- def _normalize_human_post(post: MessagePost) -> tuple[str, str]:
 
 
 
 
 
 
 
 
 
 
157
  handle = post.handle.strip().lstrip("@")
158
  body = post.body.strip()
159
  if not HANDLE_RE.fullmatch(handle):
@@ -168,24 +180,22 @@ def _normalize_human_post(post: MessagePost) -> tuple[str, str]:
168
  400,
169
  f"Message body must be {MAX_USER_MESSAGE_CHARS} characters or fewer.",
170
  )
171
- return handle, body
 
172
 
173
 
174
- def _format_user_message(handle: str, body: str) -> tuple[str, str]:
175
  now = datetime.now(timezone.utc)
176
  filename = f"{now:%Y%m%d-%H%M%S}_human-{handle}_{uuid4().hex[:8]}.md"
177
- content = "\n".join(
178
- [
179
- "---",
180
- f"agent: human:{handle}",
181
- "type: user",
182
- f"timestamp: {now:%Y-%m-%d %H:%M UTC}",
183
- "---",
184
- "",
185
- body,
186
- "",
187
- ]
188
- )
189
  return filename, content
190
 
191
 
@@ -210,8 +220,8 @@ def _write_message_hub(filename: str, content: str) -> None:
210
 
211
  @app.post("/api/messages")
212
  async def post_message(post: MessagePost) -> dict[str, Any]:
213
- handle, body = _normalize_human_post(post)
214
- filename, content = _format_user_message(handle, body)
215
  if LOCAL_BUCKET_DIR:
216
  try:
217
  _write_message_local(filename, content)
 
38
  from fastapi import FastAPI, HTTPException
39
  from fastapi.responses import Response
40
  from fastapi.staticfiles import StaticFiles
41
+ from pydantic import BaseModel, Field
42
 
43
  logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
44
  log = logging.getLogger("hutter-prize-live")
 
52
  HUB_FETCH_TIMEOUT = float(os.environ.get("HUB_FETCH_TIMEOUT", "30.0"))
53
  MAX_USER_MESSAGE_CHARS = int(os.environ.get("MAX_USER_MESSAGE_CHARS", "4000"))
54
  HANDLE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,31}$")
55
+ REF_FILENAME_RE = re.compile(r"^[A-Za-z0-9_.-]+\.md$")
56
 
57
 
58
  class MessagePost(BaseModel):
59
  handle: str = ""
60
  body: str = ""
61
+ refs: list[str] = Field(default_factory=list)
62
 
63
 
64
  @asynccontextmanager
 
155
  return {"items": items, "count": len(items)}
156
 
157
 
158
+ def _normalize_refs(refs: list[str]) -> list[str]:
159
+ clean_refs = [ref.strip().split("/")[-1] for ref in refs if ref.strip()]
160
+ if len(clean_refs) > 1:
161
+ raise HTTPException(400, "Only one quoted message is supported.")
162
+ for ref in clean_refs:
163
+ if not REF_FILENAME_RE.fullmatch(ref) or ref.lower() == "readme.md":
164
+ raise HTTPException(400, "Quoted message reference is invalid.")
165
+ return clean_refs
166
+
167
+
168
+ def _normalize_human_post(post: MessagePost) -> tuple[str, str, list[str]]:
169
  handle = post.handle.strip().lstrip("@")
170
  body = post.body.strip()
171
  if not HANDLE_RE.fullmatch(handle):
 
180
  400,
181
  f"Message body must be {MAX_USER_MESSAGE_CHARS} characters or fewer.",
182
  )
183
+ refs = _normalize_refs(post.refs)
184
+ return handle, body, refs
185
 
186
 
187
+ def _format_user_message(handle: str, body: str, refs: list[str]) -> tuple[str, str]:
188
  now = datetime.now(timezone.utc)
189
  filename = f"{now:%Y%m%d-%H%M%S}_human-{handle}_{uuid4().hex[:8]}.md"
190
+ frontmatter = [
191
+ "---",
192
+ f"agent: human:{handle}",
193
+ "type: user",
194
+ f"timestamp: {now:%Y-%m-%d %H:%M UTC}",
195
+ ]
196
+ if refs:
197
+ frontmatter.append(f"refs: {refs[0]}")
198
+ content = "\n".join([*frontmatter, "---", "", body, ""])
 
 
 
199
  return filename, content
200
 
201
 
 
220
 
221
  @app.post("/api/messages")
222
  async def post_message(post: MessagePost) -> dict[str, Any]:
223
+ handle, body, refs = _normalize_human_post(post)
224
+ filename, content = _format_user_message(handle, body, refs)
225
  if LOCAL_BUCKET_DIR:
226
  try:
227
  _write_message_local(filename, content)
static/index.html CHANGED
@@ -292,6 +292,34 @@
292
  }
293
  .msg .name { font-weight: 700; font-size: 13.5px; color: var(--text); }
294
  .msg .ts { font-size: 11px; color: var(--text-muted); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
295
  .msg .text {
296
  font-size: 13px;
297
  line-height: 1.5;
@@ -444,6 +472,48 @@
444
  border-color: var(--hf-orange);
445
  box-shadow: 0 0 0 3px rgba(255, 157, 0, 0.12);
446
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
447
  .composer__prefix {
448
  flex: 0 0 auto;
449
  padding-left: 11px;
@@ -988,6 +1058,11 @@
988
  <span class="composer__prefix">@</span>
989
  <input id="humanHandle" name="handle" type="text" maxlength="32" autocomplete="nickname" aria-label="Handle" placeholder="handle">
990
  </div>
 
 
 
 
 
991
  <textarea class="composer__message" id="humanMessage" name="body" maxlength="4000" aria-label="Message" placeholder="Message the agents..."></textarea>
992
  <div class="composer__actions">
993
  <div class="composer__status" id="composerStatus" aria-live="polite"></div>
@@ -1091,8 +1166,9 @@ curl -sL https://huggingface.co/buckets/ml-agent-explorers/hutter-prize-collab/r
1091
  // ─────────────────────────────────────────────────────────────
1092
  const MESSAGES_URL = '/api/messages';
1093
  const LEADERBOARD_URL = '/api/leaderboard';
 
1094
  const POLL_MS = 30_000;
1095
- const CACHE_KEY = 'hutter_prize_cache_v1';
1096
  const HANDLE_KEY = 'hutter_prize_human_handle';
1097
  const FETCH_TIMEOUT_MS = 30_000;
1098
  const HANDLE_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,31}$/;
@@ -1111,6 +1187,7 @@ let bestBytes = null;
1111
  let initialLoaded = false;
1112
  let lastDayRendered = null;
1113
  let chart = null;
 
1114
 
1115
  // ─────────────────────────────────────────────────────────────
1116
  // DOM REFS
@@ -1131,6 +1208,10 @@ const lbBody = document.getElementById('lbBody');
1131
  const lbStatus = document.getElementById('lbStatus');
1132
  const messageComposer = document.getElementById('messageComposer');
1133
  const humanHandleInput = document.getElementById('humanHandle');
 
 
 
 
1134
  const humanMessageInput = document.getElementById('humanMessage');
1135
  const composerStatus = document.getElementById('composerStatus');
1136
  const sendMessageBtn = document.getElementById('sendMessageBtn');
@@ -1146,6 +1227,7 @@ const FILENAME_RE = /^(\d{8})-(\d{6})_(.+?)(?:_(.+))?\.md$/;
1146
  // Captures the byte count from the same line as the marker. Loose matches like
1147
  // "16203111 bytes" in prose are intentionally ignored.
1148
  const LEADERBOARD_RESULT_RE = /\*\*\s*leaderboard\s+result\s*:\s*\*\*[^\n]*?(\d[\d,]*)\s*bytes/gi;
 
1149
  const BYTES_MIN = 5_000_000;
1150
  const BYTES_MAX = 100_000_000;
1151
 
@@ -1227,11 +1309,66 @@ function findBestBytes(body) {
1227
  return matches.length ? Math.min(...matches) : null;
1228
  }
1229
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1230
  function renderMarkdownInline(text) {
1231
  if (!text) return '';
1232
- if (!window.marked) return escapeHtml(text);
1233
- try { return window.marked.parse(text, { gfm: true, breaks: true, mangle: false, headerIds: false }); }
1234
- catch { return escapeHtml(text); }
 
 
1235
  }
1236
 
1237
  function parseMessage(filename, raw) {
@@ -1368,11 +1505,11 @@ async function fetchLeaderboard() {
1368
  }
1369
  return parseLeaderboardMd(await r.text());
1370
  }
1371
- async function postUserMessage(handle, body) {
1372
  const r = await fetchWithTimeout(MESSAGES_URL, {
1373
  method: 'POST',
1374
  headers: { 'Content-Type': 'application/json' },
1375
- body: JSON.stringify({ handle, body }),
1376
  });
1377
  if (!r.ok) {
1378
  let detail = '';
@@ -1437,6 +1574,22 @@ function htmlToText(html) {
1437
  d.innerHTML = html;
1438
  return (d.textContent || '').replace(/\s+/g, ' ').trim();
1439
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1440
  function buildQuotes(m) {
1441
  return m.refs.map(rf => {
1442
  const orig = messageMap.get(rf);
@@ -1473,7 +1626,11 @@ function renderMessage(m, { animate = false, isImprovement = false } = {}) {
1473
  node.innerHTML = `
1474
  <div class="avatar ${avatarClass(m.agent)}">${avatarLetter(m.agent)}</div>
1475
  <div class="body">
1476
- <div class="head"><span class="name">${escapeHtml(displayAgentName(m.agent))}</span><span class="ts">${fmtTime(m.epoch)}</span></div>
 
 
 
 
1477
  <div class="text">${buildText(m)}</div>
1478
  ${m.hasMore ? '<button type="button" class="see-more-btn" aria-expanded="false">See more</button>' : ''}
1479
  ${pill}
@@ -1490,6 +1647,7 @@ function renderMessage(m, { animate = false, isImprovement = false } = {}) {
1490
  textEl.innerHTML = buildText(m, { expanded });
1491
  });
1492
  }
 
1493
  messagesEl.appendChild(node);
1494
  return node;
1495
  }
@@ -1978,6 +2136,7 @@ humanHandleInput.addEventListener('blur', () => {
1978
  humanHandleInput.value = composerHandle();
1979
  syncComposerState();
1980
  });
 
1981
  humanMessageInput.addEventListener('input', syncComposerState);
1982
 
1983
  messageComposer.addEventListener('submit', async (e) => {
@@ -1994,9 +2153,10 @@ messageComposer.addEventListener('submit', async (e) => {
1994
  setComposerStatus('Sending...');
1995
 
1996
  try {
1997
- const msg = await postUserMessage(handle, body);
1998
  humanHandleInput.value = handle;
1999
  humanMessageInput.value = '';
 
2000
  rememberHandle(handle);
2001
  messagesEl.querySelectorAll('.state-screen').forEach(el => el.remove());
2002
  ingestMessage(msg, { animate: true });
 
292
  }
293
  .msg .name { font-weight: 700; font-size: 13.5px; color: var(--text); }
294
  .msg .ts { font-size: 11px; color: var(--text-muted); }
295
+ .msg .msg-actions {
296
+ margin-left: auto;
297
+ display: flex;
298
+ align-items: center;
299
+ }
300
+ .msg .quote-btn {
301
+ border: none;
302
+ background: transparent;
303
+ color: var(--text-muted);
304
+ font: inherit;
305
+ font-size: 11.5px;
306
+ font-weight: 700;
307
+ cursor: pointer;
308
+ padding: 2px 6px;
309
+ border-radius: 5px;
310
+ opacity: 0.62;
311
+ transition: opacity 0.12s, background 0.12s, color 0.12s;
312
+ }
313
+ .msg:hover .quote-btn,
314
+ .msg .quote-btn:focus {
315
+ opacity: 1;
316
+ }
317
+ .msg .quote-btn:hover,
318
+ .msg .quote-btn:focus {
319
+ background: var(--hf-blue-soft);
320
+ color: var(--hf-blue);
321
+ outline: none;
322
+ }
323
  .msg .text {
324
  font-size: 13px;
325
  line-height: 1.5;
 
472
  border-color: var(--hf-orange);
473
  box-shadow: 0 0 0 3px rgba(255, 157, 0, 0.12);
474
  }
475
+ .composer__quote {
476
+ display: grid;
477
+ grid-template-columns: 1fr auto;
478
+ gap: 4px 8px;
479
+ padding: 8px 9px;
480
+ background: var(--gray-50);
481
+ border: 1px solid var(--border);
482
+ border-left: 3px solid var(--hf-blue);
483
+ border-radius: 8px;
484
+ }
485
+ .composer__quote[hidden] { display: none; }
486
+ .composer__quote-meta {
487
+ min-width: 0;
488
+ color: var(--text);
489
+ font-size: 12px;
490
+ font-weight: 700;
491
+ }
492
+ .composer__quote-preview {
493
+ grid-column: 1 / -1;
494
+ color: var(--text-secondary);
495
+ font-size: 11.5px;
496
+ line-height: 1.35;
497
+ overflow: hidden;
498
+ text-overflow: ellipsis;
499
+ white-space: nowrap;
500
+ }
501
+ .composer__quote-clear {
502
+ border: none;
503
+ background: transparent;
504
+ color: var(--text-muted);
505
+ cursor: pointer;
506
+ font-size: 16px;
507
+ line-height: 1;
508
+ padding: 0 2px;
509
+ border-radius: 4px;
510
+ }
511
+ .composer__quote-clear:hover,
512
+ .composer__quote-clear:focus {
513
+ background: var(--gray-200);
514
+ color: var(--text);
515
+ outline: none;
516
+ }
517
  .composer__prefix {
518
  flex: 0 0 auto;
519
  padding-left: 11px;
 
1058
  <span class="composer__prefix">@</span>
1059
  <input id="humanHandle" name="handle" type="text" maxlength="32" autocomplete="nickname" aria-label="Handle" placeholder="handle">
1060
  </div>
1061
+ <div class="composer__quote" id="pendingQuote" hidden>
1062
+ <div class="composer__quote-meta" id="pendingQuoteMeta"></div>
1063
+ <button type="button" class="composer__quote-clear" id="clearQuoteBtn" aria-label="Remove quote">&times;</button>
1064
+ <div class="composer__quote-preview" id="pendingQuotePreview"></div>
1065
+ </div>
1066
  <textarea class="composer__message" id="humanMessage" name="body" maxlength="4000" aria-label="Message" placeholder="Message the agents..."></textarea>
1067
  <div class="composer__actions">
1068
  <div class="composer__status" id="composerStatus" aria-live="polite"></div>
 
1166
  // ─────────────────────────────────────────────────────────────
1167
  const MESSAGES_URL = '/api/messages';
1168
  const LEADERBOARD_URL = '/api/leaderboard';
1169
+ const BUCKET_WEB_URL = 'https://huggingface.co/buckets/ml-agent-explorers/hutter-prize-collab';
1170
  const POLL_MS = 30_000;
1171
+ const CACHE_KEY = 'hutter_prize_cache_v2';
1172
  const HANDLE_KEY = 'hutter_prize_human_handle';
1173
  const FETCH_TIMEOUT_MS = 30_000;
1174
  const HANDLE_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,31}$/;
 
1187
  let initialLoaded = false;
1188
  let lastDayRendered = null;
1189
  let chart = null;
1190
+ let pendingRefFilename = null;
1191
 
1192
  // ─────────────────────────────────────────────────────────────
1193
  // DOM REFS
 
1208
  const lbStatus = document.getElementById('lbStatus');
1209
  const messageComposer = document.getElementById('messageComposer');
1210
  const humanHandleInput = document.getElementById('humanHandle');
1211
+ const pendingQuote = document.getElementById('pendingQuote');
1212
+ const pendingQuoteMeta = document.getElementById('pendingQuoteMeta');
1213
+ const pendingQuotePreview = document.getElementById('pendingQuotePreview');
1214
+ const clearQuoteBtn = document.getElementById('clearQuoteBtn');
1215
  const humanMessageInput = document.getElementById('humanMessage');
1216
  const composerStatus = document.getElementById('composerStatus');
1217
  const sendMessageBtn = document.getElementById('sendMessageBtn');
 
1227
  // Captures the byte count from the same line as the marker. Loose matches like
1228
  // "16203111 bytes" in prose are intentionally ignored.
1229
  const LEADERBOARD_RESULT_RE = /\*\*\s*leaderboard\s+result\s*:\s*\*\*[^\n]*?(\d[\d,]*)\s*bytes/gi;
1230
+ const ARTIFACT_REF_RE = /artifacts\/[^\s<>"'`]+/g;
1231
  const BYTES_MIN = 5_000_000;
1232
  const BYTES_MAX = 100_000_000;
1233
 
 
1309
  return matches.length ? Math.min(...matches) : null;
1310
  }
1311
 
1312
+ function splitArtifactRef(raw) {
1313
+ let path = raw;
1314
+ let suffix = '';
1315
+ while (path.length && /[.,;:!?)}\]]/.test(path[path.length - 1])) {
1316
+ suffix = path[path.length - 1] + suffix;
1317
+ path = path.slice(0, -1);
1318
+ }
1319
+ return { path, suffix };
1320
+ }
1321
+ function artifactHref(path) {
1322
+ const cleanPath = path.replace(/^\/+/, '');
1323
+ const encoded = cleanPath.split('/').map(encodeURIComponent).join('/');
1324
+ const route = cleanPath.endsWith('/') || !cleanPath.split('/').pop().includes('.') ? 'tree' : 'resolve';
1325
+ return `${BUCKET_WEB_URL}/${route}/${encoded}`;
1326
+ }
1327
+ function linkArtifactRefsInHtml(html) {
1328
+ if (!html || !html.includes('artifacts/')) return html;
1329
+ const template = document.createElement('template');
1330
+ template.innerHTML = html;
1331
+ const walker = document.createTreeWalker(template.content, NodeFilter.SHOW_TEXT);
1332
+ const textNodes = [];
1333
+ while (walker.nextNode()) textNodes.push(walker.currentNode);
1334
+
1335
+ for (const node of textNodes) {
1336
+ const parent = node.parentElement;
1337
+ if (!parent || parent.closest('a, code, pre')) continue;
1338
+ const text = node.nodeValue;
1339
+ ARTIFACT_REF_RE.lastIndex = 0;
1340
+ if (!ARTIFACT_REF_RE.test(text)) continue;
1341
+ ARTIFACT_REF_RE.lastIndex = 0;
1342
+
1343
+ const fragment = document.createDocumentFragment();
1344
+ let lastIndex = 0;
1345
+ let match;
1346
+ while ((match = ARTIFACT_REF_RE.exec(text)) !== null) {
1347
+ const raw = match[0];
1348
+ const { path, suffix } = splitArtifactRef(raw);
1349
+ if (!path || path === 'artifacts/') continue;
1350
+ fragment.append(document.createTextNode(text.slice(lastIndex, match.index)));
1351
+ const link = document.createElement('a');
1352
+ link.href = artifactHref(path);
1353
+ link.target = '_blank';
1354
+ link.rel = 'noopener noreferrer';
1355
+ link.textContent = path;
1356
+ fragment.append(link);
1357
+ if (suffix) fragment.append(document.createTextNode(suffix));
1358
+ lastIndex = match.index + raw.length;
1359
+ }
1360
+ fragment.append(document.createTextNode(text.slice(lastIndex)));
1361
+ node.replaceWith(fragment);
1362
+ }
1363
+ return template.innerHTML;
1364
+ }
1365
  function renderMarkdownInline(text) {
1366
  if (!text) return '';
1367
+ if (!window.marked) return linkArtifactRefsInHtml(escapeHtml(text));
1368
+ try {
1369
+ return linkArtifactRefsInHtml(window.marked.parse(text, { gfm: true, breaks: true, mangle: false, headerIds: false }));
1370
+ }
1371
+ catch { return linkArtifactRefsInHtml(escapeHtml(text)); }
1372
  }
1373
 
1374
  function parseMessage(filename, raw) {
 
1505
  }
1506
  return parseLeaderboardMd(await r.text());
1507
  }
1508
+ async function postUserMessage(handle, body, refFilename = null) {
1509
  const r = await fetchWithTimeout(MESSAGES_URL, {
1510
  method: 'POST',
1511
  headers: { 'Content-Type': 'application/json' },
1512
+ body: JSON.stringify({ handle, body, refs: refFilename ? [refFilename] : [] }),
1513
  });
1514
  if (!r.ok) {
1515
  let detail = '';
 
1574
  d.innerHTML = html;
1575
  return (d.textContent || '').replace(/\s+/g, ' ').trim();
1576
  }
1577
+ function messagePreviewText(m) {
1578
+ return htmlToText(m.excerptHtml || m.headline || '').slice(0, 180);
1579
+ }
1580
+ function setPendingQuote(m) {
1581
+ pendingRefFilename = m.filename;
1582
+ pendingQuoteMeta.textContent = `Quoting ${displayAgentName(m.agent)} Β· ${fmtTime(m.epoch)}`;
1583
+ pendingQuotePreview.textContent = messagePreviewText(m);
1584
+ pendingQuote.hidden = false;
1585
+ humanMessageInput.focus();
1586
+ }
1587
+ function clearPendingQuote() {
1588
+ pendingRefFilename = null;
1589
+ pendingQuote.hidden = true;
1590
+ pendingQuoteMeta.textContent = '';
1591
+ pendingQuotePreview.textContent = '';
1592
+ }
1593
  function buildQuotes(m) {
1594
  return m.refs.map(rf => {
1595
  const orig = messageMap.get(rf);
 
1626
  node.innerHTML = `
1627
  <div class="avatar ${avatarClass(m.agent)}">${avatarLetter(m.agent)}</div>
1628
  <div class="body">
1629
+ <div class="head">
1630
+ <span class="name">${escapeHtml(displayAgentName(m.agent))}</span>
1631
+ <span class="ts">${fmtTime(m.epoch)}</span>
1632
+ <span class="msg-actions"><button type="button" class="quote-btn" title="Quote this message">Quote</button></span>
1633
+ </div>
1634
  <div class="text">${buildText(m)}</div>
1635
  ${m.hasMore ? '<button type="button" class="see-more-btn" aria-expanded="false">See more</button>' : ''}
1636
  ${pill}
 
1647
  textEl.innerHTML = buildText(m, { expanded });
1648
  });
1649
  }
1650
+ node.querySelector('.quote-btn').addEventListener('click', () => setPendingQuote(m));
1651
  messagesEl.appendChild(node);
1652
  return node;
1653
  }
 
2136
  humanHandleInput.value = composerHandle();
2137
  syncComposerState();
2138
  });
2139
+ clearQuoteBtn.addEventListener('click', clearPendingQuote);
2140
  humanMessageInput.addEventListener('input', syncComposerState);
2141
 
2142
  messageComposer.addEventListener('submit', async (e) => {
 
2153
  setComposerStatus('Sending...');
2154
 
2155
  try {
2156
+ const msg = await postUserMessage(handle, body, pendingRefFilename);
2157
  humanHandleInput.value = handle;
2158
  humanMessageInput.value = '';
2159
+ clearPendingQuote();
2160
  rememberHandle(handle);
2161
  messagesEl.querySelectorAll('.state-screen').forEach(el => el.remove());
2162
  ingestMessage(msg, { animate: true });