cmpatino HF Staff Claude Opus 4.8 (1M context) commited on
Commit
576cb71
·
1 Parent(s): 3dae9da

Add channels UI: topic-room chips, feed view, and creation modal

Browse files

Ports the dashboard side of agent-collabs PR #4. The messages panel is now
channel-aware: a chips row (Board + one per channel, with fresh-activity
dots) switches the feed between the board and a channel, a channel header
shows the theme and members, and a "+" chip opens a creation modal. The
composer posts into the selected channel (retargeting placeholder/button),
and broadcast is hidden while a channel is active.

Adds /api/channels proxies to the bucket-sync backend (list, detail,
messages, create) with graceful degradation to a hidden UI when no backend
is configured. Merged alongside the existing wiki dashboard UI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files changed (2) hide show
  1. app.py +194 -19
  2. static/index.html +731 -36
app.py CHANGED
@@ -111,12 +111,22 @@ SESSION_SECRET = (
111
  MAX_USER_MESSAGE_CHARS = int(os.environ.get("MAX_USER_MESSAGE_CHARS", "4000"))
112
  HANDLE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,31}$")
113
  REF_FILENAME_RE = re.compile(r"^[A-Za-z0-9_.-]+\.md$")
 
 
 
114
 
115
 
116
  class MessagePost(BaseModel):
117
  body: str = ""
118
  refs: list[str] = Field(default_factory=list)
119
  broadcast: bool = False
 
 
 
 
 
 
 
120
 
121
 
122
  @asynccontextmanager
@@ -301,6 +311,95 @@ async def backend_status() -> Any:
301
  return await _hub_cache.get("__backend_status__", _check)
302
 
303
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
304
  # ──────────────────────────────────────────────────────────────
305
  # OAuth (HF Spaces auto-injects OAUTH_CLIENT_ID/SECRET when
306
  # `hf_oauth: true` is set in README.md).
@@ -618,6 +717,12 @@ class _SingleFlightCache:
618
  def invalidate(self, key: str) -> None:
619
  self._values.pop(key, None)
620
 
 
 
 
 
 
 
621
 
622
  _hub_cache = _SingleFlightCache(LIST_CACHE_TTL)
623
 
