diff --git a/services/frontend/src/app/(dashboard)/analysis/view.tsx b/services/frontend/src/app/(dashboard)/analysis/view.tsx
index 1b6dc1c2..ae1c3bb8 100644
--- a/services/frontend/src/app/(dashboard)/analysis/view.tsx
+++ b/services/frontend/src/app/(dashboard)/analysis/view.tsx
@@ -1,24 +1,72 @@
"use client";
-import { Hash, Search, Sparkles, TrendingUp } from "lucide-react";
-import { useEffect, useState } from "react";
+/**
+ * Analysis scene — semantic search lives in a floating console (left);
+ * results stream into a translucent dossier (right). The stage shows an
+ * analysis hub wired to the guild's top channels.
+ */
+import { Hash, Search, Sparkles } from "lucide-react";
+import { useEffect, useMemo, useState } from "react";
import { useAmbient } from "@/components/ambient/ambient-context";
-import { Avatar, Badge, GlassPanel, Input } from "@/components/primitives";
-import { EmptyState, SectionHeader, SkeletonRows } from "@/components/shared";
-import { useChannels, useMessageSearch, useTopReactors } from "@/hooks";
+import { Avatar, Badge } from "@/components/primitives";
+import { EmptyState, SkeletonRows } from "@/components/shared";
+import { useScenePublish } from "@/components/shell/scene-graph-context";
+import { useChannels, useMessageSearch } from "@/hooks";
import { aiTone } from "@/lib/ai-status";
+import type { ConstellationGraph } from "@/lib/constellation/graph";
import {
formatDuration,
getMessageChannelLabel,
renderMessageContent,
} from "@/lib/format";
+function analysisGraph(
+ channels:
+ | {
+ channel_id: string;
+ channel_name?: string | null;
+ total_messages: number;
+ }[]
+ | undefined,
+): ConstellationGraph {
+ const rows = (channels ?? []).slice(0, 10);
+ const maxMsg = rows.reduce((m, c) => Math.max(m, c.total_messages), 0);
+ const clamp = (n: number) => (maxMsg <= 0 ? 0.3 : Math.max(0.15, n / maxMsg));
+ return {
+ nodes: [
+ { id: "hub", label: "analysis", kind: "guild", value: 1 },
+ ...rows.map((c) => ({
+ id: `channel:${c.channel_id}`,
+ label: c.channel_name ?? c.channel_id.slice(0, 8),
+ kind: "channel" as const,
+ href: "/channels/",
+ value: clamp(c.total_messages),
+ })),
+ ],
+ edges: rows.map((c) => ({
+ source: "hub",
+ target: `channel:${c.channel_id}`,
+ })),
+ };
+}
+
export function AnalysisView() {
const [query, setQuery] = useState("");
const search = useMessageSearch(query, query.trim().length >= 2);
- const { data: reactors } = useTopReactors();
const { data: channels } = useChannels();
const ambient = useAmbient();
+ const publish = useScenePublish();
+
+ const graph = useMemo(() => analysisGraph(channels), [channels]);
+
+ useEffect(() => {
+ publish({ graph, focus: null });
+ }, [graph, publish]);
+
+ useEffect(
+ () => () => publish({ graph: { nodes: [], edges: [] }, focus: null }),
+ [publish],
+ );
useEffect(() => {
ambient.set(
@@ -29,52 +77,50 @@ export function AnalysisView() {
}, [query, ambient]);
return (
-
-
-
-
-
-
-
Semantic search
-
- Search the archive
-
-
-
-
-
-
+ {/* Search console — left */}
+
+
+ Search the archive
+
+
+
+ setQuery(e.target.value)}
+ placeholder="Find messages, patterns, flags…"
autoFocus
+ className="h-11 w-full rounded-full border border-[var(--color-hairline)] bg-[var(--color-canvas)]/70 pl-11 pr-4 text-sm text-[var(--color-ink)] backdrop-blur-md outline-none placeholder:text-[var(--color-ink-faint)] focus:border-[var(--color-signal)]"
/>
- {query.trim().length > 0 && query.trim().length < 2 && (
-
- Type at least 2 characters…
-
- )}
-
+ {query.trim().length > 0 && query.trim().length < 2 ? (
+
+ minimal 2 karakter…
+
+ ) : null}
+
-
-
-
- {(search.data ?? []).length}
-
- }
- />
- {query.trim().length >= 2 && search.isLoading && (
-
- )}
- {(search.data ?? []).length === 0 ? (
+ {/* Results dossier — right */}
+
+
+ matches
+
+ {(search.data ?? []).length}
+
+
+
+ {query.trim().length >= 2 && search.isLoading ? (
+
+ ) : (search.data ?? []).length === 0 ? (
}
+ icon={
}
title="No matches yet"
description="Run a search to surface messages across the guild."
/>
@@ -83,27 +129,27 @@ export function AnalysisView() {
{(search.data ?? []).map((m) => (
-
+
-
+
{m.username}
-
+
{getMessageChannelLabel(m)}
- {m.ai_status && (
+ {m.ai_status ? (
{m.ai_analysis_duration_ms &&
m.ai_analysis_duration_ms > 0
? `${m.ai_status} · ${formatDuration(m.ai_analysis_duration_ms)}`
: m.ai_status}
- )}
+ ) : null}
-
+
{renderMessageContent(m.content, m.metadata) || "(embed)"}
@@ -111,86 +157,14 @@ export function AnalysisView() {
))}
)}
-
-
-
-
-
- Top reactors
-
- }
- />
-
- {(reactors ?? []).slice(0, 6).map((r, i) => {
- const maxNet = reactors?.[0]?.net_count || 1;
- const pct = Math.max(
- 4,
- Math.round((r.net_count / maxNet) * 100),
- );
- return (
-
-
{i + 1}
-
- {r.username}
-
-
-
- +{r.net_count}
-
-
- );
- })}
- {(reactors ?? []).length === 0 && (
-
- No data
-
- )}
-
-
-
-
-
- Top channels
-
- }
- />
-
- {(channels ?? []).slice(0, 6).map((c) => (
-
-
- {c.channel_name ?? c.channel_id.slice(0, 8)}
-
-
- {c.total_messages}
-
-
- ))}
- {(channels ?? []).length === 0 && (
-
- No data
-
- )}
-
-
-
+
+
+ {/* Channels hint — bottom-left */}
+
+ {(channels ?? []).length} channels
+ terhubung ke hub analitik — klik untuk membuka konstelasi channel
+
);
}
diff --git a/services/frontend/src/app/(dashboard)/glossary/view.tsx b/services/frontend/src/app/(dashboard)/glossary/view.tsx
index 19098096..fe36d0c8 100644
--- a/services/frontend/src/app/(dashboard)/glossary/view.tsx
+++ b/services/frontend/src/app/(dashboard)/glossary/view.tsx
@@ -1,15 +1,118 @@
"use client";
+/**
+ * Glossary scene — term KB as a satellite ring on the stage; the search
+ * whisper filters nodes live, and a term's definition floats in a dossier.
+ */
+import { useEffect, useMemo, useState } from "react";
import { SkeletonPanel } from "@/components/shared";
-import { TermGlossary } from "@/components/TermGlossary";
+import {
+ useSceneFocusSetter,
+ useSceneGraph,
+ useScenePublish,
+} from "@/components/shell/scene-graph-context";
import { useGlossary } from "@/hooks";
+import type { ConstellationGraph } from "@/lib/constellation/graph";
import type { GlossaryRow } from "@/lib/types";
+/** Terms → satellite ring (deterministic positions come from layout). */
+function termsToGraph(rows: GlossaryRow[]): ConstellationGraph {
+ return {
+ nodes: rows.slice(0, 60).map((t) => ({
+ id: `term:${t.term}`,
+ label: t.term,
+ kind: "term" as const,
+ value: Math.min(1, 0.3 + t.definition.length / 400),
+ })),
+ edges: [],
+ };
+}
+
export function GlossaryView({
initialTerms,
}: {
initialTerms?: GlossaryRow[];
}) {
const { data: terms } = useGlossary(100, initialTerms);
- return terms ? : ;
+ const publish = useScenePublish();
+ const setFocus = useSceneFocusSetter();
+ const { state } = useSceneGraph();
+ const [query, setQuery] = useState("");
+
+ const rows = terms ?? [];
+ const filtered = useMemo(() => {
+ const q = query.trim().toLowerCase();
+ if (!q) return rows;
+ return rows.filter(
+ (t) =>
+ t.term.toLowerCase().includes(q) ||
+ t.definition.toLowerCase().includes(q),
+ );
+ }, [rows, query]);
+
+ const graph = useMemo(() => termsToGraph(filtered), [filtered]);
+
+ useEffect(() => {
+ publish({ graph, focus: null });
+ }, [graph, publish]);
+
+ useEffect(() => () => setFocus(null), [setFocus]);
+
+ const selectedId = state?.focus ?? null;
+ const selectedTerm = useMemo(
+ () =>
+ selectedId
+ ? (rows.find((t) => `term:${t.term}` === selectedId) ?? null)
+ : null,
+ [rows, selectedId],
+ );
+
+ return (
+
+
+
+ {selectedTerm ? (
+
+ ) : (
+
+ klik sebuah satelit untuk membuka definisi
+
+ )}
+
+ );
}