revert(frontend): kembalikan shell usable — hapus total eksperimen constellation (three/d3-force dihapus)
This commit is contained in:
@@ -1,11 +1,5 @@
|
||||
"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,
|
||||
@@ -21,13 +15,20 @@ 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, 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";
|
||||
Avatar,
|
||||
Badge,
|
||||
GlassPanel,
|
||||
Input,
|
||||
Skeleton,
|
||||
} from "@/components/primitives";
|
||||
import {
|
||||
EmptyState,
|
||||
ErrorState,
|
||||
SectionHeader,
|
||||
SkeletonRows,
|
||||
} from "@/components/shared";
|
||||
import { GuildChannelPicker } from "@/components/shared/guild-picker";
|
||||
import {
|
||||
useLoadMore,
|
||||
useMessageActivity,
|
||||
@@ -41,7 +42,6 @@ import {
|
||||
useSemanticSearch,
|
||||
} from "@/hooks";
|
||||
import { aiTone } from "@/lib/ai-status";
|
||||
import type { ConstellationGraph } from "@/lib/constellation/graph";
|
||||
import {
|
||||
formatBytes,
|
||||
formatDuration,
|
||||
@@ -59,24 +59,6 @@ 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,
|
||||
@@ -98,11 +80,15 @@ export function MessagesView({
|
||||
const [channelId, setChannelId] = useState<string | null>(null);
|
||||
const [selected, setSelected] = useState<string | null>(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,
|
||||
@@ -113,7 +99,11 @@ 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,
|
||||
@@ -134,12 +124,10 @@ 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<HTMLDivElement>(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;
|
||||
@@ -164,32 +152,21 @@ 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]);
|
||||
|
||||
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],
|
||||
);
|
||||
|
||||
// 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 timelineNodes = useMemo(() => {
|
||||
if (viewMode !== "timeline") return null;
|
||||
const out: Array<
|
||||
| { type: "date"; label: string; iso: string }
|
||||
| { type: "msg"; m: (typeof display)[number] }
|
||||
> = [];
|
||||
let prevDate = "";
|
||||
let prev = "";
|
||||
for (const m of display) {
|
||||
const d = new Date(m.created_at).toLocaleDateString(undefined, {
|
||||
weekday: "short",
|
||||
@@ -197,15 +174,24 @@ export function MessagesView({
|
||||
day: "numeric",
|
||||
});
|
||||
const iso = new Date(m.created_at).toISOString().slice(0, 10);
|
||||
if (d !== prevDate) {
|
||||
if (d !== prev) {
|
||||
out.push({ type: "date", label: d, iso });
|
||||
prevDate = d;
|
||||
prev = 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<HTMLDivElement>(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) {
|
||||
@@ -215,6 +201,8 @@ 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;
|
||||
@@ -227,12 +215,8 @@ export function MessagesView({
|
||||
}, [list.length, searching]);
|
||||
|
||||
return (
|
||||
<div className="min-h-full">
|
||||
{/* Console whisper — top-left */}
|
||||
<section
|
||||
className="pointer-events-auto absolute left-5 top-16 z-20 w-[min(22rem,90vw)] space-y-2"
|
||||
aria-label="Stream controls"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<GlassPanel className="flex flex-wrap items-center gap-3">
|
||||
<GuildChannelPicker
|
||||
mode="text"
|
||||
guildsInitial={initialGuilds}
|
||||
@@ -246,79 +230,117 @@ export function MessagesView({
|
||||
firstLoadRef.current = true;
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[var(--color-ink-faint)]" />
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => 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)]"
|
||||
<div className="relative ml-auto w-full sm:w-64">
|
||||
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-ink-faint" />
|
||||
<Input
|
||||
className="pl-9"
|
||||
placeholder="Search messages…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSemanticMode((v) => !v)}
|
||||
className={`rounded-full border px-3 py-1.5 text-xs transition-colors ${
|
||||
semanticMode
|
||||
? "border-signal/40 bg-signal/10 text-signal"
|
||||
: "border-hairline bg-white/[0.03] text-ink-soft hover:bg-white/[0.06]"
|
||||
}`}
|
||||
title="Toggle semantic (vector) search over the message archive"
|
||||
>
|
||||
{semanticMode ? "Semantic" : "Exact"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setViewMode((v) => (v === "feed" ? "timeline" : "feed"))
|
||||
}
|
||||
className={`rounded-full border px-3 py-1.5 text-xs transition-colors ${
|
||||
viewMode === "timeline"
|
||||
? "border-signal/40 bg-signal/10 text-signal"
|
||||
: "border-hairline bg-white/[0.03] text-ink-soft hover:bg-white/[0.06]"
|
||||
}`}
|
||||
title="Toggle timeline (date-grouped) view"
|
||||
>
|
||||
{viewMode === "timeline" ? "Timeline" : "Feed"}
|
||||
</button>
|
||||
</GlassPanel>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-5">
|
||||
{semanticSearching && (
|
||||
<GlassPanel className="lg:col-span-5">
|
||||
<SectionHeader
|
||||
eyebrow="semantic"
|
||||
title={`“${query}”`}
|
||||
action={
|
||||
<span className="mono text-xs text-ink-faint">
|
||||
{semantic.data?.length ?? 0} matches
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSemanticMode((v) => !v)}
|
||||
className={`rounded-full border px-3 py-1.5 font-mono text-xs transition-colors ${
|
||||
semanticMode
|
||||
? "border-signal/40 bg-signal/10 text-signal"
|
||||
: "border-[var(--color-hairline)] text-[var(--color-ink-soft)] hover:text-[var(--color-ink)]"
|
||||
}`}
|
||||
title="Toggle semantic (vector) search over the message archive"
|
||||
>
|
||||
{semanticMode ? "Semantic" : "Exact"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setViewMode((v) => (v === "feed" ? "timeline" : "feed"))
|
||||
{semantic.isLoading ? (
|
||||
<SkeletonRows rows={4} />
|
||||
) : semantic.data && semantic.data.length > 0 ? (
|
||||
<div className="max-h-[60vh] space-y-1.5 overflow-y-auto pr-1">
|
||||
{semantic.data.map((r, i) => (
|
||||
<div
|
||||
key={r.message_id ?? i}
|
||||
className="animate-stagger flex items-start gap-3 rounded-[10px] border border-hairline bg-white/[0.03] p-3"
|
||||
style={staggerDelay(i)}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="mono text-[0.6rem] text-signal">
|
||||
{(r.score * 100).toFixed(0)}%
|
||||
</span>
|
||||
<span className="mono ml-auto text-[0.6rem] text-ink-faint">
|
||||
{formatRelativeTime(r.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 line-clamp-3 text-sm text-ink-soft">
|
||||
{r.content}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={<Search className="size-7" />}
|
||||
title="No semantic matches"
|
||||
description="Try different wording — semantic search finds meaning, not exact text."
|
||||
/>
|
||||
)}
|
||||
</GlassPanel>
|
||||
)}
|
||||
|
||||
<GlassPanel className="lg:col-span-3">
|
||||
<SectionHeader
|
||||
eyebrow={searching ? "results" : "live feed"}
|
||||
title={searching ? `“${query}”` : "Messages"}
|
||||
action={
|
||||
<span className="mono text-xs text-ink-faint">
|
||||
{list.length} shown
|
||||
</span>
|
||||
}
|
||||
className={`rounded-full border px-3 py-1.5 font-mono text-xs transition-colors ${
|
||||
viewMode === "timeline"
|
||||
? "border-signal/40 bg-signal/10 text-signal"
|
||||
: "border-[var(--color-hairline)] text-[var(--color-ink-soft)] hover:text-[var(--color-ink)]"
|
||||
}`}
|
||||
title="Toggle timeline (date-grouped) view"
|
||||
>
|
||||
{viewMode === "timeline" ? "Timeline" : "Feed"}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Stream dossier — right column */}
|
||||
<section
|
||||
className="pointer-events-auto absolute bottom-20 right-5 top-28 hidden w-[min(30rem,92vw)] flex-col overflow-hidden rounded-2xl border border-[var(--color-hairline)] bg-[var(--color-canvas-2)]/70 backdrop-blur-xl md:flex"
|
||||
aria-label="Message stream"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-[var(--color-hairline)] px-4 py-2.5">
|
||||
<span className="eyebrow">
|
||||
{searching ? `“${query}”` : "live stream"}
|
||||
</span>
|
||||
<span className="font-mono text-xs text-[var(--color-ink-faint)]">
|
||||
{list.length} shown
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-hidden p-2">
|
||||
{semanticSearching ? (
|
||||
<SemanticResults semantic={semantic} />
|
||||
) : error && !messages ? (
|
||||
/>
|
||||
{error && !messages ? (
|
||||
<ErrorState error={error} />
|
||||
) : isLoading && !messages ? (
|
||||
<SkeletonRows rows={8} />
|
||||
) : list.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<MessageSquare className="size-6" />}
|
||||
icon={<MessageSquare className="size-7" />}
|
||||
title="No messages"
|
||||
description="Pick a guild to begin, or run a search."
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full flex-col">
|
||||
<div>
|
||||
{!searching && (
|
||||
<div className="mb-1.5 flex items-center justify-center gap-2">
|
||||
<div className="mb-2 flex items-center justify-center gap-2">
|
||||
{loadMore.isPending ? (
|
||||
<span className="flex items-center gap-1.5 font-mono text-xs text-[var(--color-ink-soft)]">
|
||||
<span className="flex items-center gap-1.5 text-xs text-ink-soft">
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
Loading older…
|
||||
</span>
|
||||
@@ -326,19 +348,19 @@ export function MessagesView({
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadOlder}
|
||||
className="rounded-full border border-[var(--color-hairline)] px-3 py-1 font-mono text-xs text-[var(--color-ink-soft)] transition-colors hover:text-[var(--color-ink)]"
|
||||
className="rounded-full border border-hairline bg-white/[0.03] px-3 py-1 text-xs text-ink-soft transition-colors hover:bg-white/[0.06]"
|
||||
>
|
||||
↑ Load older messages
|
||||
</button>
|
||||
) : loadedPages >= MAX_OLDER_PAGES ? (
|
||||
<span className="font-mono text-[0.65rem] text-[var(--color-ink-faint)]">
|
||||
<span className="mono text-[0.65rem] text-ink-faint">
|
||||
capped at {MAX_OLDER_PAGES} older pages · use search for
|
||||
more
|
||||
</span>
|
||||
) : (
|
||||
messages &&
|
||||
messages.length > 0 && (
|
||||
<span className="font-mono text-[0.65rem] text-[var(--color-ink-faint)]">
|
||||
<span className="mono text-[0.65rem] text-ink-faint">
|
||||
beginning of history
|
||||
</span>
|
||||
)
|
||||
@@ -347,9 +369,11 @@ export function MessagesView({
|
||||
)}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="min-h-0 flex-1 space-y-1.5 overflow-y-auto pr-1"
|
||||
className="max-h-[60vh] space-y-1.5 overflow-y-auto pr-1"
|
||||
onScroll={(e) => {
|
||||
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;
|
||||
@@ -360,11 +384,11 @@ export function MessagesView({
|
||||
}}
|
||||
>
|
||||
{viewMode === "timeline" && timelineNodes
|
||||
? timelineNodes.map((node) =>
|
||||
? timelineNodes.map((node, _i) =>
|
||||
node.type === "date" ? (
|
||||
<div
|
||||
key={`date-${node.iso}`}
|
||||
className="flex items-center gap-2 px-1 font-mono text-[0.65rem] text-[var(--color-ink-faint)]"
|
||||
className="flex items-center gap-2 px-1 text-[0.65rem] text-ink-faint"
|
||||
>
|
||||
<Calendar className="size-3" />
|
||||
{node.label}
|
||||
@@ -378,7 +402,7 @@ export function MessagesView({
|
||||
/>
|
||||
),
|
||||
)
|
||||
: display.map((m) => (
|
||||
: display.map((m, _i) => (
|
||||
<MessageRow
|
||||
key={m.id}
|
||||
m={m}
|
||||
@@ -389,49 +413,19 @@ export function MessagesView({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</GlassPanel>
|
||||
|
||||
{/* Mobile stream — full-width sheet under the console */}
|
||||
<section
|
||||
className="pointer-events-auto absolute inset-x-4 bottom-24 top-44 overflow-y-auto rounded-2xl border border-[var(--color-hairline)] bg-[var(--color-canvas-2)]/80 p-2 backdrop-blur-xl md:hidden"
|
||||
aria-label="Message stream mobile"
|
||||
>
|
||||
{error && !messages ? (
|
||||
<ErrorState error={error} />
|
||||
) : isLoading && !messages ? (
|
||||
<SkeletonRows rows={6} />
|
||||
) : display.length === 0 ? (
|
||||
<EmptyState title="No messages" description="Pick a guild first." />
|
||||
) : (
|
||||
display.map((m) => (
|
||||
<MessageRow
|
||||
key={m.id}
|
||||
m={m}
|
||||
selected={selected}
|
||||
onSelect={setSelected}
|
||||
<GlassPanel className="lg:col-span-2">
|
||||
<SectionHeader eyebrow="inspect" title="Detail" />
|
||||
{!selected ? (
|
||||
<EmptyState
|
||||
title="Select a message"
|
||||
description="Click any message to inspect AI analysis, attachments and edit history."
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Inspection dossier — bottom-center */}
|
||||
{selected ? (
|
||||
<aside
|
||||
className="pointer-events-auto absolute bottom-20 left-1/2 z-30 max-h-[52vh] w-[min(34rem,92vw)] -translate-x-1/2 overflow-y-auto rounded-2xl border border-[var(--color-hairline)] bg-[var(--color-canvas-2)]/85 p-4 backdrop-blur-xl"
|
||||
aria-label="Message detail"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-3 top-3 font-mono text-xs text-[var(--color-ink-faint)] hover:text-[var(--color-ink)]"
|
||||
onClick={() => setSelected(null)}
|
||||
>
|
||||
esc
|
||||
</button>
|
||||
{detail.loading ? (
|
||||
) : detail.loading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-16" />
|
||||
<Skeleton className="h-10" />
|
||||
<Skeleton className="h-20" />
|
||||
<Skeleton className="h-12" />
|
||||
</div>
|
||||
) : detail.message ? (
|
||||
<MessageDetail
|
||||
@@ -441,71 +435,14 @@ export function MessagesView({
|
||||
) : (
|
||||
<EmptyState title="Not found" />
|
||||
)}
|
||||
</aside>
|
||||
) : null}
|
||||
|
||||
{/* Intel strip — bottom-left */}
|
||||
<div className="pointer-events-auto absolute bottom-20 left-5 hidden w-72 lg:block">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowIntel((v) => !v)}
|
||||
className={`rounded-full border px-3 py-1 font-mono text-xs transition-colors ${
|
||||
showIntel
|
||||
? "border-signal/40 bg-signal/10 text-signal"
|
||||
: "border-[var(--color-hairline)] text-[var(--color-ink-soft)] hover:text-[var(--color-ink)]"
|
||||
}`}
|
||||
>
|
||||
intel {showIntel ? "▲" : "▼"}
|
||||
</button>
|
||||
{showIntel ? (
|
||||
<div className="mt-2 max-h-[38vh] space-y-3 overflow-y-auto rounded-2xl border border-[var(--color-hairline)] bg-[var(--color-canvas-2)]/75 p-3 backdrop-blur-xl">
|
||||
{activity.data && activity.data.length > 0 ? (
|
||||
<ActivityHeatmap buckets={activity.data} />
|
||||
) : null}
|
||||
{edits.data ? <EditHistory edits={edits.data} /> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</GlassPanel>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SemanticResults({
|
||||
semantic,
|
||||
}: {
|
||||
semantic: ReturnType<typeof useSemanticSearch>;
|
||||
}) {
|
||||
if (semantic.isLoading) return <SkeletonRows rows={4} />;
|
||||
if (!semantic.data || semantic.data.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<Search className="size-6" />}
|
||||
title="No semantic matches"
|
||||
description="Try different wording — semantic search finds meaning, not exact text."
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="h-full space-y-1.5 overflow-y-auto pr-1">
|
||||
{semantic.data.map((r, i) => (
|
||||
<div
|
||||
key={r.message_id ?? i}
|
||||
className="animate-stagger rounded-xl border border-[var(--color-hairline)] p-2.5"
|
||||
style={staggerDelay(i)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-[0.6rem] text-signal">
|
||||
{(r.score * 100).toFixed(0)}%
|
||||
</span>
|
||||
<span className="ml-auto font-mono text-[0.6rem] text-[var(--color-ink-faint)]">
|
||||
{formatRelativeTime(r.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-0.5 line-clamp-3 text-sm text-[var(--color-ink-soft)]">
|
||||
{r.content}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
{activity.data && activity.data.length > 0 && (
|
||||
<ActivityHeatmap buckets={activity.data} />
|
||||
)}
|
||||
|
||||
{edits.data && <EditHistory edits={edits.data} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -556,12 +493,10 @@ function MessageDetail({
|
||||
return (
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar src={m.avatar_url} name={m.username} size={36} />
|
||||
<Avatar src={m.avatar_url} name={m.username} size={40} />
|
||||
<div>
|
||||
<div className="font-semibold text-[var(--color-ink)]">
|
||||
{m.username}
|
||||
</div>
|
||||
<div className="font-mono text-[0.65rem] text-[var(--color-ink-faint)]">
|
||||
<div className="font-semibold text-ink">{m.username}</div>
|
||||
<div className="mono text-[0.65rem] text-ink-faint">
|
||||
{getMessageChannelLabel(m)} · {formatRelativeTime(m.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -573,21 +508,21 @@ function MessageDetail({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-[var(--color-hairline)] p-3 text-[var(--color-ink-soft)]">
|
||||
<div className="rounded-[10px] border border-hairline bg-white/[0.03] p-3 text-ink-soft">
|
||||
{renderMessageContent(m.edited_content ?? m.content, m.metadata) ||
|
||||
"(no text)"}
|
||||
</div>
|
||||
|
||||
{m.ai_analysis ? (
|
||||
{m.ai_analysis && (
|
||||
<div>
|
||||
<div className="eyebrow mb-1">AI analysis</div>
|
||||
<div className="rounded-xl border border-[var(--color-hairline)] p-3 text-[var(--color-ink-soft)]">
|
||||
<div className="rounded-[10px] border border-hairline bg-white/[0.03] p-3 text-ink-soft">
|
||||
{m.ai_analysis}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
)}
|
||||
|
||||
{flags.length > 0 || cats.length > 0 ? (
|
||||
{(flags.length > 0 || cats.length > 0) && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{flags.map((f) => (
|
||||
<Badge key={f} tone="vermilion">
|
||||
@@ -600,9 +535,9 @@ function MessageDetail({
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
)}
|
||||
|
||||
{attachments.length > 0 ? (
|
||||
{attachments.length > 0 && (
|
||||
<div>
|
||||
<div className="eyebrow mb-1 flex items-center gap-1.5">
|
||||
<Paperclip className="size-3" /> Attachments ({attachments.length})
|
||||
@@ -614,22 +549,23 @@ function MessageDetail({
|
||||
href={a.discord_url ?? a.uploaded_url ?? "#"}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
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)]"
|
||||
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"
|
||||
>
|
||||
<ImageIcon className="size-3.5 text-signal" />
|
||||
<span className="flex-1 truncate">{a.filename}</span>
|
||||
<span className="text-[var(--color-ink-faint)]">
|
||||
<span className="mono text-ink-faint">
|
||||
{formatBytes(a.size)}
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Single message card used by both the live feed and the date-grouped timeline. */
|
||||
function MessageRow({
|
||||
m,
|
||||
selected,
|
||||
@@ -641,32 +577,31 @@ function MessageRow({
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
key={m.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(m.id)}
|
||||
className={`animate-stagger flex w-full items-start gap-3 rounded-xl border p-2.5 text-left transition-colors ${
|
||||
className={`animate-stagger flex w-full items-start gap-3 rounded-[12px] border p-3 text-left transition-colors ${
|
||||
selected === m.id
|
||||
? "border-signal/40 bg-signal/8"
|
||||
: "border-[var(--color-hairline)] hover:bg-white/[0.04]"
|
||||
: "border-hairline bg-white/[0.03] hover:bg-white/[0.06]"
|
||||
}`}
|
||||
>
|
||||
<Avatar src={m.avatar_url} name={m.username} size={32} />
|
||||
<Avatar src={m.avatar_url} name={m.username} size={34} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-semibold text-[var(--color-ink)]">
|
||||
<span className="truncate text-sm font-semibold text-ink">
|
||||
{m.username}
|
||||
</span>
|
||||
<span className="font-mono text-[0.65rem] text-[var(--color-ink-faint)]">
|
||||
<span className="mono text-[0.65rem] text-ink-faint">
|
||||
{getMessageChannelLabel(m)}
|
||||
</span>
|
||||
<span className="ml-auto font-mono text-[0.6rem] text-[var(--color-ink-faint)]">
|
||||
<span className="mono ml-auto text-[0.6rem] text-ink-faint">
|
||||
{formatRelativeTime(m.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 line-clamp-2 text-sm text-[var(--color-ink-soft)]">
|
||||
<div className="mt-0.5 line-clamp-2 text-sm text-ink-soft">
|
||||
{renderMessageContent(m.content, m.metadata) || (
|
||||
<span className="italic text-[var(--color-ink-faint)]">
|
||||
(empty / embed)
|
||||
</span>
|
||||
<span className="italic text-ink-faint">(empty / embed)</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user