From 0581dc34856db3dd9abc36200f99e98cd0f0dc93 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Mon, 24 Aug 2026 15:35:51 +0700 Subject: [PATCH] feat(frontend): messages orbital-belt & moderation verdict-hub scenes --- .../src/app/(dashboard)/messages/view.tsx | 451 +++++++++------- .../src/app/(dashboard)/moderation/view.tsx | 503 ++++++++++-------- 2 files changed, 536 insertions(+), 418 deletions(-) diff --git a/services/frontend/src/app/(dashboard)/messages/view.tsx b/services/frontend/src/app/(dashboard)/messages/view.tsx index 093f1871..dacccbc9 100644 --- a/services/frontend/src/app/(dashboard)/messages/view.tsx +++ b/services/frontend/src/app/(dashboard)/messages/view.tsx @@ -1,5 +1,11 @@ "use client"; +/** + * Messages scene — the live feed becomes an orbital belt of nodes on the + * stage; the functional console (picker/search/modes) floats top-left, + * the stream itself is a translucent dossier column, and a selected + * message opens its inspection dossier bottom-center. + */ import { AlertTriangle, Calendar, @@ -15,20 +21,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ActivityHeatmap } from "@/components/ActivityHeatmap"; import { useAmbient } from "@/components/ambient/ambient-context"; import { EditHistory } from "@/components/EditHistory"; -import { - Avatar, - Badge, - GlassPanel, - Input, - Skeleton, -} from "@/components/primitives"; -import { - EmptyState, - ErrorState, - SectionHeader, - SkeletonRows, -} from "@/components/shared"; +import { Avatar, Badge, Skeleton } from "@/components/primitives"; +import { EmptyState, ErrorState, SkeletonRows } from "@/components/shared"; import { GuildChannelPicker } from "@/components/shared/guild-picker"; +import { + useSceneFocusSetter, + useScenePublish, +} from "@/components/shell/scene-graph-context"; import { useLoadMore, useMessageActivity, @@ -42,6 +41,7 @@ import { useSemanticSearch, } from "@/hooks"; import { aiTone } from "@/lib/ai-status"; +import type { ConstellationGraph } from "@/lib/constellation/graph"; import { formatBytes, formatDuration, @@ -59,6 +59,24 @@ import type { import { staggerDelay } from "@/lib/utils"; import { useWebSocket } from "@/lib/ws/context"; +/** Recent messages → orbital belt (chained edges give the belt shape). */ +function beltGraph(list: MessageRecord[]): ConstellationGraph { + const recent = list.slice(0, 36); + const nodes = recent.map((m, i) => ({ + id: `msg:${m.id}`, + label: m.username, + kind: + m.ai_status === "flagged" ? ("flagged" as const) : ("message" as const), + value: Math.max(0.15, 1 - i / Math.max(1, recent.length)), + href: undefined, + })); + const edges = nodes.slice(0, -1).map((n, i) => ({ + source: n.id, + target: nodes[i + 1]?.id ?? n.id, + })); + return { nodes, edges }; +} + export function MessagesView({ initialGuilds, initialGuildId, @@ -80,15 +98,11 @@ export function MessagesView({ const [channelId, setChannelId] = useState(null); const [selected, setSelected] = useState(null); const [query, setQuery] = useState(""); - // Search mode: "exact" (substring match over captured messages) or - // "semantic" (vector similarity over the persistent Qdrant archive). const [semanticMode, setSemanticMode] = useState(false); - // feed | timeline: "timeline" groups messages into date-grouped cards. const [viewMode, setViewMode] = useState<"feed" | "timeline">("feed"); - // Guard against loading the entire history on a long scroll: cap how many - // older pages we append. Each page is 50 messages (backend limit default). const MAX_OLDER_PAGES = 10; const [loadedPages, setLoadedPages] = useState(0); + const [showIntel, setShowIntel] = useState(false); const { data: messages, @@ -99,11 +113,7 @@ export function MessagesView({ channelId ?? undefined, initialMessages ?? undefined, ); - // Stream history one message per WS frame (replaces the 50-row batched fetch). - // Drives snapshots into the SWR list above as they arrive; falls back to the - // SSR `initialMessages` seed if WS is unavailable. useMessagesStream(ws, guildId ?? "", channelId ?? undefined); - // Cursor to the next (older) page + whether more history exists. const { data: pageInfo } = useMessagesHasMore( guildId ?? "", channelId ?? undefined, @@ -124,10 +134,12 @@ export function MessagesView({ const edits = useRecentEdits(50, undefined, initialEdits); const detail = useMessageDetail(selected); const ambient = useAmbient(); + const publish = useScenePublish(); + const setFocus = useSceneFocusSetter(); + + const scrollRef = useRef(null); + const nearBottomRef = useRef(true); - // Fetch the next (older) page via cursor and bump the loaded-page counter. - // Older messages prepend at the top, so preserve the viewport by offsetting - // scrollTop by the height added above (Discord keeps your place while loading). const loadOlder = useCallback(async () => { if (!guildId || !hasMore || loadedPages >= MAX_OLDER_PAGES) return; const el = scrollRef.current; @@ -152,21 +164,32 @@ export function MessagesView({ const searching = query.trim().length >= 2 && !semanticMode; const semanticSearching = query.trim().length >= 2 && semanticMode; const list = searching ? (search.data ?? []) : (messages ?? []); - // Discord-style order: oldest at the top, newest at the bottom. The backend - // returns DESC (newest first); reverse so the feed reads top→bottom like DC. const display = useMemo(() => [...list].reverse(), [list]); - // Timeline mode: inject date-separator headers above the first message of - // each day. Messages are sorted oldest→newest (display is reversed), so a - // date change means a new group. Produces an array of either "date" or "msg" - // nodes so the render loop can switch easily. + const graph = useMemo( + () => (searching ? { nodes: [], edges: [] } : beltGraph(list)), + [list, searching], + ); + + useEffect(() => { + publish({ graph, focus: selected ? `msg:${selected}` : null }); + }, [graph, selected, publish]); + + useEffect( + () => () => { + publish({ graph: { nodes: [], edges: [] }, focus: null }); + setFocus(null); + }, + [publish, setFocus], + ); + const timelineNodes = useMemo(() => { if (viewMode !== "timeline") return null; const out: Array< | { type: "date"; label: string; iso: string } | { type: "msg"; m: (typeof display)[number] } > = []; - let prev = ""; + let prevDate = ""; for (const m of display) { const d = new Date(m.created_at).toLocaleDateString(undefined, { weekday: "short", @@ -174,24 +197,15 @@ export function MessagesView({ day: "numeric", }); const iso = new Date(m.created_at).toISOString().slice(0, 10); - if (d !== prev) { + if (d !== prevDate) { out.push({ type: "date", label: d, iso }); - prev = d; + prevDate = d; } out.push({ type: "msg", m }); } return out; }, [display, viewMode]); - // Ref to the scroll container so we can manage scroll position like Discord: - // open at the bottom (newest), keep the viewport stable when prepending older - // messages at the top, and follow new live messages only when already near - // the bottom. - const scrollRef = useRef(null); - const nearBottomRef = useRef(true); - - // Scroll to the bottom on the first load / when switching guild-channel, so - // the newest messages are visible (Discord behaviour). const firstLoadRef = useRef(true); useEffect(() => { if (firstLoadRef.current && display.length > 0) { @@ -201,8 +215,6 @@ export function MessagesView({ } }, [display.length]); - // When a new live message lands (list grows, still searching off), follow it - // to the bottom only if the user was already near the bottom. const prevLen = useRef(list.length); useEffect(() => { if (searching) return; @@ -215,8 +227,12 @@ export function MessagesView({ }, [list.length, searching]); return ( -
- +
+ {/* Console whisper — top-left */} +
-
- - setQuery(e.target.value)} - /> -
- - - - -
- {semanticSearching && ( - - - {semantic.data?.length ?? 0} matches - - } +
+
+ + setQuery(e.target.value)} + placeholder="Search messages…" + className="h-9 w-full rounded-full border border-[var(--color-hairline)] bg-[var(--color-canvas)]/70 pl-9 pr-3 font-mono text-xs text-[var(--color-ink)] backdrop-blur-md outline-none placeholder:text-[var(--color-ink-faint)] focus:border-[var(--color-signal)]" /> - {semantic.isLoading ? ( - - ) : semantic.data && semantic.data.length > 0 ? ( -
- {semantic.data.map((r, i) => ( -
-
-
- - {(r.score * 100).toFixed(0)}% - - - {formatRelativeTime(r.created_at)} - -
-
- {r.content} -
-
-
- ))} -
- ) : ( - } - title="No semantic matches" - description="Try different wording — semantic search finds meaning, not exact text." - /> - )} - - )} - - - - {list.length} shown - +
+ + +
+
+ + {/* Stream dossier — right column */} +
+
+ + {searching ? `“${query}”` : "live stream"} + + + {list.length} shown + +
+ +
+ {semanticSearching ? ( + + ) : error && !messages ? ( ) : isLoading && !messages ? ( ) : list.length === 0 ? ( } + icon={} title="No messages" description="Pick a guild to begin, or run a search." /> ) : ( -
+
{!searching && ( -
+
{loadMore.isPending ? ( - + Loading older… @@ -348,19 +326,19 @@ export function MessagesView({ ) : loadedPages >= MAX_OLDER_PAGES ? ( - + capped at {MAX_OLDER_PAGES} older pages · use search for more ) : ( messages && messages.length > 0 && ( - + beginning of history ) @@ -369,11 +347,9 @@ export function MessagesView({ )}
{ const el = e.currentTarget; - // Track whether the user is near the bottom (to follow live - // messages) and auto-load older messages when scrolled to top. nearBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 120; if (searching || !hasMore || loadMore.isPending) return; @@ -384,11 +360,11 @@ export function MessagesView({ }} > {viewMode === "timeline" && timelineNodes - ? timelineNodes.map((node, _i) => + ? timelineNodes.map((node) => node.type === "date" ? (
{node.label} @@ -402,7 +378,7 @@ export function MessagesView({ /> ), ) - : display.map((m, _i) => ( + : display.map((m) => (
)} - +
+
- - - {!selected ? ( - + {error && !messages ? ( + + ) : isLoading && !messages ? ( + + ) : display.length === 0 ? ( + + ) : ( + display.map((m) => ( + - ) : detail.loading ? ( + )) + )} + + + {/* Inspection dossier — bottom-center */} + {selected ? ( + + ) : null} + + {/* Intel strip — bottom-left */} +
+ + {showIntel ? ( +
+ {activity.data && activity.data.length > 0 ? ( + + ) : null} + {edits.data ? : null} +
+ ) : null}
+
+ ); +} - {activity.data && activity.data.length > 0 && ( - - )} - - {edits.data && } +function SemanticResults({ + semantic, +}: { + semantic: ReturnType; +}) { + if (semantic.isLoading) return ; + if (!semantic.data || semantic.data.length === 0) { + return ( + } + title="No semantic matches" + description="Try different wording — semantic search finds meaning, not exact text." + /> + ); + } + return ( +
+ {semantic.data.map((r, i) => ( +
+
+ + {(r.score * 100).toFixed(0)}% + + + {formatRelativeTime(r.created_at)} + +
+

+ {r.content} +

+
+ ))}
); } @@ -493,10 +556,12 @@ function MessageDetail({ return (
- +
-
{m.username}
-
+
+ {m.username} +
+
{getMessageChannelLabel(m)} · {formatRelativeTime(m.created_at)}
@@ -508,21 +573,21 @@ function MessageDetail({
-
+
{renderMessageContent(m.edited_content ?? m.content, m.metadata) || "(no text)"}
- {m.ai_analysis && ( + {m.ai_analysis ? (
AI analysis
-
+
{m.ai_analysis}
- )} + ) : null} - {(flags.length > 0 || cats.length > 0) && ( + {flags.length > 0 || cats.length > 0 ? (
{flags.map((f) => ( @@ -535,9 +600,9 @@ function MessageDetail({ ))}
- )} + ) : null} - {attachments.length > 0 && ( + {attachments.length > 0 ? (
Attachments ({attachments.length}) @@ -549,23 +614,22 @@ function MessageDetail({ href={a.discord_url ?? a.uploaded_url ?? "#"} target="_blank" rel="noreferrer" - className="flex items-center gap-2 rounded-[10px] border border-hairline bg-white/5 px-3 py-2 text-xs text-ink-soft hover:text-ink" + className="flex items-center gap-2 rounded-xl border border-[var(--color-hairline)] px-3 py-2 font-mono text-xs text-[var(--color-ink-soft)] hover:text-[var(--color-ink)]" > {a.filename} - + {formatBytes(a.size)} ))}
- )} + ) : null}
); } -/** Single message card used by both the live feed and the date-grouped timeline. */ function MessageRow({ m, selected, @@ -577,31 +641,32 @@ function MessageRow({ }) { return ( -
+
+ -
- {(actions ?? []).map((a, i) => ( - - ))} - {(actions ?? []).length === 0 && ( -
- No matching actions. -
- )} +
+ {Object.entries(byAction).map(([k, v]) => { + const count = typeof v === "number" ? v : null; + return ( +

+ {ACTION_ICON[k as ModerationActionType]} + + {ACTION_LABEL[k as ModerationActionType] ?? k} + + {count !== null ? {count} : null} +

+ ); + })}
- +
+ + + {/* Intel dossier — right */} +
+ + {intelOpen ? ( +
+ {trends ? : null} + {coverage ? : null} + {domains ? : null} + {hourly ? : null} + {channels ? : null} + +
+ ) : null}
+ + {/* Live feed ribbon — bottom */} +
+
+ + + action log + +
+ + +
+
+
+ +
+
); } +function ActionRows({ + actions, + liveCount, +}: { + actions: ModerationAction[]; + liveCount: number; +}) { + if (actions.length === 0 && liveCount === 0) { + return ( +
+ No matching actions. +
+ ); + } + return ( +
+ {actions.map((a, i) => ( + + ))} +
+ ); +} + +function Whisper({ + label, + value, + tone, +}: { + label: string; + value: string; + tone: "ink" | "signal" | "vermilion" | "amber"; +}) { + const color = + tone === "vermilion" + ? "text-vermilion" + : tone === "amber" + ? "text-amber" + : tone === "signal" + ? "text-signal" + : "text-ink"; + return ( +

+ + {label} + + {value} +

+ ); +} + function ActionRow({ a, index = 0 }: { a: ModerationAction; index?: number }) { const tone = a.status === "executed" @@ -345,8 +398,6 @@ function ActionRow({ a, index = 0 }: { a: ModerationAction; index?: number }) { const icon = ACTION_ICON[a.action_type] ?? ( ); - // Map moderation severity → design-system tone (reuse aiTone with a - // severity→status projection so "none" reads as clean/signal). const severityTone = a.severity == null ? null @@ -359,8 +410,8 @@ function ActionRow({ a, index = 0 }: { a: ModerationAction; index?: number }) { ); return (
- + {a.username ?? "unknown"} {a.status} - + {formatRelativeTime(a.created_at)}
- {a.reason && ( -
“{a.reason}”
- )} - {severityTone && a.severity && ( + {a.reason ? ( +
+ “{a.reason}” +
+ ) : null} + {severityTone && a.severity ? (
{a.severity} - {a.confidence != null && ( - + {a.confidence != null ? ( + conf {(a.confidence * 100).toFixed(0)}% - )} + ) : null}
- )} + ) : null} {a.flags?.length ? (
{a.flags.slice(0, 6).map((f) => ( @@ -400,24 +453,24 @@ function ActionRow({ a, index = 0 }: { a: ModerationAction; index?: number }) {
) : null} {a.evidence?.length ? ( -
+
“{a.evidence[0]}”
) : null} - {a.executed_by && ( -
+ {a.executed_by ? ( +
by {a.executed_by} {a.executed_at ? ` · ${formatRelativeTime(a.executed_at)}` : ""}
- )} - {a.content && ( -
+ ) : null} + {a.content ? ( +
{a.content}
- )} - {a.error && ( + ) : null} + {a.error ? (
{a.error}
- )} + ) : null}
);