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 { 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<string | null>(null);
const [sending, setSending] = useState(false);
const [isFetching, setIsFetching] = useState(false);
const [lastPollAt, setLastPollAt] = useState<number | null>(null);
const scrollRef = useRef<HTMLDivElement>(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 (
<Card className="flex h-[70vh] flex-col">
<CardHeader className="flex-row items-center justify-between border-b py-3">
<CardTitle className="text-base font-medium">
<span className="text-muted-foreground">#</span>
{topicName}
</CardTitle>
<div className="flex items-center gap-2 text-xs">
<Badge variant="outline" className="font-mono">
{meshSlug}
</Badge>
<span className="text-muted-foreground">
key expires {fmtTime(apiKeyExpiresAt)}
<div className="flex h-[70vh] flex-col overflow-hidden rounded-[var(--cm-radius-lg)] border border-[var(--cm-border)] bg-[var(--cm-bg)]">
{/* Header — mono strip, clay-pulse dot, metadata right */}
<div
className="flex items-center justify-between border-b border-[var(--cm-border)] bg-[var(--cm-bg-elevated)]/60 px-4 py-3"
style={monoStyle}
>
<div className="flex items-center gap-3">
<span
className={
"inline-block h-2 w-2 rounded-full " +
(isFetching
? "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>
</div>
</CardHeader>
<CardContent
ref={scrollRef}
className="flex-1 overflow-y-auto p-4"
>
{/* Message stream */}
<div ref={scrollRef} className="flex-1 overflow-y-auto px-4 py-4">
{messages.length === 0 ? (
<p className="text-muted-foreground py-8 text-center text-sm">
No messages yet. Be the first.
<p
className="py-12 text-center text-[11px] text-[var(--cm-fg-tertiary)]"
style={monoStyle}
>
no envelopes on this topic yet
</p>
) : (
<ol className="flex flex-col gap-3">
<ol className="flex flex-col gap-4">
{messages.map((m) => (
<li key={m.id} className="flex flex-col gap-0.5">
<div className="text-muted-foreground flex items-baseline gap-2 text-xs">
<span className="font-medium text-foreground">
<li key={m.id} className="flex flex-col gap-1">
<div
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)}
</span>
<span className="font-mono">
{m.senderPubkey.slice(0, 6)}
<span className="text-[var(--cm-fg-tertiary)]">
{m.senderPubkey.slice(0, 8)}
</span>
<span className="text-[var(--cm-fg-tertiary)]">
{fmtTime(m.createdAt)}
</span>
<span>{fmtTime(m.createdAt)}</span>
</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)}
</p>
</li>
))}
</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 ? (
<p className="mb-2 text-xs text-destructive">{error}</p>
<p
className="mb-2 text-[10px] text-[#c46686]"
style={monoStyle}
>
error · {error}
</p>
) : null}
<form
className="flex gap-2"
@@ -209,9 +250,9 @@ export function TopicChatPanel({
<textarea
value={draft}
onChange={(e) => 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({
}}
/>
<Button type="submit" disabled={sending || !draft.trim()}>
{sending ? "…" : "Send"}
{sending ? "…" : "send"}
</Button>
</form>
</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>
);
}