@@ -703,7 +808,11 @@ def _format_user_message(username: str, body: str, refs: list[str]) -> tuple[str
703
 
704
 
705
  def _echo_user_message(
706
- username: str, body: str, refs: list[str], broadcast: bool = False
 
 
 
 
707
  ) -> str:
708
  """Reconstruct (approximately) the file the bucket-sync API just wrote,
709
  for the immediate UI echo — the next full reload serves the real bytes."""
@@ -717,11 +826,36 @@ def _echo_user_message(
717
  ]
718
  if broadcast:
719
  frontmatter.append("broadcast: true")
 
 
720
  if refs:
721
  frontmatter.append(f"refs: {refs[0]}")
722
  return "\n".join([*frontmatter, "---", "", body, ""])
723
 
724
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
725
  class _ApiPostRejected(Exception):
726
  """A bucket-sync verdict the user must see (e.g. rate limit). Falling
727
  back to a direct bucket write would silently bypass it."""
@@ -733,14 +867,20 @@ class _ApiPostRejected(Exception):
733
 
734
 
735
  async def _post_message_via_api(
736
- username: str, body: str, refs: list[str], user_token: str, broadcast: bool = False
 
 
 
 
 
737
  ) -> dict[str, Any]:
738
  """POST through the bucket-sync API so @mentions and quote-refs land in
739
  agent inboxes (its human-post path). The user's OAuth token is the
740
  identity proof — the API verifies it via whoami and derives the handle
741
  itself. Returns the API response dict; raises _ApiPostRejected for
742
  verdicts to surface, any other exception means "fall back to the direct
743
- bucket write" (board-visible, fan-out reconciled later by the backfill)."""
 
744
  payload: dict[str, Any] = {
745
  "agent_id": _human_handle(username),
746
  "body": body,
@@ -750,6 +890,8 @@ async def _post_message_via_api(
750
  payload["refs"] = refs[0]
751
  if broadcast:
752
  payload["broadcast"] = True
 
 
753
  # A fresh client: app.state.client carries the Space's admin HF_TOKEN in
754
  # its default headers, which must never ride along to another service.
755
  async with httpx.AsyncClient(timeout=httpx.Timeout(HUB_FETCH_TIMEOUT)) as client:
@@ -759,24 +901,19 @@ async def _post_message_via_api(
759
  headers={"Authorization": f"Bearer {user_token}"},
760
  )
761
  if r.status_code == 429:
762
- detail = ""
763
- try:
764
- detail = r.json()["detail"]["error"]["message"]
765
- except Exception:
766
- pass
767
- raise _ApiPostRejected(429, detail or "Rate limited — please slow down.")
768
  if r.status_code != 201:
769
- if broadcast:
770
- # A broadcast never falls back to a direct write, so surface the
771
- # backend's verdict (403 not-organizer, 503 try-again, …) instead
772
- # of a generic failure.
773
- detail = ""
774
- try:
775
- detail = r.json()["detail"]["error"]["message"]
776
- except Exception:
777
- pass
778
  raise _ApiPostRejected(
779
- r.status_code, detail or f"Broadcast rejected ({r.status_code})."
 
780
  )
781
  raise RuntimeError(f"bucket-sync API returned {r.status_code}: {r.text[:200]}")
782
  return r.json()
@@ -820,6 +957,44 @@ async def post_message(post: MessagePost, request: Request) -> dict[str, Any]:
820
  user_token = request.session.get("access_token")
821
  handle, body, refs = _normalize_human_post(post, username)
822
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
823
  if post.broadcast:
824
  # Organizer broadcast: only the bucket-sync API performs the gated
825
  # broadcasts/ write, so this path never falls back to the local or
 
111
  MAX_USER_MESSAGE_CHARS = int(os.environ.get("MAX_USER_MESSAGE_CHARS", "4000"))
112
  HANDLE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,31}$")
113
  REF_FILENAME_RE = re.compile(r"^[A-Za-z0-9_.-]+\.md$")
114
+ # Mirrors the backend's channel-name rule (CHANNELS_DESIGN.md §2) for friendly
115
+ # client-side errors; the backend remains the authority.
116
+ CHANNEL_NAME_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$")
117
 
118
 
119
  class MessagePost(BaseModel):
120
  body: str = ""
121
  refs: list[str] = Field(default_factory=list)
122
  broadcast: bool = False
123
+ # Post into a channel instead of the board (CHANNELS_DESIGN.md §8.2).
124
+ channel: str | None = None
125
+
126
+
127
+ class ChannelCreate(BaseModel):
128
+ name: str = ""
129
+ body: str = "" # the theme
130
 
131
 
132
  @asynccontextmanager
 
311
  return await _hub_cache.get("__backend_status__", _check)
312
 
313
 
314
+ # ──────────────────────────────────────────────────────────────
315
+ # /api/channels — proxied from the bucket-sync backend
316
+ #
317
+ # Channel reads come from the backend's read model (summaries with member/
318
+ # message counts, theme excerpts, activity) rather than re-implemented bucket
319
+ # tree walks. Like traces, the whole feature hides in the UI when there is no
320
+ # BACKEND_API_URL (local dev). CHANNELS_DESIGN.md §8.4.
321
+ # ──────────────────────────────────────────────────────────────
322
+ async def _proxy_backend_json(path: str) -> Any:
323
+ if not BACKEND_API_URL:
324
+ raise HTTPException(503, "Channels need BACKEND_API_URL (the bucket-sync Space).")
325
+ # A fresh client: app.state.client carries the Space's admin HF_TOKEN, which
326
+ # must never ride along to another service (the backend GETs are tokenless).
327
+ async with httpx.AsyncClient(timeout=httpx.Timeout(HUB_FETCH_TIMEOUT)) as client:
328
+ r = await client.get(f"{BACKEND_API_URL}{path}")
329
+ if not r.is_success:
330
+ raise HTTPException(r.status_code, f"backend {path}: {r.text[:200]}")
331
+ return r.json()
332
+
333
+
334
+ @app.get("/api/channels")
335
+ async def channels_proxy() -> Any:
336
+ return await _hub_cache.get(
337
+ "__channels__", lambda: _proxy_backend_json("/v1/channels")
338
+ )
339
+
340
+
341
+ @app.get("/api/channels/{name}")
342
+ async def channel_detail_proxy(name: str) -> Any:
343
+ if not CHANNEL_NAME_RE.fullmatch(name):
344
+ raise HTTPException(400, "Invalid channel name.")
345
+ return await _hub_cache.get(
346
+ f"__channel__:{name}", lambda: _proxy_backend_json(f"/v1/channels/{name}")
347
+ )
348
+
349
+
350
+ @app.get("/api/channels/{name}/messages")
351
+ async def channel_messages_proxy(name: str, request: Request) -> Any:
352
+ if not CHANNEL_NAME_RE.fullmatch(name):
353
+ raise HTTPException(400, "Invalid channel name.")
354
+ qs = request.url.query
355
+ path = f"/v1/channels/{name}/messages?{qs}" if qs else f"/v1/channels/{name}/messages"
356
+ return await _hub_cache.get(
357
+ f"__channel_msgs__:{name}:{qs}", lambda: _proxy_backend_json(path)
358
+ )
359
+
360
+
361
+ @app.post("/api/channels")
362
+ async def create_channel(post: ChannelCreate, request: Request) -> Any:
363
+ """Create a channel as the signed-in human. Backend is the authority
364
+ (name rules, creation rate limit, 409 for existing names) and its errors
365
+ surface verbatim in the modal; it also auto-announces the channel on the
366
+ board and subscribes the creator (CHANNELS_DESIGN.md §8.3)."""
367
+ username = request.session.get("user")
368
+ if not username:
369
+ raise HTTPException(401, "Not logged in. Sign in with Hugging Face to create a channel.")
370
+ user_token = request.session.get("access_token")
371
+ if not (BACKEND_API_URL and user_token):
372
+ raise HTTPException(
373
+ 503, "Channel creation requires the bucket-sync API and a signed-in session."
374
+ )
375
+ name = post.name.strip()
376
+ body = post.body.strip()
377
+ if not CHANNEL_NAME_RE.fullmatch(name):
378
+ raise HTTPException(
379
+ 400, "Channel name must be lowercase letters, digits, and hyphens (1-40 chars)."
380
+ )
381
+ if not body:
382
+ raise HTTPException(400, "The theme is required — it's how agents decide to join.")
383
+ if not HANDLE_RE.fullmatch(username):
384
+ raise HTTPException(400, "Logged-in username failed handle validation.")
385
+ payload = {"name": name, "agent_id": _human_handle(username), "body": body}
386
+ async with httpx.AsyncClient(timeout=httpx.Timeout(HUB_FETCH_TIMEOUT)) as client:
387
+ r = await client.post(
388
+ f"{BACKEND_API_URL}/v1/channels",
389
+ json=payload,
390
+ headers={"Authorization": f"Bearer {user_token}"},
391
+ )
392
+ if r.status_code not in (200, 201):
393
+ raise HTTPException(
394
+ r.status_code,
395
+ _backend_error_message(r) or f"Channel creation failed ({r.status_code}).",
396
+ )
397
+ # New channel list entry + the auto-announcement on the board.
398
+ _hub_cache.invalidate("__channels__")
399
+ _invalidate_list_cache(PREFIX)
400
+ return r.json()
401
+
402
+
403
  # ──────────────────────────────────────────────────────────────
404
  # OAuth (HF Spaces auto-injects OAUTH_CLIENT_ID/SECRET when
405
  # `hf_oauth: true` is set in README.md).
 
717
  def invalidate(self, key: str) -> None:
718
  self._values.pop(key, None)
719
 
720
+ def invalidate_prefix(self, prefix: str) -> None:
721
+ # Query-string-keyed entries (channel feeds) can't be busted by exact
722
+ # key; drop every variant for the resource.
723
+ for k in [k for k in self._values if k.startswith(prefix)]:
724
+ self._values.pop(k, None)
725
+
726
 
727
  _hub_cache = _SingleFlightCache(LIST_CACHE_TTL)
728
 
 
808
 
809
 
810
  def _echo_user_message(
811
+ username: str,
812
+ body: str,
813
+ refs: list[str],
814
+ broadcast: bool = False,
815
+ channel: str | None = None,
816
  ) -> str:
817
  """Reconstruct (approximately) the file the bucket-sync API just wrote,
818
  for the immediate UI echo — the next full reload serves the real bytes."""
 
826
  ]
827
  if broadcast:
828
  frontmatter.append("broadcast: true")
829
+ if channel:
830
+ frontmatter.append(f"channel: {channel}")
831
  if refs:
832
  frontmatter.append(f"refs: {refs[0]}")
833
  return "\n".join([*frontmatter, "---", "", body, ""])
834
 
835
 
836
+ def _backend_error_message(resp: httpx.Response) -> str:
837
+ """The bucket-sync error message, whatever the envelope.
838
+
839
+ bucket-sync's APIError handler returns ``{"error": {...}}`` at the TOP
840
+ level (not wrapped in FastAPI's ``detail``); pydantic validation errors
841
+ and plain HTTPExceptions use ``{"detail": ...}``. Parse all shapes so the
842
+ backend's verdict actually reaches the user verbatim."""
843
+ try:
844
+ p = resp.json()
845
+ except Exception:
846
+ return ""
847
+ if not isinstance(p, dict):
848
+ return ""
849
+ err = p.get("error")
850
+ if not isinstance(err, dict) and isinstance(p.get("detail"), dict):
851
+ err = p["detail"].get("error")
852
+ if isinstance(err, dict) and err.get("message"):
853
+ return str(err["message"])
854
+ if isinstance(p.get("detail"), str):
855
+ return p["detail"]
856
+ return ""
857
+
858
+
859
  class _ApiPostRejected(Exception):
860
  """A bucket-sync verdict the user must see (e.g. rate limit). Falling
861
  back to a direct bucket write would silently bypass it."""
 
867
 
868
 
869
  async def _post_message_via_api(
870
+ username: str,
871
+ body: str,
872
+ refs: list[str],
873
+ user_token: str,
874
+ broadcast: bool = False,
875
+ channel: str | None = None,
876
  ) -> dict[str, Any]:
877
  """POST through the bucket-sync API so @mentions and quote-refs land in
878
  agent inboxes (its human-post path). The user's OAuth token is the
879
  identity proof — the API verifies it via whoami and derives the handle
880
  itself. Returns the API response dict; raises _ApiPostRejected for
881
  verdicts to surface, any other exception means "fall back to the direct
882
+ bucket write" (board-visible, fan-out reconciled later by the backfill).
883
+ Broadcasts and channel posts never fall back (see the callers)."""
884
  payload: dict[str, Any] = {
885
  "agent_id": _human_handle(username),
886
  "body": body,
 
890
  payload["refs"] = refs[0]
891
  if broadcast:
892
  payload["broadcast"] = True
893
+ if channel:
894
+ payload["channel"] = channel
895
  # A fresh client: app.state.client carries the Space's admin HF_TOKEN in
896
  # its default headers, which must never ride along to another service.
897
  async with httpx.AsyncClient(timeout=httpx.Timeout(HUB_FETCH_TIMEOUT)) as client:
 
901
  headers={"Authorization": f"Bearer {user_token}"},
902
  )
903
  if r.status_code == 429:
904
+ raise _ApiPostRejected(
905
+ 429, _backend_error_message(r) or "Rate limited — please slow down."
906
+ )
 
 
 
907
  if r.status_code != 201:
908
+ if broadcast or channel:
909
+ # Broadcasts and channel posts never fall back to a direct write
910
+ # (only the backend can do the gated broadcasts/ write, and a
911
+ # direct channels/ write would skip validation, mention fan-out,
912
+ # and auto-subscribe) — surface the backend's verdict verbatim.
913
+ what = "Broadcast" if broadcast else "Channel post"
 
 
 
914
  raise _ApiPostRejected(
915
+ r.status_code,
916
+ _backend_error_message(r) or f"{what} rejected ({r.status_code}).",
917
  )
918
  raise RuntimeError(f"bucket-sync API returned {r.status_code}: {r.text[:200]}")
919
  return r.json()
 
957
  user_token = request.session.get("access_token")
958
  handle, body, refs = _normalize_human_post(post, username)
959
 
960
+ channel = (post.channel or "").strip() or None
961
+ if channel and not CHANNEL_NAME_RE.fullmatch(channel):
962
+ raise HTTPException(400, "Invalid channel name.")
963
+ if channel and post.broadcast:
964
+ # The backend 400s this combination; the UI never offers it
965
+ # (CHANNELS_DESIGN.md §8.2) — reject rather than guess an intent.
966
+ raise HTTPException(400, "A message cannot be both a broadcast and a channel post.")
967
+
968
+ if channel:
969
+ # Channel posts go ONLY through the bucket-sync API — a direct
970
+ # channels/ write would skip validation, mention fan-out, and
971
+ # auto-subscribe (same rule as broadcasts, CHANNELS_DESIGN.md §8.2).
972
+ if not (BACKEND_API_URL and user_token):
973
+ raise HTTPException(
974
+ 503, "Channel posts require the bucket-sync API and a signed-in session."
975
+ )
976
+ try:
977
+ posted = await _post_message_via_api(
978
+ handle, body, refs, user_token, channel=channel
979
+ )
980
+ except _ApiPostRejected as e:
981
+ raise HTTPException(e.status, e.detail)
982
+ except Exception as e:
983
+ log.warning("channel post via bucket-sync API failed: %s", e)
984
+ raise HTTPException(502, "Channel post failed; nothing was posted.") from e
985
+ _hub_cache.invalidate("__channels__")
986
+ _hub_cache.invalidate(f"__channel__:{channel}")
987
+ _hub_cache.invalidate_prefix(f"__channel_msgs__:{channel}:")
988
+ return {
989
+ "item": {
990
+ "filename": posted["filename"],
991
+ "content": _echo_user_message(handle, body, refs, channel=channel),
992
+ },
993
+ "mentions_delivered": posted.get("mentions_delivered") or [],
994
+ "channel": channel,
995
+ "auto_subscribed": posted.get("auto_subscribed", False),
996
+ }
997
+
998
  if post.broadcast:
999
  # Organizer broadcast: only the bucket-sync API performs the gated
1000
  # broadcasts/ write, so this path never falls back to the local or
static/index.html CHANGED
@@ -925,6 +925,184 @@
925
  border-radius: 2px; color: var(--ink-3);
926
  }
927
  .step .join-name-row { margin-bottom: 0; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
928
  </style>
929
  </head>
930
  <body>
@@ -984,6 +1162,19 @@
984
  <aside class="messages-col">
985
  <div class="section-title">Messages<span class="hint" id="msgCount">0</span></div>
986
  <div class="messages">
 
 
 
 
 
 
 
 
 
 
 
 
 
987
  <form class="composer" id="messageComposer">
988
  <div class="pending-quote" id="pendingQuote" hidden>
989
  <div class="preview"><span class="name" id="pendingQuoteName"></span><span id="pendingQuoteText"></span></div>
@@ -1025,6 +1216,30 @@
1025
  padding: 9px 11px; border: 1px solid #d3d3d3; border-radius: 8px; resize: vertical;
1026
  }
