style(web): topic chat panel matches mesh-panel idiom
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled

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) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-05-02 16:22:22 +01:00
parent b60daff886
commit c801afd2ab

View File

@@ -2,9 +2,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Badge } from "@turbostarter/ui-web/badge";
import { Button } from "@turbostarter/ui-web/button"; import { Button } from "@turbostarter/ui-web/button";
import { Card, CardContent, CardHeader, CardTitle } from "@turbostarter/ui-web/card";
const POLL_INTERVAL_MS = 5000; 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({ export function TopicChatPanel({
topicName, topicName,
meshSlug, meshSlug,
@@ -81,6 +89,8 @@ export function TopicChatPanel({
const [draft, setDraft] = useState(""); const [draft, setDraft] = useState("");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const [isFetching, setIsFetching] = useState(false);
const [lastPollAt, setLastPollAt] = useState<number | null>(null);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const headers = useMemo( const headers = useMemo(
@@ -92,6 +102,7 @@ export function TopicChatPanel({
); );
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
setIsFetching(true);
try { try {
const res = await fetch( const res = await fetch(
`/api/v1/topics/${encodeURIComponent(topicName)}/messages?limit=100`, `/api/v1/topics/${encodeURIComponent(topicName)}/messages?limit=100`,
@@ -104,8 +115,11 @@ export function TopicChatPanel({
const json = (await res.json()) as { messages: TopicMessage[] }; const json = (await res.json()) as { messages: TopicMessage[] };
setMessages(json.messages.slice().reverse()); setMessages(json.messages.slice().reverse());
setError(null); setError(null);
setLastPollAt(Date.now());
} catch (e) { } catch (e) {
setError((e as Error).message); setError((e as Error).message);
} finally {
setIsFetching(false);
} }
}, [headers, topicName]); }, [headers, topicName]);
@@ -148,56 +162,83 @@ export function TopicChatPanel({
} }
}; };
const secondsSincePoll = lastPollAt
? Math.max(0, Math.floor((Date.now() - lastPollAt) / 1000))
: null;
return ( return (
<Card className="flex h-[70vh] flex-col"> <div className="flex h-[70vh] flex-col overflow-hidden rounded-[var(--cm-radius-lg)] border border-[var(--cm-border)] bg-[var(--cm-bg)]">
<CardHeader className="flex-row items-center justify-between border-b py-3"> {/* Header — mono strip, clay-pulse dot, metadata right */}
<CardTitle className="text-base font-medium"> <div
<span className="text-muted-foreground">#</span> className="flex items-center justify-between border-b border-[var(--cm-border)] bg-[var(--cm-bg-elevated)]/60 px-4 py-3"
{topicName} style={monoStyle}
</CardTitle> >
<div className="flex items-center gap-2 text-xs"> <div className="flex items-center gap-3">
<Badge variant="outline" className="font-mono"> <span
{meshSlug} className={
</Badge> "inline-block h-2 w-2 rounded-full " +
<span className="text-muted-foreground"> (isFetching
key expires {fmtTime(apiKeyExpiresAt)} ? "bg-[var(--cm-clay)] animate-pulse"
: "bg-emerald-500")
}
/>
<span className="text-[11px] text-[var(--cm-fg-secondary)]">
#{topicName}
</span>
</div>
<span className="text-[10px] text-[var(--cm-fg-tertiary)]">
{messages.length} msg ·{" "}
{isFetching
? "polling…"
: `${secondsSincePoll ?? "—"}s ago`}
</span> </span>
</div> </div>
</CardHeader>
<CardContent {/* Message stream */}
ref={scrollRef} <div ref={scrollRef} className="flex-1 overflow-y-auto px-4 py-4">
className="flex-1 overflow-y-auto p-4"
>
{messages.length === 0 ? ( {messages.length === 0 ? (
<p className="text-muted-foreground py-8 text-center text-sm"> <p
No messages yet. Be the first. className="py-12 text-center text-[11px] text-[var(--cm-fg-tertiary)]"
style={monoStyle}
>
no envelopes on this topic yet
</p> </p>
) : ( ) : (
<ol className="flex flex-col gap-3"> <ol className="flex flex-col gap-4">
{messages.map((m) => ( {messages.map((m) => (
<li key={m.id} className="flex flex-col gap-0.5"> <li key={m.id} className="flex flex-col gap-1">
<div className="text-muted-foreground flex items-baseline gap-2 text-xs"> <div
<span className="font-medium text-foreground"> className="flex items-baseline gap-2 text-[10px]"
style={monoStyle}
>
<span className="text-[var(--cm-fg)] font-medium">
{m.senderName || m.senderPubkey.slice(0, 8)} {m.senderName || m.senderPubkey.slice(0, 8)}
</span> </span>
<span className="font-mono"> <span className="text-[var(--cm-fg-tertiary)]">
{m.senderPubkey.slice(0, 6)} {m.senderPubkey.slice(0, 8)}
</span>
<span className="text-[var(--cm-fg-tertiary)]">
{fmtTime(m.createdAt)}
</span> </span>
<span>{fmtTime(m.createdAt)}</span>
</div> </div>
<p className="text-sm whitespace-pre-wrap break-words"> <p className="text-[var(--cm-fg)] text-sm leading-relaxed whitespace-pre-wrap break-words">
{decodeIncoming(m.ciphertext)} {decodeIncoming(m.ciphertext)}
</p> </p>
</li> </li>
))} ))}
</ol> </ol>
)} )}
</CardContent> </div>
<div className="border-t p-3"> {/* Compose */}
<div className="border-t border-[var(--cm-border)] bg-[var(--cm-bg-elevated)]/30 p-3">
{error ? ( {error ? (
<p className="mb-2 text-xs text-destructive">{error}</p> <p
className="mb-2 text-[10px] text-[#c46686]"
style={monoStyle}
>
error · {error}
</p>
) : null} ) : null}
<form <form
className="flex gap-2" className="flex gap-2"
@@ -209,9 +250,9 @@ export function TopicChatPanel({
<textarea <textarea
value={draft} value={draft}
onChange={(e) => setDraft(e.target.value)} onChange={(e) => setDraft(e.target.value)}
placeholder={`Message #${topicName}`} placeholder={`message #${topicName}`}
rows={1} 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) => { onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) { if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
@@ -220,10 +261,26 @@ export function TopicChatPanel({
}} }}
/> />
<Button type="submit" disabled={sending || !draft.trim()}> <Button type="submit" disabled={sending || !draft.trim()}>
{sending ? "…" : "Send"} {sending ? "…" : "send"}
</Button> </Button>
</form> </form>
</div> </div>
</Card>
{/* Status footer — 9px mono, matches peer-graph + state-timeline footers */}
<div
className="flex flex-wrap items-center gap-x-5 gap-y-1 border-t border-[var(--cm-border)] bg-[var(--cm-bg-elevated)]/30 px-4 py-2 text-[9px] text-[var(--cm-fg-tertiary)]"
style={monoStyle}
>
<span className="flex items-center gap-1.5">
<span className="inline-block h-1.5 w-1.5 rounded-full bg-[var(--cm-clay)]" />
mesh · {meshSlug}
</span>
<span>polling every {POLL_INTERVAL_MS / 1000}s</span>
<span>key valid until {fmtTime(apiKeyExpiresAt)}</span>
<span className="ml-auto">
v0.2.0 · plaintext base64 · per-topic crypto in v0.3.0
</span>
</div>
</div>
); );
} }