From c801afd2ab5db04c14bda85c0c3dffcee42131be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= <35082514+alezmad@users.noreply.github.com> Date: Sat, 2 May 2026 16:22:22 +0100 Subject: [PATCH] style(web): topic chat panel matches mesh-panel idiom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit against peer-graph-panel, live-stream-panel, state-timeline-panel, and resource-panel showed the chat used generic shadcn Card chrome instead of the established panel pattern. Refactor swaps the wrapper to the canonical idiom: - rounded-[var(--cm-radius-lg)] + border-[var(--cm-border)] + bg-[var(--cm-bg)] - mono header strip with clay-pulse fetch dot, 11px label, 10px metadata - mono 9px footer status bar (mesh slug · poll cadence · key expiry) - Anthropic Mono via var(--cm-font-mono) on chrome, sans on message body - compose textarea uses cm-bg-elevated + cm-border-hover focus state - error line in cm-fig (#c46686) instead of generic destructive No behavior change — only chrome. Polling, send path, decode logic unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../web/src/modules/mesh/topic-chat-panel.tsx | 129 +++++++++++++----- 1 file changed, 93 insertions(+), 36 deletions(-) diff --git a/apps/web/src/modules/mesh/topic-chat-panel.tsx b/apps/web/src/modules/mesh/topic-chat-panel.tsx index 5256a84..012c83f 100644 --- a/apps/web/src/modules/mesh/topic-chat-panel.tsx +++ b/apps/web/src/modules/mesh/topic-chat-panel.tsx @@ -2,9 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { Badge } from "@turbostarter/ui-web/badge"; import { Button } from "@turbostarter/ui-web/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@turbostarter/ui-web/card"; const POLL_INTERVAL_MS = 5000; @@ -71,6 +69,16 @@ function fmtTime(iso: string): string { } } +function fmtRelative(iso: string): string { + const ms = Date.now() - new Date(iso).getTime(); + if (ms < 60_000) return `${Math.floor(ms / 1000)}s ago`; + if (ms < 3_600_000) return `${Math.floor(ms / 60_000)}m ago`; + if (ms < 86_400_000) return `${Math.floor(ms / 3_600_000)}h ago`; + return new Date(iso).toLocaleDateString(); +} + +const monoStyle = { fontFamily: "var(--cm-font-mono)" } as const; + export function TopicChatPanel({ topicName, meshSlug, @@ -81,6 +89,8 @@ export function TopicChatPanel({ const [draft, setDraft] = useState(""); const [error, setError] = useState(null); const [sending, setSending] = useState(false); + const [isFetching, setIsFetching] = useState(false); + const [lastPollAt, setLastPollAt] = useState(null); const scrollRef = useRef(null); const headers = useMemo( @@ -92,6 +102,7 @@ export function TopicChatPanel({ ); const refresh = useCallback(async () => { + setIsFetching(true); try { const res = await fetch( `/api/v1/topics/${encodeURIComponent(topicName)}/messages?limit=100`, @@ -104,8 +115,11 @@ export function TopicChatPanel({ const json = (await res.json()) as { messages: TopicMessage[] }; setMessages(json.messages.slice().reverse()); setError(null); + setLastPollAt(Date.now()); } catch (e) { setError((e as Error).message); + } finally { + setIsFetching(false); } }, [headers, topicName]); @@ -148,56 +162,83 @@ export function TopicChatPanel({ } }; + const secondsSincePoll = lastPollAt + ? Math.max(0, Math.floor((Date.now() - lastPollAt) / 1000)) + : null; + return ( - - - - # - {topicName} - -
- - {meshSlug} - - - key expires {fmtTime(apiKeyExpiresAt)} +
+ {/* Header — mono strip, clay-pulse dot, metadata right */} +
+
+ + + #{topicName}
- + + {messages.length} msg ·{" "} + {isFetching + ? "polling…" + : `${secondsSincePoll ?? "—"}s ago`} + +
- + {/* Message stream */} +
{messages.length === 0 ? ( -

- No messages yet. Be the first. +

+ no envelopes on this topic yet

) : ( -
    +
      {messages.map((m) => ( -
    1. -
      - +
    2. +
      + {m.senderName || m.senderPubkey.slice(0, 8)} - - {m.senderPubkey.slice(0, 6)}… + + {m.senderPubkey.slice(0, 8)} + + + {fmtTime(m.createdAt)} - {fmtTime(m.createdAt)}
      -

      +

      {decodeIncoming(m.ciphertext)}

    3. ))}
    )} - +
-
+ {/* Compose */} +
{error ? ( -

{error}

+

+ error · {error} +

) : null}
setDraft(e.target.value)} - placeholder={`Message #${topicName}…`} + placeholder={`message #${topicName}…`} rows={1} - className="flex-1 resize-none rounded-md border bg-transparent px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring" + className="flex-1 resize-none rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg)] px-3 py-2 text-sm text-[var(--cm-fg)] placeholder:text-[var(--cm-fg-tertiary)] focus:border-[var(--cm-border-hover)] focus:outline-none" onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); @@ -220,10 +261,26 @@ export function TopicChatPanel({ }} />
- + + {/* Status footer — 9px mono, matches peer-graph + state-timeline footers */} +
+ + + mesh · {meshSlug} + + polling every {POLL_INTERVAL_MS / 1000}s + key valid until {fmtTime(apiKeyExpiresAt)} + + v0.2.0 · plaintext base64 · per-topic crypto in v0.3.0 + +
+
); }