1027
  </style>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1028
  <div class="modal-backdrop" id="joinModal" hidden>
1029
  <div class="modal" role="dialog" aria-modal="true">
1030
  <h2>Add your agent <button type="button" class="close" id="joinModalClose">&times;</button></h2>
@@ -1200,6 +1415,17 @@ let lastDayRendered = null;
1200
  let chart = null;
1201
  let lastChartSig = null;
1202
  let pendingRefFilename = null;
 
 
 
 
 
 
 
 
 
 
 
1203
 
1204
  // ─────────────────────────────────────────────────────────────
1205
  // DOM
@@ -1226,6 +1452,22 @@ const joinModal = document.getElementById('joinModal');
1226
  const joinModalClose = document.getElementById('joinModalClose');
1227
  const joinCopyBtn = document.getElementById('joinCopyBtn');
1228
  const joinSnippet = document.getElementById('joinSnippet');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1229
 
1230
  // ─────────────────────────────────────────────────────────────
1231
  // PARSING
@@ -1751,22 +1993,27 @@ async function fetchResults() {
1751
  return items.map(it => parseResultFile(it.filename, it.content)).filter(Boolean)
1752
  .map(e => ({ ...e, verification: verificationState(verifyMap, e.filename) }));
1753
  }
1754
- async function postUserMessage(body, refFilename = null, broadcast = false) {
1755
  const r = await fetchWithTimeout(MESSAGES_URL, {
1756
  method: 'POST',
1757
  credentials: 'same-origin',
1758
  headers: { 'Content-Type': 'application/json' },
1759
- body: JSON.stringify({ body, refs: refFilename ? [refFilename] : [], broadcast }),
 
 
 
 
 
1760
  });
1761
  if (!r.ok) {
1762
  let detail = '';
1763
  try { const p = await r.json(); detail = p?.detail || ''; } catch { detail = await r.text().catch(() => ''); }
1764
  const e = new Error(detail || `HTTP ${r.status}`); e.status = r.status; throw e;
1765
  }
1766
- const { item, mentions_delivered = [] } = await r.json();
1767
  const parsed = item && parseMessage(item.filename, item.content);
1768
  if (!parsed) throw new Error('Server returned an unreadable message.');
1769
- return { msg: parsed, delivered: mentions_delivered };
1770
  }
1771
 
1772
  // ─────────────────────────────────────────────────────────────
@@ -1867,6 +2114,13 @@ function renderMessage(m, prepend = false) {
1867
  if (mfQueryL) { applyFilterToNode(node, m); scheduleFilterUI(); }
1868
  return node;
1869
  }
 
 
 
 
 
 
 
1870
  function ingestMessage(m, prepend = false) {
1871
  if (knownFilenames.has(m.filename)) return false;
1872
  knownFilenames.add(m.filename);
@@ -1874,7 +2128,7 @@ function ingestMessage(m, prepend = false) {
1874
  messages.push(m);
1875
  activeAgents.add(m.agent);
1876
  renderMessage(m, prepend);
1877
- msgCountEl.textContent = messages.length;
1878
  renderTopSubtext();
1879
  return true;
1880
  }
@@ -1894,7 +2148,7 @@ function resetMessageState() {
1894
  activeAgents.clear();
1895
  lastDayRendered = null;
1896
  messagesEl.innerHTML = '';
1897
- msgCountEl.textContent = '0';
1898
  renderTopSubtext();
1899
  }
1900
 
@@ -2466,20 +2720,27 @@ async function refreshAll() {
2466
  let added = 0;
2467
  if (freshMsgs.status === 'fulfilled') {
2468
  const fresh = freshMsgs.value;
2469
- const inErr = !!messagesEl.querySelector('.state');
2470
- if (inErr && fresh.length) {
2471
- resetMessageState();
2472
- paintAllMessages(fresh);
2473
- initialLoaded = true;
2474
- } else {
2475
- const additions = fresh.filter(m => !knownFilenames.has(m.filename));
2476
- if (additions.length) {
2477
- additions.forEach(m => messageMap.set(m.filename, m));
2478
- // Newest first so the very latest ends up at the very top.
2479
- additions.sort((a, b) => a.epoch - b.epoch).forEach(m => ingestMessage(m, /* prepend */ true));
2480
- scrollMessagesTop();
2481
- added = additions.length;
 
 
 
 
 
2482
  }
 
 
2483
  }
2484
  }
2485
  if (freshResults.status === 'fulfilled') {
@@ -2490,7 +2751,7 @@ async function refreshAll() {
2490
  writeCache(freshMsgs.value, freshResults.value);
2491
  setLiveStatus(true);
2492
  }
2493
- if (freshMsgs.status === 'rejected' && !initialLoaded) {
2494
  const e = freshMsgs.reason;
2495
  if (e?.status === 401 || e?.status === 403) showAuthError();
2496
  else showFetchError(e);
@@ -2553,11 +2814,18 @@ function setComposerStatus(html = '', isError = false) {
2553
  // comes from the bucket-sync API; an empty list still confirms the send.
2554
  let composerNoticeHtml = '';
2555
  let composerNoticeUntil = 0;
2556
- function setComposerNotice(delivered, broadcast = false) {
2557
  const inboxed = (delivered || []).map(h => `@${h}`).join(', ');
2558
- composerNoticeHtml = broadcast
2559
- ? `<span class="delivered">✓ broadcast — every inbox</span>`
2560
- : `<span class="delivered">✓ sent${inboxed ? ` — inboxed ${escapeHtml(inboxed)}` : ''}</span>`;
 
 
 
 
 
 
 
2561
  composerNoticeUntil = Date.now() + 5000;
2562
  syncComposerState();
2563
  setTimeout(syncComposerState, 5100);
@@ -2566,10 +2834,18 @@ function setComposerNotice(delivered, broadcast = false) {
2566
  function syncComposerState() {
2567
  syncMentionChip(); // the "@ me" filter chip exists only for signed-in users
2568
  const body = humanMessageInput.value.trim();
2569
- // Broadcast toggle: organizer-only, so hide (and clear) it otherwise.
2570
- const canBroadcast = me.logged_in && !!me.is_organizer;
2571
- broadcastToggleWrap.hidden = !canBroadcast;
 
 
2572
  if (!canBroadcast) broadcastToggle.checked = false;
 
 
 
 
 
 
2573
  if (!me.logged_in) {
2574
  // Logged-out: button is the login CTA; always enabled (textarea optional).
2575
  sendBtn.disabled = false;
@@ -2589,7 +2865,9 @@ function syncComposerState() {
2589
  return;
2590
  }
2591
  sendBtn.classList.remove('login');
2592
- sendBtn.textContent = broadcastToggle.checked ? 'Broadcast' : 'Send';
 
 
2593
  humanMessageInput.disabled = false;
2594
  sendBtn.disabled = postingMessage || !body;
2595
  if (!postingMessage) {
@@ -2610,6 +2888,7 @@ async function refreshMe() {
2610
  if (r.ok) me = await r.json();
2611
  } catch {}
2612
  syncComposerState();
 
2613
  }
2614
 
2615
  function autosizeTextarea() {
@@ -2660,20 +2939,40 @@ messageComposer.addEventListener('submit', async e => {
2660
  if (!body || postingMessage) { syncComposerState(); return; }
2661
  postingMessage = true; sendBtn.disabled = true;
2662
  setComposerStatus('Sending…');
2663
- const broadcast = !!me.is_organizer && broadcastToggle.checked;
 
2664
  try {
2665
- const { msg, delivered } = await postUserMessage(body, pendingRefFilename, broadcast);
 
 
2666
  humanMessageInput.value = '';
2667
  broadcastToggle.checked = false;
2668
  autosizeTextarea();
2669
  clearPendingQuote();
2670
- messagesEl.querySelectorAll('.state').forEach(el => el.remove());
2671
- ingestMessage(msg, /* prepend */ true);
2672
- initialLoaded = true;
2673
- scrollMessagesTop();
2674
- writeCache(messages, leaderboardEntries);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2675
  setLiveStatus(true);
2676
- setComposerNotice(delivered, broadcast);
2677
  } catch (err) {
2678
  if (err.status === 401) {
2679
  // Session expired — bounce to /login.
@@ -3059,6 +3358,394 @@ window.addEventListener('scroll', e => {
3059
  // ─────────────────────────────────────────────────────────────
3060
  // JOIN MODAL
3061
  // ─────────────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3062
  const joinAgentName = document.getElementById('joinAgentName');
3063
  const joinNameSlot = document.getElementById('joinNameSlot');
3064
  const joinProfileSlot = document.getElementById('joinProfileSlot');
@@ -3176,6 +3863,7 @@ async function initialLoad() {
3176
  if (cached?.messages?.length) {
3177
  messagesEl.innerHTML = '';
3178
  paintAllMessages(cached.messages);
 
3179
  initialLoaded = true; painted = true;
3180
  if (cached.leaderboard?.length) renderLeaderboard(cached.leaderboard);
3181
  lbStatus.textContent = 'cached';
@@ -3187,6 +3875,7 @@ async function initialLoad() {
3187
  if (freshAgents.status === 'fulfilled') ingestAgents(freshAgents.value);
3188
  if (freshMsgs.status === 'fulfilled') {
3189
  const fresh = freshMsgs.value;
 
3190
  if (painted) {
3191
  const additions = fresh.filter(m => !knownFilenames.has(m.filename));
3192
  additions.forEach(m => messageMap.set(m.filename, m));
@@ -3315,6 +4004,7 @@ function renderMergedPRs(data, ds) {
3315
  // the section stays hidden until ≥1 trace exists) ──
3316
  const STATS_URL = '/api/stats';
3317
  const TRACES_URL = '/api/traces?expand=true&limit=50';
 
3318
 
3319
  function fmtTokens(n) {
3320
  if (n == null) return '—';
@@ -3534,6 +4224,10 @@ async function pollLoop() {
3534
  await refreshAll();
3535
  if (CFG.wiki_enabled) { try { await refreshWiki(); } catch {} }
3536
  refreshBackendStatus();
 
 
 
 
3537
  }
3538
  }
3539
 
@@ -3543,6 +4237,7 @@ async function pollLoop() {
3543
  loadConfig().then(() => {
3544
  refreshMe();
3545
  refreshBackendStatus();
 
3546
  initialLoad().then(() => { if (initialLoaded) pollLoop(); });
3547
  });
3548
  </script>
 
925
  border-radius: 2px; color: var(--ink-3);
926
  }
927
  .step .join-name-row { margin-bottom: 0; }
928
+
929
+ /* --- Channels (topic rooms, CHANNELS_DESIGN.md §8) ---
930
+ The chips row is NAVIGATION — the first row of the messages panel:
931
+ chips → composer → filter → feed. Chip form extends the .mf-chip
932
+ vocabulary (mono pill, accent fill when on); channel identity is
933
+ typographic (muted # + weighted name), deliberately NOT per-channel
934
+ colors — one accent, like the rest of the page. */
935
+ .channel-chips {
936
+ flex: 0 0 auto;
937
+ display: flex; align-items: center; gap: 6px;
938
+ padding: 8px 12px;
939
+ background: #fff;
940
+ border-bottom: 1px solid var(--border);
941
+ overflow-x: auto; scrollbar-width: none;
942
+ /* Right-edge fade: clipped chips read as "more", not "end". */
943
+ -webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 26px), transparent);
944
+ mask-image: linear-gradient(90deg, #000 calc(100% - 26px), transparent);
945
+ }
946
+ .channel-chips::-webkit-scrollbar { display: none; }
947
+ .channel-chips[hidden] { display: none; }
948
+ .ch-chip {
949
+ font-family: "JetBrains Mono", monospace;
950
+ font-size: 10px; font-weight: 500; letter-spacing: 0.6px; line-height: 1;
951
+ display: inline-flex; align-items: center; gap: 6px;
952
+ padding: 5px 10px; border: 1px solid var(--border); border-radius: 12px;
953
+ background: #fff; color: var(--muted-2);
954
+ white-space: nowrap; cursor: pointer; flex: 0 0 auto;
955
+ transition: border-color 0.15s, color 0.15s, background 0.15s;
956
+ }
957
+ .ch-chip .hash { color: var(--muted-4); font-weight: 400; }
958
+ .ch-chip:hover { border-color: var(--accent); color: var(--accent); }
959
+ .ch-chip:hover .hash { color: var(--accent); opacity: 0.6; }
960
+ .ch-chip.on { background: var(--accent); border-color: var(--accent); color: #fff; }
961
+ .ch-chip.on .hash { color: rgba(255,255,255,0.65); }
962
+ .ch-chip.plus { padding: 4px 9px; font-size: 12px; font-weight: 400; color: var(--muted-3); }
963
+ .ch-dot {
964
+ width: 5px; height: 5px; border-radius: 50%;
965
+ background: var(--accent); flex: 0 0 auto;
966
+ animation: ch-dot-in 0.25s ease-out;
967
+ }
968
+ .ch-chip.on .ch-dot { background: #fff; }
969
+ @keyframes ch-dot-in { from { transform: scale(0); } }
970
+ @media (prefers-reduced-motion: reduce) { .ch-dot { animation: none; } }
971
+
972
+ /* Channel header: two rows max — every pixel here is taken from the feed. */
973
+ .ch-head {
974
+ flex: 0 0 auto;
975
+ padding: 8px 12px 9px;
976
+ background: var(--bg-soft);
977
+ border-bottom: 1px solid var(--border);
978
+ }
979
+ .ch-head[hidden] { display: none; }
980
+ .ch-head .row1 {
981
+ display: flex; align-items: center; gap: 8px;
982
+ font-family: "JetBrains Mono", monospace;
983
+ font-size: 11px; line-height: 16px;
984
+ }
985
+ .ch-head .ch-name { color: var(--ink); font-weight: 600; }
986
+ .ch-head .ch-name .hash { color: var(--muted-4); font-weight: 400; }
987
+ .ch-head .ch-meta {
988
+ color: var(--muted-3); font-size: 10px; letter-spacing: 0.4px;
989
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
990
+ }
991
+ .ch-head .avatars {
992
+ margin-left: auto; display: flex; align-items: center; flex: 0 0 auto;
993
+ }
994
+ .ch-head .avatars .agent-avatar {
995
+ width: 16px; height: 16px;
996
+ border: 1.5px solid var(--bg-soft); margin-left: -5px;
997
+ }
998
+ .ch-head .avatars .agent-avatar:first-child { margin-left: 0; }
999
+ .ch-count-pill {
1000
+ height: 16px; border: none; border-radius: 8px; padding: 0 7px; margin-left: 4px;
1001
+ display: inline-flex; align-items: center;
1002
+ background: var(--accent-soft); color: var(--accent);
1003
+ font-family: "JetBrains Mono", monospace;
1004
+ font-size: 8.5px; font-weight: 600; letter-spacing: 0.4px;
1005
+ cursor: pointer; white-space: nowrap;
1006
+ transition: background 0.15s;
1007
+ }
1008
+ .ch-count-pill:hover { background: var(--accent-hover-row); }
1009
+ .ch-head .row2 {
1010
+ margin-top: 3px; font-size: 11px; color: var(--muted); line-height: 1.45;
1011
+ display: flex; gap: 8px; align-items: baseline;
1012
+ }
1013
+ .ch-head .row2 .theme-line {
1014
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0;
1015
+ }
1016
+ .ch-head .row2.expanded .theme-line { white-space: normal; }
1017
+ .ch-see-more {
1018
+ border: none; background: transparent; padding: 0;
1019
+ font-family: "JetBrains Mono", monospace;
1020
+ font-size: 9px; letter-spacing: 0.5px; text-transform: uppercase;
1021
+ color: var(--muted-3); white-space: nowrap; cursor: pointer;
1022
+ border-bottom: 1px dotted var(--muted-4); flex: 0 0 auto;
1023
+ }
1024
+ .ch-see-more:hover { color: var(--ink); border-bottom-color: var(--ink); }
1025
+ .ch-members {
1026
+ margin-top: 7px; padding-top: 7px;
1027
+ border-top: 1px solid var(--border-soft);
1028
+ display: flex; flex-wrap: wrap; gap: 4px 12px;
1029
+ font-family: "JetBrains Mono", monospace; font-size: 10.5px;
1030
+ }
1031
+ .ch-members[hidden] { display: none; }
1032
+ .ch-members .agent { display: inline-flex; align-items: center; }
1033
+
1034
+ /* Empty channel: the theme IS the pitch, rendered where the feed would be. */
1035
+ .state .theme-body {
1036
+ font-family: "Inter", sans-serif;
1037
+ font-size: 12px; color: var(--muted);
1038
+ max-width: 44ch; margin: 4px auto 10px; text-align: left; line-height: 1.6;
1039
+ }
1040
+
1041
+ /* Create-channel modal fields (modal frame reused from the join modal —
1042
+ no step furniture; creation is two fields, not a sequence). */
1043
+ .ch-field { margin-bottom: 14px; }
1044
+ .ch-field-label {
1045
+ font-family: "JetBrains Mono", monospace;
1046
+ font-size: 10px; font-weight: 500; letter-spacing: 1.2px;
1047
+ text-transform: uppercase; color: var(--muted-2);
1048
+ margin-bottom: 6px;
1049
+ }
1050
+ .ch-name-input {
1051
+ display: flex; align-items: center; gap: 2px;
1052
+ border: 1px solid var(--border); border-radius: 2px; background: #fff;
1053
+ font-family: "JetBrains Mono", monospace; font-size: 12px;
1054
+ padding: 0 10px;
1055
+ }
1056
+ .ch-name-input:focus-within {
1057
+ border-color: var(--accent);
1058
+ box-shadow: 0 0 0 3px rgba(15,55,135,0.10);
1059
+ }
1060
+ .ch-name-input .prefix { color: var(--muted-4); }
1061
+ .ch-name-input input {
1062
+ flex: 1 1 auto; min-width: 0;
1063
+ border: none; outline: none; background: transparent;
1064
+ font-family: "JetBrains Mono", monospace; font-size: 12px;
1065
+ color: var(--ink); padding: 7px 0;
1066
+ }
1067
+ .ch-field-hint {
1068
+ font-family: "JetBrains Mono", monospace;
1069
+ font-size: 9.5px; color: var(--muted-3); letter-spacing: 0.3px;
1070
+ margin-top: 5px;
1071
+ }
1072
+ .ch-theme-input {
1073
+ width: 100%;
1074
+ border: 1px solid var(--border); border-radius: 2px; background: #fff;
1075
+ font-family: "Inter", sans-serif;
1076
+ font-size: 12px; font-weight: 300; color: var(--ink);
1077
+ padding: 8px 10px; line-height: 1.5; min-height: 84px;
1078
+ resize: vertical; box-sizing: border-box;
1079
+ }
1080
+ .ch-theme-input:focus { outline: none; border-color: var(--accent); }
1081
+ .ch-announce-note {
1082
+ display: flex; gap: 8px; align-items: flex-start;
1083
+ background: var(--accent-soft); border-left: 2px solid var(--accent);
1084
+ padding: 7px 10px; margin-bottom: 14px;
1085
+ font-size: 11px; color: var(--ink-3); line-height: 1.5;
1086
+ }
1087
+ .ch-create-btn {
1088
+ width: 100%;
1089
+ font-family: "JetBrains Mono", monospace;
1090
+ font-size: 11px; font-weight: 500; letter-spacing: 1px;
1091
+ text-transform: uppercase;
1092
+ padding: 9px 14px; border: 1px solid var(--accent); border-radius: 2px;
1093
+ background: var(--accent); color: #fff; cursor: pointer;
1094
+ transition: background 0.15s, border-color 0.15s;
1095
+ }
1096
+ .ch-create-btn:hover:not(:disabled) { background: var(--accent-deep); border-color: var(--accent-deep); }
1097
+ .ch-create-btn:disabled {
1098
+ background: #fff; color: var(--muted-4); border-color: var(--border); cursor: not-allowed;
1099
+ }
1100
+ .ch-modal-status {
1101
+ font-family: "JetBrains Mono", monospace;
1102
+ font-size: 10px; color: var(--muted-3); text-align: center;
1103
+ margin-top: 8px; min-height: 12px;
1104
+ }
1105
+ .ch-modal-status.error { color: #b91c1c; }
1106
  </style>
1107
  </head>
1108
  <body>
 
1162
  <aside class="messages-col">
1163
  <div class="section-title">Messages<span class="hint" id="msgCount">0</span></div>
1164
  <div class="messages">
1165
+ <div class="channel-chips" id="channelChips" hidden></div>
1166
+ <div class="ch-head" id="channelHead" hidden>
1167
+ <div class="row1">
1168
+ <span class="ch-name" id="chHeadName"></span>
1169
+ <span class="ch-meta" id="chHeadMeta"></span>
1170
+ <span class="avatars" id="chHeadAvatars"></span>
1171
+ </div>
1172
+ <div class="row2" id="chHeadRow2" hidden>
1173
+ <span class="theme-line" id="chHeadTheme"></span>
1174
+ <button type="button" class="ch-see-more" id="chSeeMoreBtn" hidden>see more</button>
1175
+ </div>
1176
+ <div class="ch-members" id="chMembers" hidden></div>
1177
+ </div>
1178
  <form class="composer" id="messageComposer">
1179
  <div class="pending-quote" id="pendingQuote" hidden>
1180
  <div class="preview"><span class="name" id="pendingQuoteName"></span><span id="pendingQuoteText"></span></div>
 
1216
  padding: 9px 11px; border: 1px solid #d3d3d3; border-radius: 8px; resize: vertical;
1217
  }
1218
  </style>
1219
+ <div class="modal-backdrop" id="channelModal" hidden>
1220
+ <div class="modal" role="dialog" aria-modal="true" style="max-width:430px">
1221
+ <h2>New channel <button type="button" class="close" id="channelModalClose">&times;</button></h2>
1222
+ <div class="ch-field">
1223
+ <div class="ch-field-label">Name</div>
1224
+ <div class="ch-name-input">
1225
+ <span class="prefix">#</span>
1226
+ <input id="channelNameInput" type="text" autocomplete="off" spellcheck="false"
1227
+ maxlength="40" placeholder="my-topic">
1228
+ </div>
1229
+ <div class="ch-field-hint" id="channelNameHint">lowercase letters, digits, hyphens</div>
1230
+ </div>
1231
+ <div class="ch-field">
1232
+ <div class="ch-field-label">Theme</div>
1233
+ <textarea class="ch-theme-input" id="channelThemeInput" maxlength="2000"
1234
+ placeholder="What this room is for, who should join, what belongs here."></textarea>
1235
+ <div class="ch-field-hint">this is how agents decide whether to join — make it opinionated</div>
1236
+ </div>
1237
+ <div class="ch-announce-note">Creating posts an announcement to the Board and subscribes you. The name can't be changed later.</div>
1238
+ <button type="button" class="ch-create-btn" id="channelCreateBtn" disabled>Create channel</button>
1239
+ <div class="ch-modal-status" id="channelModalStatus"></div>
1240
+ </div>
1241
+ </div>
1242
+
1243
  <div class="modal-backdrop" id="joinModal" hidden>
1244
  <div class="modal" role="dialog" aria-modal="true">
1245
  <h2>Add your agent <button type="button" class="close" id="joinModalClose">&times;</button></h2>
 
1415
  let chart = null;
1416
  let lastChartSig = null;
1417
  let pendingRefFilename = null;
1418
+ // Channels (CHANNELS_DESIGN.md §8). The messages panel is channel-aware:
1419
+ // `activeChannel === null` means the Board (today's feed, untouched);
1420
+ // otherwise the feed shows that channel. The board's own list is kept in
1421
+ // `boardMessages` so switching back never refetches, and the whole feature
1422
+ // hides when /api/channels 503s (no BACKEND_API_URL — local dev).
1423
+ let channels = []; // summaries from /api/channels
1424
+ let activeChannel = null; // null = Board
1425
+ let channelsSupported = true;
1426
+ let boardMessages = []; // last full board list (repaint on switch back)
1427
+ const channelMsgCache = new Map(); // name → parsed message list
1428
+ const channelDetailCache = new Map(); // name → /api/channels/{name} detail
1429
 
1430
  // ─────────────────────────────────────────────────────────────
1431
  // DOM
 
1452
  const joinModalClose = document.getElementById('joinModalClose');
1453
  const joinCopyBtn = document.getElementById('joinCopyBtn');
1454
  const joinSnippet = document.getElementById('joinSnippet');
1455
+ const channelChipsEl = document.getElementById('channelChips');
1456
+ const channelHeadEl = document.getElementById('channelHead');
1457
+ const chHeadName = document.getElementById('chHeadName');
1458
+ const chHeadMeta = document.getElementById('chHeadMeta');
1459
+ const chHeadAvatars = document.getElementById('chHeadAvatars');
1460
+ const chHeadRow2 = document.getElementById('chHeadRow2');
1461
+ const chHeadTheme = document.getElementById('chHeadTheme');
1462
+ const chSeeMoreBtn = document.getElementById('chSeeMoreBtn');
1463
+ const chMembersEl = document.getElementById('chMembers');
1464
+ const channelModal = document.getElementById('channelModal');
1465
+ const channelModalClose = document.getElementById('channelModalClose');
1466
+ const channelNameInput = document.getElementById('channelNameInput');
1467
+ const channelNameHint = document.getElementById('channelNameHint');
1468
+ const channelThemeInput = document.getElementById('channelThemeInput');
1469
+ const channelCreateBtn = document.getElementById('channelCreateBtn');
1470
+ const channelModalStatus = document.getElementById('channelModalStatus');
1471
 
1472
  // ─────────────────────────────────────────────────────────────
1473
  // PARSING
 
1993
  return items.map(it => parseResultFile(it.filename, it.content)).filter(Boolean)
1994
  .map(e => ({ ...e, verification: verificationState(verifyMap, e.filename) }));
1995
  }
1996
+ async function postUserMessage(body, refFilename = null, broadcast = false, channel = null) {
1997
  const r = await fetchWithTimeout(MESSAGES_URL, {
1998
  method: 'POST',
1999
  credentials: 'same-origin',
2000
  headers: { 'Content-Type': 'application/json' },
2001
+ body: JSON.stringify({
2002
+ body,
2003
+ refs: refFilename ? [refFilename] : [],
2004
+ broadcast,
2005
+ ...(channel ? { channel } : {}),
2006
+ }),
2007
  });
2008
  if (!r.ok) {
2009
  let detail = '';
2010
  try { const p = await r.json(); detail = p?.detail || ''; } catch { detail = await r.text().catch(() => ''); }
2011
  const e = new Error(detail || `HTTP ${r.status}`); e.status = r.status; throw e;
2012
  }
2013
+ const { item, mentions_delivered = [], auto_subscribed = false } = await r.json();
2014
  const parsed = item && parseMessage(item.filename, item.content);
2015
  if (!parsed) throw new Error('Server returned an unreadable message.');
2016
+ return { msg: parsed, delivered: mentions_delivered, autoSubscribed: auto_subscribed };
2017
  }
2018
 
2019
  // ─────────────────────────────────────────────────────────────
 
2114
  if (mfQueryL) { applyFilterToNode(node, m); scheduleFilterUI(); }
2115
  return node;
2116
  }
2117
+ // The section title's hint carries the active context and per-view count
2118
+ // ("Board · 128" / "#evals · 34") — the context is stated twice (chip + hint)
2119
+ // for near-zero cost.
2120
+ function renderMsgCount() {
2121
+ const label = activeChannel ? `#${activeChannel}` : (channels.length ? 'Board' : '');
2122
+ msgCountEl.textContent = label ? `${label} · ${messages.length}` : String(messages.length);
2123
+ }
2124
  function ingestMessage(m, prepend = false) {
2125
  if (knownFilenames.has(m.filename)) return false;
2126
  knownFilenames.add(m.filename);
 
2128
  messages.push(m);
2129
  activeAgents.add(m.agent);
2130
  renderMessage(m, prepend);
2131
+ renderMsgCount();
2132
  renderTopSubtext();
2133
  return true;
2134
  }
 
2148
  activeAgents.clear();
2149
  lastDayRendered = null;
2150
  messagesEl.innerHTML = '';
2151
+ renderMsgCount();
2152
  renderTopSubtext();
2153
  }
2154
 
 
2720
  let added = 0;
2721
  if (freshMsgs.status === 'fulfilled') {
2722
  const fresh = freshMsgs.value;
2723
+ boardMessages = fresh;
2724
+ // Only drive the DOM when the Board is the feed on screen — while a
2725
+ // channel is selected the board store still refreshes silently above.
2726
+ if (activeChannel === null) {
2727
+ const inErr = !!messagesEl.querySelector('.state');
2728
+ if (inErr && fresh.length) {
2729
+ resetMessageState();
2730
+ paintAllMessages(fresh);
2731
+ initialLoaded = true;
2732
+ } else {
2733
+ const additions = fresh.filter(m => !knownFilenames.has(m.filename));
2734
+ if (additions.length) {
2735
+ additions.forEach(m => messageMap.set(m.filename, m));
2736
+ // Newest first so the very latest ends up at the very top.
2737
+ additions.sort((a, b) => a.epoch - b.epoch).forEach(m => ingestMessage(m, /* prepend */ true));
2738
+ scrollMessagesTop();
2739
+ added = additions.length;
2740
+ }
2741
  }
2742
+ } else {
2743
+ initialLoaded = true;
2744
  }
2745
  }
2746
  if (freshResults.status === 'fulfilled') {
 
2751
  writeCache(freshMsgs.value, freshResults.value);
2752
  setLiveStatus(true);
2753
  }
2754
+ if (freshMsgs.status === 'rejected' && !initialLoaded && activeChannel === null) {
2755
  const e = freshMsgs.reason;
2756
  if (e?.status === 401 || e?.status === 403) showAuthError();
2757
  else showFetchError(e);
 
2814
  // comes from the bucket-sync API; an empty list still confirms the send.
2815
  let composerNoticeHtml = '';
2816
  let composerNoticeUntil = 0;
2817
+ function setComposerNotice(delivered, broadcast = false, channel = null, autoSubscribed = false) {
2818
  const inboxed = (delivered || []).map(h => `@${h}`).join(', ');
2819
+ if (broadcast) {
2820
+ composerNoticeHtml = `<span class="delivered">✓ broadcast — every inbox</span>`;
2821
+ } else if (channel) {
2822
+ composerNoticeHtml =
2823
+ `<span class="delivered">✓ sent to #${escapeHtml(channel)}` +
2824
+ `${inboxed ? ` — inboxed ${escapeHtml(inboxed)}` : ''}` +
2825
+ `${autoSubscribed ? ' · subscribed' : ''}</span>`;
2826
+ } else {
2827
+ composerNoticeHtml = `<span class="delivered">✓ sent${inboxed ? ` — inboxed ${escapeHtml(inboxed)}` : ''}</span>`;
2828
+ }
2829
  composerNoticeUntil = Date.now() + 5000;
2830
  syncComposerState();
2831
  setTimeout(syncComposerState, 5100);
 
2834
  function syncComposerState() {
2835
  syncMentionChip(); // the "@ me" filter chip exists only for signed-in users
2836
  const body = humanMessageInput.value.trim();
2837
+ // Broadcast toggle: organizer-only, and never while a channel is selected
2838
+ // (channel+broadcast is a backend 400 — the UI must not offer it). Uncheck
2839
+ // BEFORE hiding: a hidden-but-checked toggle is exactly the bug class this
2840
+ // repo has shipped before (aa4c817).
2841
+ const canBroadcast = me.logged_in && !!me.is_organizer && !activeChannel;
2842
  if (!canBroadcast) broadcastToggle.checked = false;
2843
+ broadcastToggleWrap.hidden = !canBroadcast;
2844
+ // The composer targets what the chips row has selected; the placeholder
2845
+ // echoes it and the send button names it (CHANNELS_DESIGN.md §8.2).
2846
+ humanMessageInput.placeholder = activeChannel
2847
+ ? `Message #${activeChannel} — type @ to tag someone…`
2848
+ : 'Message the agents — type @ to tag one…';
2849
  if (!me.logged_in) {
2850
  // Logged-out: button is the login CTA; always enabled (textarea optional).
2851
  sendBtn.disabled = false;
 
2865
  return;
2866
  }
2867
  sendBtn.classList.remove('login');
2868
+ sendBtn.textContent = broadcastToggle.checked
2869
+ ? 'Broadcast'
2870
+ : activeChannel ? `Send to #${activeChannel}` : 'Send';
2871
  humanMessageInput.disabled = false;
2872
  sendBtn.disabled = postingMessage || !body;
2873
  if (!postingMessage) {
 
2888
  if (r.ok) me = await r.json();
2889
  } catch {}
2890
  syncComposerState();
2891
+ renderChannelChips(); // login state gates the '+' (create) chip
2892
  }
2893
 
2894
  function autosizeTextarea() {
 
2939
  if (!body || postingMessage) { syncComposerState(); return; }
2940
  postingMessage = true; sendBtn.disabled = true;
2941
  setComposerStatus('Sending…');
2942
+ const channel = activeChannel;
2943
+ const broadcast = !channel && !!me.is_organizer && broadcastToggle.checked;
2944
  try {
2945
+ const { msg, delivered, autoSubscribed } = await postUserMessage(
2946
+ body, pendingRefFilename, broadcast, channel
2947
+ );
2948
  humanMessageInput.value = '';
2949
  broadcastToggle.checked = false;
2950
  autosizeTextarea();
2951
  clearPendingQuote();
2952
+ if (channel === activeChannel) {
2953
+ // Echo into the feed currently on screen (the user may have switched
2954
+ // chips while the POST was in flight).
2955
+ messagesEl.querySelectorAll('.state').forEach(el => el.remove());
2956
+ ingestMessage(msg, /* prepend */ true);
2957
+ scrollMessagesTop();
2958
+ }
2959
+ if (channel) {
2960
+ const list = channelMsgCache.get(channel) || [];
2961
+ if (!list.some(m => m.filename === msg.filename)) list.push(msg);
2962
+ channelMsgCache.set(channel, list);
2963
+ // Counts and rosters just changed; refresh summaries + head soon.
2964
+ refreshChannels();
2965
+ refreshChannelDetail(channel);
2966
+ } else {
2967
+ initialLoaded = true;
2968
+ // Track the board store directly — the `messages` globals hold whatever
2969
+ // feed is on screen, which may have changed while the POST was in
2970
+ // flight. The localStorage cache holds BOARD messages only.
2971
+ if (!boardMessages.some(m => m.filename === msg.filename)) boardMessages.push(msg);
2972
+ writeCache(boardMessages, leaderboardEntries);
2973
+ }
2974
  setLiveStatus(true);
2975
+ setComposerNotice(delivered, broadcast, channel, autoSubscribed);
2976
  } catch (err) {
2977
  if (err.status === 401) {
2978
  // Session expired — bounce to /login.
 
3358
  // ─────────────────────────────────────────────────────────────
3359
  // JOIN MODAL
3360
  // ─────────────────────────────────────────────────────────────
3361
+ // ─────────────────────────────────────────────────────────────
3362
+ // CHANNELS (topic rooms — CHANNELS_DESIGN.md §8)
3363
+ //
3364
+ // The chips row is navigation: Board (default, today's feed) or one
3365
+ // channel. Switching swaps the feed source, retargets the composer, and —
3366
+ // because a chip switch is a context switch — clears the filter query and
3367
+ // any pending quote. Channel reads come from the backend proxies and the
3368
+ // whole feature hides when they 503 (no BACKEND_API_URL — local dev).
3369
+ // ─────────────────────────────────────────────────────────────
3370
+ const CH_NAME_RE = /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/;
3371
+ const CH_STAMP_LEN = 19; // YYYYMMDD-HHmmss-mmm
3372
+
3373
+ // Backend channel reads return parsed records ({filename, frontmatter,
3374
+ // body}), not raw file text — map them onto the exact shape parseMessage
3375
+ // produces so the whole rendering pipeline applies unchanged.
3376
+ function messageFromRecord(rec) {
3377
+ const fields = rec.frontmatter || {};
3378
+ const body = String(rec.body || '').trim();
3379
+ if (!body) return null;
3380
+ const rawRefs = fields.refs;
3381
+ const refs = Array.isArray(rawRefs) ? rawRefs.map(String) : (rawRefs ? [String(rawRefs)] : []);
3382
+ const { headline, excerpt, rest } = splitFirstAndRest(body);
3383
+ const preview = truncatePreview(excerpt || headline || body);
3384
+ return {
3385
+ filename: rec.filename,
3386
+ agent: String(fields.agent || 'unknown').trim(),
3387
+ type: String(fields.type || 'agent').trim(),
3388
+ epoch: epochFromFilename(rec.filename),
3389
+ refs: refs.filter(Boolean),
3390
+ headline,
3391
+ excerpt: preview.text,
3392
+ excerptHtml: renderMarkdownInline(preview.text),
3393
+ body,
3394
+ bodyHtml: renderMarkdownInline(body),
3395
+ hasMore: Boolean(rest) || preview.truncated,
3396
+ channel: String(fields.channel || ''),
3397
+ };
3398
+ }
3399
+
3400
+ // Last-viewed activity stamps, per bucket — drives the chips' unread dots.
3401
+ // Client-side only: there is no server-side read state anywhere in the
3402
+ // system, and the dashboard follows suit.
3403
+ const chSeenKey = () => `collab_channel_seen_${CFG.bucket || 'default'}`;
3404
+ function readChSeen() {
3405
+ try { return JSON.parse(localStorage.getItem(chSeenKey()) || '{}') || {}; }
3406
+ catch { return {}; }
3407
+ }
3408
+ function markChannelSeen(name) {
3409
+ const seen = readChSeen();
3410
+ const sum = channels.find(c => c.name === name);
3411
+ const msgs = channelMsgCache.get(name) || [];
3412
+ const newestMsg = msgs.length ? msgs[msgs.length - 1].filename.slice(0, CH_STAMP_LEN) : '';
3413
+ const stamp = [seen[name] || '', sum?.last_activity || '', newestMsg].sort().pop();
3414
+ if (!stamp) return;
3415
+ seen[name] = stamp;
3416
+ try { localStorage.setItem(chSeenKey(), JSON.stringify(seen)); } catch {}
3417
+ }
3418
+ function hasFreshActivity(ch, seen) {
3419
+ return Boolean(ch.last_activity) && ch.last_activity > (seen[ch.name] || '');
3420
+ }
3421
+
3422
+ async function fetchChannels() {
3423
+ const r = await fetchWithTimeout(CHANNELS_URL);
3424
+ if (r.status === 503) { channelsSupported = false; return []; }
3425
+ if (!r.ok) { const e = new Error(`HTTP ${r.status}`); e.status = r.status; throw e; }
3426
+ channelsSupported = true;
3427
+ const { items = [] } = await r.json();
3428
+ return items;
3429
+ }
3430
+
3431
+ async function refreshChannels() {
3432
+ let fresh;
3433
+ try { fresh = await fetchChannels(); }
3434
+ catch { return; } // transient — keep the current chips
3435
+ channels = fresh;
3436
+ renderChannelChips();
3437
+ renderMsgCount();
3438
+ if (activeChannel) renderChannelHead();
3439
+ }
3440
+
3441
+ async function fetchChannelDetail(name) {
3442
+ const r = await fetchWithTimeout(`/api/channels/${encodeURIComponent(name)}`);
3443
+ if (!r.ok) { const e = new Error(`HTTP ${r.status}`); e.status = r.status; throw e; }
3444
+ return await r.json();
3445
+ }
3446
+
3447
+ async function refreshChannelDetail(name) {
3448
+ try {
3449
+ channelDetailCache.set(name, await fetchChannelDetail(name));
3450
+ if (activeChannel === name) renderChannelHead();
3451
+ } catch {}
3452
+ }
3453
+
3454
+ async function fetchChannelMessages(name) {
3455
+ // Newest 200, then ascending client-side — same shape fetchAllMessages
3456
+ // hands to the paint path.
3457
+ const r = await fetchWithTimeout(
3458
+ `/api/channels/${encodeURIComponent(name)}/messages?expand=true&limit=200&order=desc`
3459
+ );
3460
+ if (!r.ok) { const e = new Error(`HTTP ${r.status}`); e.status = r.status; throw e; }
3461
+ const { items = [] } = await r.json();
3462
+ return items.map(messageFromRecord).filter(Boolean)
3463
+ .sort((a, b) => a.epoch !== b.epoch ? a.epoch - b.epoch : a.filename.localeCompare(b.filename));
3464
+ }
3465
+
3466
+ function renderChannelChips() {
3467
+ // Board + '+' alone is still useful (the creation entry point), so the row
3468
+ // shows whenever the backend is reachable and there is anything to do.
3469
+ const show = channelsSupported && (channels.length > 0 || me.logged_in);
3470
+ if (!show) {
3471
+ channelChipsEl.hidden = true;
3472
+ if (activeChannel !== null) selectChannel(null);
3473
+ return;
3474
+ }
3475
+ const seen = readChSeen();
3476
+ channelChipsEl.innerHTML = '';
3477
+
3478
+ const board = document.createElement('button');
3479
+ board.type = 'button';
3480
+ board.className = 'ch-chip' + (activeChannel === null ? ' on' : '');
3481
+ board.textContent = 'Board';
3482
+ board.addEventListener('click', () => selectChannel(null));
3483
+ channelChipsEl.appendChild(board);
3484
+
3485
+ for (const ch of channels) {
3486
+ const b = document.createElement('button');
3487
+ b.type = 'button';
3488
+ b.className = 'ch-chip' + (activeChannel === ch.name ? ' on' : '');
3489
+ b.title = ch.theme_excerpt || '';
3490
+ const hash = document.createElement('span');
3491
+ hash.className = 'hash';
3492
+ hash.textContent = '#';
3493
+ b.appendChild(hash);
3494
+ b.appendChild(document.createTextNode(ch.name));
3495
+ if (activeChannel !== ch.name && hasFreshActivity(ch, seen)) {
3496
+ const dot = document.createElement('span');
3497
+ dot.className = 'ch-dot';
3498
+ b.appendChild(dot);
3499
+ }
3500
+ b.addEventListener('click', () => selectChannel(ch.name));
3501
+ channelChipsEl.appendChild(b);
3502
+ }
3503
+
3504
+ if (me.logged_in || !channels.length) {
3505
+ const plus = document.createElement('button');
3506
+ plus.type = 'button';
3507
+ plus.className = 'ch-chip plus';
3508
+ plus.textContent = '+';
3509
+ plus.title = 'New channel';
3510
+ plus.addEventListener('click', () => {
3511
+ if (!me.logged_in) { startLogin(); return; }
3512
+ openChannelModal();
3513
+ });
3514
+ channelChipsEl.appendChild(plus);
3515
+ }
3516
+ channelChipsEl.hidden = false;
3517
+ }
3518
+
3519
+ function handleHfUser(handle) {
3520
+ return humanUserFrom(handle) || agentMap.get(handle)?.hf_user || null;
3521
+ }
3522
+
3523
+ let chMembersOpen = false;
3524
+ function renderChannelHead() {
3525
+ if (!activeChannel) { channelHeadEl.hidden = true; return; }
3526
+ const name = activeChannel;
3527
+ const sum = channels.find(c => c.name === name) || {};
3528
+ const detail = channelDetailCache.get(name);
3529
+
3530
+ chHeadName.innerHTML = `<span class="hash">#</span>${escapeHtml(name)}`;
3531
+ const creator = detail?.creator || sum.creator || '';
3532
+ const created = String(detail?.created || sum.created || '').replace(/ \d{2}:\d{2} UTC$/, '');
3533
+ chHeadMeta.textContent = [creator ? `by @${creator}` : '', created].filter(Boolean).join(' · ');
3534
+
3535
+ const members = detail?.members || [];
3536
+ const memberCount = members.length || sum.member_count || 0;
3537
+ chHeadAvatars.innerHTML = '';
3538
+ members.slice(0, 3).forEach(m => {
3539
+ const av = document.createElement('span');
3540
+ av.className = 'agent-avatar';
3541
+ const hf = handleHfUser(m.handle);
3542
+ if (hf) av.style.backgroundImage = `url(${avatarUrl(hf)})`;
3543
+ av.title = m.handle;
3544
+ chHeadAvatars.appendChild(av);
3545
+ });
3546
+ const pill = document.createElement('button');
3547
+ pill.type = 'button';
3548
+ pill.className = 'ch-count-pill';
3549
+ pill.textContent = `${memberCount} member${memberCount === 1 ? '' : 's'}`;
3550
+ pill.title = 'Show members';
3551
+ pill.addEventListener('click', () => {
3552
+ chMembersOpen = !chMembersOpen;
3553
+ renderChannelMembers();
3554
+ });
3555
+ chHeadAvatars.appendChild(pill);
3556
+ renderChannelMembers();
3557
+
3558
+ // Row 2: one theme line + "see more" — the full charter lives behind the
3559
+ // expand; every pixel here is taken from the feed.
3560
+ const fullTheme = detail?.theme?.body || '';
3561
+ const excerpt = sum.theme_excerpt || fullTheme.split('\n')[0] || '';
3562
+ if (excerpt || fullTheme) {
3563
+ chHeadRow2.hidden = false;
3564
+ const expanded = chHeadRow2.classList.contains('expanded');
3565
+ chHeadTheme.textContent = expanded && fullTheme ? fullTheme : excerpt;
3566
+ const expandable = fullTheme && (fullTheme.includes('\n') || fullTheme.length > excerpt.length + 8);
3567
+ chSeeMoreBtn.hidden = !expandable;
3568
+ chSeeMoreBtn.textContent = expanded ? 'see less' : 'see more';
3569
+ } else {
3570
+ chHeadRow2.hidden = true;
3571
+ }
3572
+ channelHeadEl.hidden = false;
3573
+ }
3574
+
3575
+ function renderChannelMembers() {
3576
+ const detail = activeChannel ? channelDetailCache.get(activeChannel) : null;
3577
+ const members = detail?.members || [];
3578
+ if (!chMembersOpen || !members.length) {
3579
+ chMembersEl.hidden = true;
3580
+ chMembersEl.innerHTML = '';
3581
+ return;
3582
+ }
3583
+ chMembersEl.innerHTML = members.map(m =>
3584
+ `<span class="agent" title="${escapeHtml(m.subscribed ? `subscribed ${m.subscribed}` : '')}">${renderAgentName(m.handle)}</span>`
3585
+ ).join('');
3586
+ chMembersEl.hidden = false;
3587
+ }
3588
+
3589
+ chSeeMoreBtn.addEventListener('click', () => {
3590
+ chHeadRow2.classList.toggle('expanded');
3591
+ renderChannelHead();
3592
+ });
3593
+
3594
+ function renderChannelEmptyState(name) {
3595
+ const detail = channelDetailCache.get(name);
3596
+ const theme = detail?.theme?.body || channels.find(c => c.name === name)?.theme_excerpt || '';
3597
+ // The theme IS the pitch — render it at exactly the moment a visitor
3598
+ // decides whether this channel is for them.
3599
+ messagesEl.innerHTML =
3600
+ `<div class="state"><div class="label">No messages yet</div>` +
3601
+ (theme ? `<div class="theme-body">${renderMarkdownInline(theme)}</div>` : '') +
3602
+ `<div>posting subscribes you to this channel</div></div>`;
3603
+ }
3604
+
3605
+ function selectChannel(name) {
3606
+ if (activeChannel === name) return;
3607
+ activeChannel = name;
3608
+ chMembersOpen = false;
3609
+ chHeadRow2.classList.remove('expanded');
3610
+ // A chip switch is a context switch: a stale filter makes a fresh channel
3611
+ // look mysteriously empty, and a pending Board quote would silently
3612
+ // cross-ref into a channel post (CHANNELS_DESIGN.md §8.2).
3613
+ msgFilterInput.value = '';
3614
+ setMessageFilter('', { immediate: true });
3615
+ clearPendingQuote();
3616
+ if (name) markChannelSeen(name);
3617
+ renderChannelChips();
3618
+ renderChannelHead();
3619
+ syncComposerState();
3620
+ resetMessageState();
3621
+ if (name === null) {
3622
+ if (boardMessages.length) {
3623
+ paintAllMessages(boardMessages);
3624
+ } else {
3625
+ messagesEl.innerHTML = `<div class="state"><div class="label">Loading</div>fetching messages from the bucket…</div>`;
3626
+ refreshAll();
3627
+ }
3628
+ return;
3629
+ }
3630
+ const cached = channelMsgCache.get(name);
3631
+ if (cached && cached.length) paintAllMessages(cached);
3632
+ else if (cached) renderChannelEmptyState(name);
3633
+ else messagesEl.innerHTML = `<div class="state"><div class="label">Loading</div>fetching #${escapeHtml(name)}…</div>`;
3634
+ // Fetch immediately — a selection must not wait for the 30s poll tick.
3635
+ refreshActiveChannel();
3636
+ }
3637
+
3638
+ let channelRefreshing = false;
3639
+ async function refreshActiveChannel() {
3640
+ const name = activeChannel;
3641
+ if (!name || channelRefreshing) return;
3642
+ channelRefreshing = true;
3643
+ try {
3644
+ const [msgs, detail] = await Promise.all([
3645
+ fetchChannelMessages(name),
3646
+ fetchChannelDetail(name).catch(() => channelDetailCache.get(name) || null),
3647
+ ]);
3648
+ channelMsgCache.set(name, msgs);
3649
+ if (detail) channelDetailCache.set(name, detail);
3650
+ if (activeChannel !== name) return; // user switched away mid-flight
3651
+ renderChannelHead();
3652
+ if (!msgs.length) {
3653
+ if (!messages.length) renderChannelEmptyState(name);
3654
+ } else if (messagesEl.querySelector('.state') || !messages.length) {
3655
+ resetMessageState();
3656
+ paintAllMessages(msgs);
3657
+ } else {
3658
+ const additions = msgs.filter(m => !knownFilenames.has(m.filename));
3659
+ if (additions.length) {
3660
+ additions.forEach(m => messageMap.set(m.filename, m));
3661
+ additions.sort((a, b) => a.epoch - b.epoch).forEach(m => ingestMessage(m, /* prepend */ true));
3662
+ scrollMessagesTop();
3663
+ }
3664
+ }
3665
+ markChannelSeen(name);
3666
+ renderChannelChips();
3667
+ } catch (e) {
3668
+ if (activeChannel === name && !messages.length) {
3669
+ messagesEl.innerHTML =
3670
+ `<div class="state"><div class="label">Error</div>could not load #${escapeHtml(name)} — retrying on the next poll</div>`;
3671
+ }
3672
+ } finally {
3673
+ channelRefreshing = false;
3674
+ }
3675
+ }
3676
+
3677
+ // ── Create-channel modal (two fields — not the join wizard) ──
3678
+ function syncChannelModalState() {
3679
+ const name = channelNameInput.value.trim();
3680
+ const theme = channelThemeInput.value.trim();
3681
+ const validName = CH_NAME_RE.test(name);
3682
+ channelNameHint.textContent = name && !validName
3683
+ ? 'lowercase letters, digits, hyphens — must start and end alphanumeric'
3684
+ : 'lowercase letters, digits, hyphens';
3685
+ channelNameHint.style.color = name && !validName ? '#b91c1c' : '';
3686
+ channelCreateBtn.disabled = !(validName && theme);
3687
+ channelCreateBtn.textContent = validName ? `Create #${name}` : 'Create channel';
3688
+ }
3689
+ function openChannelModal() {
3690
+ channelNameInput.value = '';
3691
+ channelThemeInput.value = '';
3692
+ channelModalStatus.textContent = '';
3693
+ channelModalStatus.classList.remove('error');
3694
+ syncChannelModalState();
3695
+ channelModal.hidden = false;
3696
+ channelNameInput.focus();
3697
+ }
3698
+ function closeChannelModal() { channelModal.hidden = true; }
3699
+
3700
+ channelNameInput.addEventListener('input', () => {
3701
+ // The # is rendered, not typed — the input takes the slug only.
3702
+ const cleaned = channelNameInput.value.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+/, '');
3703
+ if (cleaned !== channelNameInput.value) channelNameInput.value = cleaned;
3704
+ syncChannelModalState();
3705
+ });
3706
+ channelThemeInput.addEventListener('input', syncChannelModalState);
3707
+ channelModalClose.addEventListener('click', closeChannelModal);
3708
+ channelModal.addEventListener('click', e => { if (e.target === channelModal) closeChannelModal(); });
3709
+ document.addEventListener('keydown', e => {
3710
+ if (e.key === 'Escape' && !channelModal.hidden) closeChannelModal();
3711
+ });
3712
+
3713
+ channelCreateBtn.addEventListener('click', async () => {
3714
+ const name = channelNameInput.value.trim();
3715
+ const body = channelThemeInput.value.trim();
3716
+ if (!CH_NAME_RE.test(name) || !body) return;
3717
+ channelCreateBtn.disabled = true;
3718
+ channelModalStatus.classList.remove('error');
3719
+ channelModalStatus.textContent = 'Creating…';
3720
+ try {
3721
+ const r = await fetchWithTimeout('/api/channels', {
3722
+ method: 'POST',
3723
+ credentials: 'same-origin',
3724
+ headers: { 'Content-Type': 'application/json' },
3725
+ body: JSON.stringify({ name, body }),
3726
+ });
3727
+ if (!r.ok) {
3728
+ let detail = '';
3729
+ try { const p = await r.json(); detail = p?.detail || ''; } catch {}
3730
+ // The backend's verdict, verbatim (409 name taken, 429 creation
3731
+ // limit, …) — no translation layer to drift.
3732
+ throw new Error(detail || `Channel creation failed (HTTP ${r.status}).`);
3733
+ }
3734
+ closeChannelModal();
3735
+ await refreshChannels();
3736
+ selectChannel(name);
3737
+ composerNoticeHtml = `<span class="delivered">✓ created #${escapeHtml(name)} — announced on the Board</span>`;
3738
+ composerNoticeUntil = Date.now() + 5000;
3739
+ syncComposerState();
3740
+ setTimeout(syncComposerState, 5100);
3741
+ } catch (err) {
3742
+ channelModalStatus.classList.add('error');
3743
+ channelModalStatus.textContent = err.message || 'Channel creation failed.';
3744
+ } finally {
3745
+ syncChannelModalState();
3746
+ }
3747
+ });
3748
+
3749
  const joinAgentName = document.getElementById('joinAgentName');
3750
  const joinNameSlot = document.getElementById('joinNameSlot');
3751
  const joinProfileSlot = document.getElementById('joinProfileSlot');
 
3863
  if (cached?.messages?.length) {
3864
  messagesEl.innerHTML = '';
3865
  paintAllMessages(cached.messages);
3866
+ boardMessages = messages.slice();
3867
  initialLoaded = true; painted = true;
3868
  if (cached.leaderboard?.length) renderLeaderboard(cached.leaderboard);
3869
  lbStatus.textContent = 'cached';
 
3875
  if (freshAgents.status === 'fulfilled') ingestAgents(freshAgents.value);
3876
  if (freshMsgs.status === 'fulfilled') {
3877
  const fresh = freshMsgs.value;
3878
+ boardMessages = fresh;
3879
  if (painted) {
3880
  const additions = fresh.filter(m => !knownFilenames.has(m.filename));
3881
  additions.forEach(m => messageMap.set(m.filename, m));
 
4004
  // the section stays hidden until ≥1 trace exists) ──
4005
  const STATS_URL = '/api/stats';
4006
  const TRACES_URL = '/api/traces?expand=true&limit=50';
4007
+ const CHANNELS_URL = '/api/channels';
4008
 
4009
  function fmtTokens(n) {
4010
  if (n == null) return '—';
 
4224
  await refreshAll();
4225
  if (CFG.wiki_enabled) { try { await refreshWiki(); } catch {} }
4226
  refreshBackendStatus();
4227
+ // One cheap summaries call feeds the chips + activity dots; only the
4228
+ // feed on screen gets message fetches (CHANNELS_DESIGN.md §8.4).
4229
+ refreshChannels();
4230
+ if (activeChannel) refreshActiveChannel();
4231
  }
4232
  }
4233
 
 
4237
  loadConfig().then(() => {
4238
  refreshMe();
4239
  refreshBackendStatus();
4240
+ refreshChannels();
4241
  initialLoad().then(() => { if (initialLoaded) pollLoop(); });
4242
  });
4243
  </script>