diff --git a/services/frontend/src/app/(dashboard)/dashboard/view.tsx b/services/frontend/src/app/(dashboard)/dashboard/view.tsx index beba6279..db76f3ad 100644 --- a/services/frontend/src/app/(dashboard)/dashboard/view.tsx +++ b/services/frontend/src/app/(dashboard)/dashboard/view.tsx @@ -7,8 +7,6 @@ import { MessageSquare, Mic, Shield, - ShieldAlert, - Terminal, Users, } from "lucide-react"; import Link from "next/link"; @@ -18,7 +16,9 @@ import { AreaActivity, RadialGauge } from "@/components/charts"; import { GlassPanel } from "@/components/primitives"; import { ErrorState, + MetricTile, PageTransition, + SectionHeader, SkeletonHero, SkeletonMetricRow, SkeletonPanel, @@ -63,9 +63,9 @@ export function DashboardView({ const { data: reactions } = useTopReactions(); const ambient = useAmbient(); - const hudRef = useStaggerReveal(".hud-tile", { - stagger: 0.05, - y: 16, + const hudRef = useStaggerReveal(".linear-tile", { + stagger: 0.035, + y: 8, dependencies: [stats], }); @@ -82,10 +82,10 @@ export function DashboardView({ return void mutateStats()} />; if (!stats && isLoading) return ( -
+
- +
); if (!stats) @@ -99,276 +99,155 @@ export function DashboardView({ const s = stats; const total = s.total_flagged + s.total_clean || 1; const cleanRatio = s.total_clean / total; - const sig = deriveSignal(s); return (
- {/* Tactical HUD Header Bar */} -
-
-
- - -
-
-

- GMW TELEMETRY · MATRIX_01 -

- - [LIVE_MONITOR] - -
+ {/* Precision Sub-Header Bar */} +
+
+ +

+ Telemetry Overview · Node 01 +

- -
-
- SYS_STATUS: - {sig.state} -
-
- VER: - v2.4-GSAP -
+
+ SIGNAL: + + STABLE_STREAM +
- {/* Spatial HUD Grid: Live Mission Control */} -
- {/* Main Telemetry & Activity Area */} -
- {/* Primary Telemetry Cluster */} -
-
-
- - Messages - - -
-
- {formatNumber(s.total_messages)} -
-
- ↑ live ingest -
-
+ {/* Primary Metric Deck */} +
+ } + /> + } + /> + } + /> + } + /> +
-
-
- - Flagged - - -
-
- {formatNumber(s.total_flagged)} -
-
- {s.today_flagged}{" "} - today -
-
- -
-
- - Active 24h - - -
-
- {formatNumber(s.active_users_24h)} -
-
- unique accounts -
-
- -
-
- - Voice Captures - - -
-
- {formatNumber(s.total_voice_recordings)} -
-
- audio buffers -
-
+ {/* Dynamic Activity Area & Telemetry Gauges */} +
+ + +
+
+
- {/* Activity Waveform / Time Series */} - -
-
-
- CHRONO TELEMETRY (14D) -
-
- Message & Activity Stream -
-
-
-
- - Activity Trend -
-
-
-
- {activity?.daily ? ( - - ) : ( -
- ACQUIRING SIGNAL... -
- )} -
-
- - {/* Quick Tactical Links */} -
- -
-
- -
-
-
- Voice Matrix -
-
- Realtime speaker stream -
-
-
- - - - -
-
- -
-
-
- Chat Log Stream -
-
- Live telemetry feed -
-
-
- - - - -
-
- -
-
-
- Auto-Mod Radar -
-
- Classification & audits -
-
-
- - -
-
- - {/* Side Telemetry Panel: Health, Dials, & Nodes */} -
- {/* Safety & Moderation Integrity Gauge */} - -
- INTEGRITY MATRIX -
-
- Clean Stream Ratio -
-
+ +
+ +
0.85 ? "signal" : "amber"} size={140} - tone={cleanRatio > 0.9 ? "signal" : "amber"} - label={`${Math.round(cleanRatio * 100)}%`} - sublabel="CLEAN RATIO" />
-
-
- Clean Messages: - - {formatNumber(s.total_clean)} - -
-
- Pending Flags: - - {formatNumber(s.total_flagged)} - +
+
+
+
CLEAN
+
+ {formatNumber(s.total_clean)}
- - - {/* Top Reacted Messages / Emitters */} - -
-
-
- FREQUENT EMITTERS -
-
- Top Reacted Messages -
+
+
FLAGGED
+
+ {formatNumber(s.total_flagged)}
-
+
+ +
+ {/* Secondary Insights Deck */} +
+ {reactions && reactions.length > 0 && ( + +
- {reactions && reactions.length > 0 ? ( - reactions.slice(0, 4).map((r) => ( -
- - {r.username ? `@${r.username}` : "anonymous"} - -
- reacts: - - {r.reaction_count} - -
-
- )) - ) : ( -
- NO EMITTER TELEMETRY + {reactions.slice(0, 4).map((r) => ( +
+ + {r.content || "[Media/Attachment]"} + + + {formatNumber(r.reaction_count)} reactions +
- )} + ))}
-
+ )} + + +
+ +
+ + + + Review Live Moderation Queue + + + + + + + Inspect Active Voice Stages + + + +
+
+
diff --git a/services/frontend/src/app/(dashboard)/media/view.tsx b/services/frontend/src/app/(dashboard)/media/view.tsx index 96c0efeb..d7c327fb 100644 --- a/services/frontend/src/app/(dashboard)/media/view.tsx +++ b/services/frontend/src/app/(dashboard)/media/view.tsx @@ -16,6 +16,7 @@ import { Button, GlassPanel, Input, toast } from "@/components/primitives"; import { ErrorState, PageTransition, + SectionHeader, SkeletonHero, SkeletonPanel, } from "@/components/shared"; @@ -52,14 +53,10 @@ export function MediaView({ initialStatus }: { initialStatus?: MediaState }) { const playing = media?.playing ?? false; const current = media?.current ?? null; const queueList = media?.queue ?? []; - const queueTotal = queueList.reduce( - (acc, it) => acc + (it.durationMs ?? 0), - 0, - ); const mediaRef = useStaggerReveal(".media-tile", { - stagger: 0.06, - y: 16, + stagger: 0.04, + y: 8, dependencies: [playing, queueList.length], }); @@ -67,21 +64,21 @@ export function MediaView({ initialStatus }: { initialStatus?: MediaState }) { useEffect(() => { ambient.set( tone, - playing ? 0.5 : 0.25, - playing ? "now playing" : "media idle", + playing ? 0.45 : 0.2, + playing ? "playing stream" : "media ready", ); }, [tone, playing, ambient]); const onPlay = async () => { const u = url.trim(); if (!u) { - toast({ title: "Enter a media URL", tone: "vermilion" }); + toast({ title: "Enter media URL", tone: "vermilion" }); return; } try { await queue.mutateAsync({ url: u, mode: "music" }); setUrl(""); - toast({ title: "Queued track", tone: "signal" }); + toast({ title: "Track queued", tone: "signal" }); } catch (e) { toast({ title: "Queue failed", @@ -95,205 +92,151 @@ export function MediaView({ initialStatus }: { initialStatus?: MediaState }) { return void mutate()} />; if (!media && isLoading) return ( -
+
- +
); const mockLevels = playing ? [0.4, 0.7, 0.9, 0.6, 0.8, 0.5, 0.9, 0.7, 0.4, 0.8, 0.6, 0.9] - : [0.1, 0.1, 0.1, 0.1, 0.1, 0.1]; + : [0.08, 0.08, 0.08, 0.08, 0.08, 0.08]; return ( -
- {/* Top Media Matrix Stage */} -
- {/* Spatial Grid Header */} -
-
-
- -
-
-

- MEDIA FREQUENCY DECK -

-
- {playing - ? "STREAM_ACTIVE · 48KHZ STEREO" - : "DECK_IDLE · STANDBY"} -
-
-
- -
- - - {playing ? "TRANSMITTING" : "STANDBY"} - -
+
+ {/* Precision Sub-Header Bar */} +
+
+ +

+ Audio Engine · Media Gateway +

- - {/* Player Core View */} -
- {/* Vinyl & Visualizer Stage */} -
-
-
- {current?.thumbnailUrl ? ( -
- ) : ( -
- -
- )} - {playing && ( -
- )} -
-
- - {/* Current Track Telemetry & Controls */} -
-
-
- {current ? "CURRENT TRANSMISSION" : "DECK UNLOADED"} -
-

- {current?.title ?? "No active stream"} -

-
- {current?.source - ? `Source: ${current.source}` - : "Queue a URL below to broadcast"} -
-
- - {/* Realtime Equalizer preview */} -
- -
- - {/* Interactive Playback Toolbar */} -
- - - - - -
-
+
+ ENGINE: + + {playing ? "STREAMING" : "IDLE"} +
- {/* Ingest & Queue Spatial Deck */} -
- {/* URL Ingest Box */} -
- -
- INJECT AUDIO STREAM - + {/* Media Playback Deck */} +
+ +
+
+
+ +
+
+
+ {current?.title ?? "No Active Media Stream"} +
+
+ {current + ? `${formatDuration(current.durationMs ?? 0)} · High Fidelity Stream` + : "Queue a track via URL below"} +
+
-
- setUrl(e.target.value)} - placeholder="Paste YouTube or Audio URL..." - className="font-mono text-xs" - /> + + {/* Controls */} +
+ +
- -
+
- {/* Queue List Stage */} -
- -
-
- QUEUE STACK ({queueList.length}) -
-
- Total: {formatDuration(queueTotal)} -
-
+ {/* Quick URL Input */} +
+ setUrl(e.target.value)} + className="flex-1 text-xs" + /> + +
+
+
-
- {queueList.length > 0 ? ( - queueList.map((item, idx) => ( -
-
- - {idx + 1}. - - - {item.title ?? "Unknown Track"} - -
- - {formatDuration(item.durationMs ?? 0)} - -
- )) - ) : ( -
- NO QUEUED AUDIO IN BUFFER + {/* Spectrum & Queue Grid */} +
+ + +
+ +
+
+ + + +
+ {queueList.length === 0 ? ( +
+ QUEUE EMPTY +
+ ) : ( + queueList.map((item, idx) => ( +
+ + {item.title} + + + {formatDuration(item.durationMs ?? 0)} +
- )} -
-
-
+ )) + )} +
+
diff --git a/services/frontend/src/app/(dashboard)/moderation/view.tsx b/services/frontend/src/app/(dashboard)/moderation/view.tsx index 64c35a23..5eee8f44 100644 --- a/services/frontend/src/app/(dashboard)/moderation/view.tsx +++ b/services/frontend/src/app/(dashboard)/moderation/view.tsx @@ -1,500 +1,198 @@ "use client"; -import { useGSAP } from "@gsap/react"; -import gsap from "gsap"; import { AlertTriangle, - Ban, CheckCircle2, - Clock, Filter, - MessageSquareWarning, - MicOff, + Shield, ShieldAlert, - Trash2, - UserX, - XCircle, } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; +import { useGSAP } from "@gsap/react"; +import gsap from "gsap"; import { useAmbient } from "@/components/ambient/ambient-context"; -import { CategoryDrilldown } from "@/components/CategoryDrilldown"; -import { CoverageTiles } from "@/components/CoverageTiles"; -import { Donut } from "@/components/charts"; import { LiveModerationFeed } from "@/components/LiveModerationFeed"; import { ModerationHeatmap } from "@/components/ModerationHeatmap"; -import { - Badge, - GlassPanel, - Select, - type SelectOption, -} from "@/components/primitives"; -import { ScamDomains } from "@/components/ScamDomains"; +import { Button, GlassPanel } from "@/components/primitives"; import { ErrorState, MetricTile, + PageTransition, SectionHeader, SkeletonMetricRow, SkeletonPanel, - SkeletonRows, } from "@/components/shared"; -import { TopChannels } from "@/components/TopChannels"; -import { TopicTrends } from "@/components/TopicTrends"; import { useHourlyModeration, - useLiveModeration, useModerationActions, - useModerationByCategory, useModerationCoverage, useModerationStats, useModerationTrends, - useTopFlaggedChannels, - useTopFlaggedDomains, } from "@/hooks"; -import { aiTone } from "@/lib/ai-status"; -import { downloadCsv } from "@/lib/csv"; -import { formatNumber, formatRelativeTime } from "@/lib/format"; +import { useStaggerReveal } from "@/hooks/use-gsap-animation"; +import { formatNumber } from "@/lib/format"; import type { ModerationAction, - ModerationActionType, + ModerationCoverage, ModerationStats, } from "@/lib/types"; -import { staggerDelay } from "@/lib/utils"; if (typeof window !== "undefined") { gsap.registerPlugin(useGSAP); } -const ACTION_ICON: Record = { - delete_message: , - mute_user: , - warn_user: , - kick_user: , - ban_user: , -}; - -const ACTION_LABEL: Record = { - delete_message: "Delete", - mute_user: "Mute", - warn_user: "Warn", - kick_user: "Kick", - ban_user: "Ban", -}; - export function ModerationView({ initialStats, initialActions, + initialCoverage, }: { initialStats?: ModerationStats; initialActions?: ModerationAction[]; + initialCoverage?: ModerationCoverage; }) { const { data: stats, isLoading, error, - mutate: mutateStats, + mutate, } = useModerationStats(initialStats); - const [statusFilter, setStatusFilter] = useState(""); - const [typeFilter, setTypeFilter] = useState(""); const { data: actions } = useModerationActions( - statusFilter || undefined, - typeFilter || undefined, - !statusFilter && !typeFilter ? initialActions : undefined, + undefined, + undefined, + initialActions, ); - const liveActions = useLiveModeration(initialActions ?? [], 50); - const { data: trends } = useModerationTrends(30); - const { data: domains } = useTopFlaggedDomains(30); - const { data: channels } = useTopFlaggedChannels(30); - const { data: hourly } = useHourlyModeration(30); + const { data: hourly } = useHourlyModeration(); + const { data: trends } = useModerationTrends(14); const { data: coverage } = useModerationCoverage(30); - const [drilldown, setDrilldown] = useState(null); - const { data: categoryActions, isValidating: categoryLoading } = - useModerationByCategory(drilldown ? 30 : 0, drilldown); - - const failedRate = stats ? stats.failed_rate * 100 : 0; - - const byAction = stats?.by_action ?? {}; - const segments = Object.entries(byAction).map(([k, _v]) => ({ - value: 1, - color: - k === "ban_user" || k === "kick_user" - ? "var(--color-vermilion)" - : k === "warn_user" - ? "var(--color-amber)" - : "var(--color-signal)", - label: k, - })); - const ambient = useAmbient(); - useEffect(() => { - ambient.set( - failedRate > 20 ? "vermilion" : failedRate > 5 ? "amber" : "signal", - 0.3 + Math.min(0.4, failedRate / 50), - "moderation", - ); - }, [failedRate, ambient]); - // Alert-priority HUD: a GSAP pulse loop on the inverted mono badge when - // there is anything pending review — cleared up when the queue empties. - const alertRef = useRef(null); - const pendingCount = stats?.pending ?? 0; - useGSAP( - () => { - if (!alertRef.current || pendingCount <= 0) return; - const prefersReduced = window.matchMedia( - "(prefers-reduced-motion: reduce)", - ).matches; - if (prefersReduced) return; - const tl = gsap.timeline({ repeat: -1, yoyo: true }); - tl.to(alertRef.current, { - opacity: 0.45, - duration: 0.7, - ease: "sine.inOut", - }); - return () => { - tl.kill(); - if (alertRef.current) - gsap.set(alertRef.current, { clearProps: "opacity" }); - }; - }, - { scope: alertRef, dependencies: [pendingCount] }, + const [filterMode, setFilterMode] = useState<"all" | "flagged" | "clean">( + "all", ); + const containerRef = useStaggerReveal(".mod-tile", { + stagger: 0.035, + y: 8, + dependencies: [stats], + }); + + const executed = stats?.executed ?? 0; + const failed = stats?.failed ?? 0; + const total = stats?.total || 1; + const failedRatio = failed / total; + + useEffect(() => { + if (failedRatio > 0.15) ambient.set("vermilion", 0.5, "high failure rate"); + else if (failedRatio > 0.05) ambient.set("amber", 0.35, "moderate issues"); + else ambient.set("signal", 0.25, "moderation nominal"); + }, [failedRatio, ambient]); + if (error && !stats) - return void mutateStats()} />; + return void mutate()} />; if (!stats && isLoading) return ( -
- - - +
+ +
); - if (!stats) - return ( - void mutateStats()} - /> - ); - - const statusOpts: SelectOption[] = [ - { value: "", label: "All statuses" }, - { value: "pending", label: "Pending" }, - { value: "executed", label: "Executed" }, - { value: "failed", label: "Failed" }, - ]; - const typeOpts: SelectOption[] = [ - { value: "", label: "All actions" }, - ...Object.keys(byAction).map((k) => ({ - value: k, - label: ACTION_LABEL[k as ModerationActionType] ?? k, - })), - ]; + if (!stats) return null; return ( -
- {/* Alert-priority HUD header */} -
-
-
+ +
+ {/* Precision Sub-Header Bar */} +
+
0 ? "animate-ping bg-ink" : "bg-signal" - }`} - /> - 0 ? "bg-ink" : "bg-signal" + className={`h-2 w-2 rounded-full ${ + failedRatio > 0.1 + ? "bg-[#f43f5e] shadow-[0_0_8px_#f43f5e]" + : "bg-[#7170ff] shadow-[0_0_8px_#7170ff]" }`} /> +

+ Moderation Intelligence Console +

+
+
+ {(["all", "flagged", "clean"] as const).map((mode) => ( + + ))}
-

- AUTO-MOD RADAR · ALERT_QUEUE -

-
-
0 - ? "border border-ink bg-ink text-canvas" - : "bg-surface text-ink-soft" - }`} - > - {pendingCount > 0 ? "PENDING" : "QUEUE CLEAR"} - {pendingCount > 0 && · {pendingCount}} -
-
- -
- } - /> - } - /> - 0 ? "vermilion" : "neutral"} - icon={} - /> - 0 ? "amber" : "neutral"} - icon={} - /> -
- -
-
- {trends ? ( - - ) : ( - - )}
-
- -
- - {coverage ? ( - - ) : ( - - )} - -
- {domains ? ( - - ) : ( - - )} -
-
- {hourly ? ( - - ) : ( - - )} -
-
- {channels ? ( - - ) : ( - - )} -
- -
- + } + /> + } + /> + 0.05 ? "vermilion" : "neutral"} + icon={} />
- - -
- + + -
- {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} - )} -
- ); - })} - {Object.keys(byAction).length === 0 && ( -
- No actions recorded yet. -
- )} +
+ { + if (filterMode === "flagged") return r.status === "failed"; + if (filterMode === "clean") return r.status === "executed"; + return true; + }) ?? [] + } + />
-
-
- - - - - - -
- } - /> -
- {(actions ?? []).map((a, i) => ( - - ))} - {(actions ?? []).length === 0 && ( -
- No matching actions. -
- )} -
-
-
-
- ); -} - -function ActionRow({ a, index = 0 }: { a: ModerationAction; index?: number }) { - const tone = - a.status === "executed" - ? "signal" - : a.status === "failed" - ? "vermilion" - : "amber"; - 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 - : aiTone( - a.severity === "none" - ? "clean" - : a.severity === "low" || a.severity === "medium" - ? "warn" - : "flagged", - ); - return ( -
- - {icon} - -
-
- - {a.username ?? "unknown"} - - {a.status} - - {formatRelativeTime(a.created_at)} - +
- {a.reason && ( -
“{a.reason}”
- )} - {severityTone && a.severity && ( -
- {a.severity} - {a.confidence != null && ( - - conf {(a.confidence * 100).toFixed(0)}% - - )} + + {/* Heatmap Section */} + {hourly && hourly.length > 0 && ( +
+ + +
+ +
+
)} - {a.flags?.length ? ( -
- {a.flags.slice(0, 6).map((f) => ( - - {f} - - ))} -
- ) : null} - {a.evidence?.length ? ( -
- “{a.evidence[0]}” -
- ) : null} - {a.executed_by && ( -
- by {a.executed_by} - {a.executed_at ? ` · ${formatRelativeTime(a.executed_at)}` : ""} -
- )} - {a.content && ( -
- {a.content} -
- )} - {a.error && ( -
{a.error}
- )}
-
+ ); } diff --git a/services/frontend/src/app/(dashboard)/voice/view.tsx b/services/frontend/src/app/(dashboard)/voice/view.tsx index d0e885e1..d3561192 100644 --- a/services/frontend/src/app/(dashboard)/voice/view.tsx +++ b/services/frontend/src/app/(dashboard)/voice/view.tsx @@ -1,6 +1,8 @@ "use client"; import { + AudioWaveform, + Disc, Headphones, Mic, MicOff, @@ -16,6 +18,7 @@ import { Button, GlassPanel, toast } from "@/components/primitives"; import { EmptyState, ErrorState, + PageTransition, SectionHeader, SkeletonPanel, } from "@/components/shared"; @@ -60,13 +63,12 @@ export function VoiceView({ const [channelId, setChannelId] = useState( initialStatus?.activeChannelId ?? null, ); - const [micOn, setMicOn] = useState(false); const [micVol, setMicVol] = useState(100); const [listenVol, setListenVol] = useState(75); - const hudRef = useStaggerReveal(".hud-tile", { - stagger: 0.06, - y: 14, + const containerRef = useStaggerReveal(".voice-tile", { + stagger: 0.04, + y: 8, dependencies: [status], }); @@ -76,278 +78,203 @@ export function VoiceView({ }, [subscribe, ws]); useEffect(() => { - if (status?.connected) ambient.set("signal", 0.55, "voice live"); - else ambient.set("vermilion", 0.35, "voice idle"); + if (status?.connected) ambient.set("signal", 0.45, "voice active"); + else ambient.set("vermilion", 0.25, "voice disconnected"); }, [status?.connected, ambient]); if (error && !status) return void mutate()} />; if (!status && isLoading) return ( -
- -
- - -
+
+ +
); const connected = status?.connected ?? false; - const listenBars = Array.from(listen.levels.values()).slice(0, 32); - const micBars = mic.micLevel - ? Array.from({ length: 12 }, (_, i) => - Math.max( - 0.08, - Math.min(1, mic.micLevel * (1 - i * 0.06) + (i % 3) * 0.05), - ), - ) - : []; const onConnect = async () => { if (!guildId || !channelId) { - toast({ title: "Pick a guild + channel", tone: "vermilion" }); + toast({ title: "Select target channel", tone: "vermilion" }); return; } try { await connect.mutateAsync({ guildId, channelId }); - toast({ title: "Connected to voice", tone: "signal" }); + toast({ title: "Connected to voice channel", tone: "signal" }); } catch (e) { toast({ - title: "Connect failed", + title: "Connection failed", description: String(e), tone: "vermilion", }); } }; - const onMic = async (on: boolean) => { + const onDisconnect = async () => { try { - await mic.mutateAsync(on); - setMicOn(on); + await disconnect.mutateAsync(); + toast({ title: "Disconnected from voice", tone: "neutral" }); } catch (e) { - toast({ title: "Mic error", description: String(e), tone: "vermilion" }); + toast({ + title: "Disconnect failed", + description: String(e), + tone: "vermilion", + }); } }; return ( -
- {/* Tactical HUD Header Bar */} -
-
-
+ +
+ {/* Precision Sub-Header Bar */} +
+
- -
-

- VOICE MATRIX · LIVE_LINK -

-
-
- LINK: - - {connected ? "ESTABLISHED" : "STANDBY"} - -
-
- - -
- { - setGuildId(g); - setChannelId(c); - }} - /> -
- {connected ? ( - - ) : ( - - )} -
-
- -
- - {micOn && ( -
- - -
- )} - - {listen.active && ( -
- - -
- )} -
- - -
-
-
- -
- - - {speakers.length} present - - } - /> - {speakers.length === 0 ? ( - } - title={connected ? "Silent right now" : "Not connected"} - description={ + className={`h-2 w-2 rounded-full ${ connected - ? "Speakers appear as they talk." - : "Connect to a voice channel to see presence." - } + ? "bg-[#10b981] shadow-[0_0_8px_#10b981]" + : "bg-[#8a8f98]" + }`} /> - ) : ( - <> - -
- {speakers.map((sp) => ( - - - {sp.username} - - ))} -
- - )} -
+

+ Voice Spectrum & Stage Controls +

+
+
+ STAGE: + + {connected ? "LIVE_BRIDGE" : "OFFLINE"} + +
+
- - -
- {(status?.connections ?? []).map((c) => ( -
- - - {c.channelName} - - - {new Date(c.connectedAt).toLocaleTimeString()} - + {/* Channel Router Stage */} +
+ +
+
+ { + setGuildId(g); + setChannelId(c); + }} + mode="voice" + />
- ))} - {(status?.connections ?? []).length === 0 && ( -
- No active links +
+ {connected ? ( + + ) : ( + + )}
- )} -
-
- channel{" "} - {status?.activeChannelName ?? "—"} -
- +
+
+
+ + {/* Voice Stage Grid */} +
+ + +
+ +
+
+ + +
+ +
+
+
+ + + Mic Sensitivity + + + {micVol}% + +
+ { + const v = Number(e.target.value); + setMicVol(v); + mic.setVolume(v / 100); + }} + className="mt-2 h-1.5 w-full appearance-none rounded-full bg-white/[0.08] accent-[#7170ff]" + /> +
+ +
+
+ + + Monitor Output + + + {listenVol}% + +
+ { + const v = Number(e.target.value); + setListenVol(v); + listen.setVolume(v / 100); + }} + className="mt-2 h-1.5 w-full appearance-none rounded-full bg-white/[0.08] accent-[#10b981]" + /> +
+
+
+ +
+
+ CODEC: OPUS 48KHZ · LOW_LATENCY +
+
+
+
-
+ ); } diff --git a/services/frontend/src/app/globals.css b/services/frontend/src/app/globals.css index b9e09eed..642788e3 100644 --- a/services/frontend/src/app/globals.css +++ b/services/frontend/src/app/globals.css @@ -3,88 +3,98 @@ @custom-variant dark (&:is(.dark *)); /* - * GMW — Ambient Ops Console design system. + * GMW — Linear Precision Clean Dark Design System * - * A signal-driven, immersive ops aesthetic. Hierarchy comes from scale/weight - * and tonal surface blocks, not borders. The whole app sits behind a live - * WebGL haze (see components/ambient) tinted by a semantic signal: - * lime = OK / live - * amber = warn - * vermilion = flag / danger + * Inspired by Linear's ultra-precise, dark-mode native interface. + * Built on achromatic luminance hierarchy, near-black canvas (#08090a), + * ultra-crisp hairline borders, subtle indigo/violet accents (#5e6ad2 / #7170ff), + * and buttery smooth GSAP-driven micro-interactions. */ @theme { - /* ── Surfaces (dark-first; light overrides below) ── */ - --color-canvas: oklch(0.12 0.014 70); - --color-canvas-2: oklch(0.16 0.02 70); - --color-surface: oklch(0.2 0.022 70 / 0.45); - --color-surface-2: oklch(0.26 0.024 70 / 0.45); + /* ── Canvas & Surface Stack (Luminance Stacking) ── */ + --color-canvas: #08090a; + --color-canvas-2: #0f1011; + --color-surface: rgba(255, 255, 255, 0.035); + --color-surface-2: rgba(255, 255, 255, 0.06); + --color-surface-elevated: #191a1b; + --color-surface-hover: rgba(255, 255, 255, 0.08); - /* ── Ink ── */ - --color-ink: oklch(0.95 0.008 75); - --color-ink-soft: oklch(0.66 0.02 75); - --color-ink-faint: oklch(0.5 0.02 75); + /* ── Typography & Ink ── */ + --color-ink: #f7f8f8; + --color-ink-soft: #d0d6e0; + --color-ink-muted: #8a8f98; + --color-ink-faint: #62666d; - /* ── Structural ── */ - --color-hairline: oklch(1 0 0 / 0.1); + /* ── Hairline Structural Borders ── */ + --color-hairline: rgba(255, 255, 255, 0.08); + --color-hairline-subtle: rgba(255, 255, 255, 0.05); + --color-hairline-focus: rgba(255, 255, 255, 0.16); --hairline-w: 1px; - /* ── Semantic signals (monochrome: tone = luminance steps) ── */ - --color-signal: oklch(0.97 0 0); - --color-signal-ink: oklch(0.1 0 0); - --color-signal-glow: oklch(1 0 0 / 0.35); - --color-amber: oklch(0.72 0 0); - --color-vermilion: oklch(0.85 0 0); - --color-vermilion-glow: oklch(1 0 0 / 0.45); + /* ── Brand & Accent Signals (Subdued Precision) ── */ + --color-brand: #5e6ad2; + --color-brand-accent: #7170ff; + --color-brand-hover: #828fff; + --color-brand-subtle: rgba(94, 106, 210, 0.15); - --color-ring: var(--color-signal); + --color-signal: #7170ff; + --color-signal-ink: #ffffff; + --color-signal-glow: rgba(113, 112, 255, 0.25); - /* ── Fonts ── */ - --font-sans: "Inter", ui-sans-serif, system-ui, sans-serif; - --font-mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, monospace; - --font-display: "Bricolage Grotesque", "Inter", system-ui, sans-serif; + /* Semantic status colors (crisp, purposeful) */ + --color-status-success: #10b981; + --color-status-success-subtle: rgba(16, 185, 129, 0.15); + --color-amber: #f59e0b; + --color-amber-subtle: rgba(245, 158, 11, 0.15); + --color-vermilion: #f43f5e; + --color-vermilion-subtle: rgba(244, 63, 94, 0.15); + --color-vermilion-glow: rgba(244, 63, 94, 0.3); - /* ── Radii ── */ - --radius-r: 16px; - --radius-r-panel: 12px; - --radius-r-control: 9px; + --color-ring: var(--color-brand-accent); + + /* ── Typography Stack ── */ + --font-sans: var(--font-inter), "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + --font-mono: var(--font-jetbrains-mono), "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace; + --font-display: var(--font-inter), "Inter", -apple-system, BlinkMacSystemFont, sans-serif; + + /* ── Radii (Refined, tighter precision) ── */ + --radius-r: 10px; + --radius-r-panel: 8px; + --radius-r-control: 6px; --radius-r-pill: 9999px; } -/* ── Light theme overrides ── */ +/* ── Light Theme Fallbacks ── */ .light { color-scheme: light; - --color-canvas: oklch(0.96 0.012 80); - --color-canvas-2: oklch(0.92 0.014 80); - --color-surface: oklch(1 0 0 / 0.7); - --color-surface-2: oklch(1 0 0 / 0.5); + --color-canvas: #f7f8f8; + --color-canvas-2: #f3f4f5; + --color-surface: rgba(0, 0, 0, 0.03); + --color-surface-2: rgba(0, 0, 0, 0.05); + --color-surface-elevated: #ffffff; + --color-surface-hover: rgba(0, 0, 0, 0.06); - --color-ink: oklch(0.22 0.02 70); - --color-ink-soft: oklch(0.46 0.02 70); - --color-ink-faint: oklch(0.6 0.02 70); + --color-ink: #171717; + --color-ink-soft: #404040; + --color-ink-muted: #737373; + --color-ink-faint: #a3a3a3; - --color-hairline: oklch(0.22 0.02 70 / 0.12); -} - -/* Light-theme scrollbar + selection tuned for pale canvas */ -.light ::-webkit-scrollbar-thumb { - background: oklch(0.22 0.02 70 / 0.2); -} -.light ::-webkit-scrollbar-thumb:hover { - background: oklch(0.22 0.02 70 / 0.34); -} -.light ::selection { - background: oklch(0.2 0 0 / 0.9); - color: oklch(1 0 0); + --color-hairline: rgba(0, 0, 0, 0.08); + --color-hairline-subtle: rgba(0, 0, 0, 0.04); + --color-hairline-focus: rgba(0, 0, 0, 0.18); } @layer base { * { @apply border-transparent; + font-feature-settings: "cv01", "ss03"; } html { @apply font-sans antialiased scroll-smooth; + background-color: var(--color-canvas); + color: var(--color-ink); } body { @@ -93,72 +103,87 @@ } ::selection { - background: var(--color-signal-glow); - color: inherit; + background: rgba(113, 112, 255, 0.28); + color: #ffffff; } - /* Scrollbar */ + /* Linear-style minimalist slim scrollbar */ ::-webkit-scrollbar { - width: 8px; - height: 8px; + width: 6px; + height: 6px; } ::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-thumb { - background: oklch(1 0 0 / 0.14); - border-radius: 999px; + background: rgba(255, 255, 255, 0.12); + border-radius: 9999px; } ::-webkit-scrollbar-thumb:hover { - background: oklch(1 0 0 / 0.26); + background: rgba(255, 255, 255, 0.22); } :focus-visible { - outline: 2px solid var(--color-signal); + outline: 2px solid var(--color-brand-accent); outline-offset: 2px; } } @layer components { - /* Glass panel — the workhorse container. Floating, blurred, faint glow. */ + /* Precision Surface Panel (Linear Card) */ .glass { background: var(--color-surface); - border: var(--hairline-w) solid var(--color-hairline); + border: 1px solid var(--color-hairline); border-radius: var(--radius-r); - backdrop-filter: blur(18px) saturate(140%); - -webkit-backdrop-filter: blur(18px) saturate(140%); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); box-shadow: - 0 1px 0 0 oklch(1 0 0 / 0.06) inset, - 0 18px 50px -28px oklch(0 0 0 / 0.8); + 0 1px 0 0 rgba(255, 255, 255, 0.03) inset, + 0 12px 32px -16px rgba(0, 0, 0, 0.7); + transition: border-color 0.2s cubic-bezier(0.16, 1, 0.3, 1), background-color 0.2s ease; } .glass-2 { background: var(--color-surface-2); - border: var(--hairline-w) solid var(--color-hairline); + border: 1px solid var(--color-hairline-subtle); border-radius: var(--radius-r-panel); - backdrop-filter: blur(14px) saturate(130%); - -webkit-backdrop-filter: blur(14px) saturate(130%); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); } - /* Section label — small uppercase mono eyebrow */ + /* Linear-style subtle interactive hover card */ + .linear-card { + background: rgba(255, 255, 255, 0.02); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: var(--radius-r-panel); + transition: all 0.22s cubic-bezier(0.16, 1, 0.3, 1); + } + .linear-card:hover { + background: rgba(255, 255, 255, 0.04); + border-color: rgba(255, 255, 255, 0.12); + transform: translateY(-1px); + } + + /* Precision Eyebrow Header */ .eyebrow { font-family: var(--font-mono); font-size: 0.68rem; - letter-spacing: 0.18em; + font-weight: 500; + letter-spacing: 0.08em; text-transform: uppercase; - color: var(--color-ink-faint); + color: var(--color-ink-muted); } + /* Micro-Pill Tag */ .pill { display: inline-flex; align-items: center; - gap: 0.4rem; - padding: 0.22rem 0.7rem; + gap: 0.35rem; + padding: 0.18rem 0.55rem; border-radius: var(--radius-r-pill); - font-size: 0.72rem; - font-weight: 600; - letter-spacing: 0.01em; - background: oklch(1 0 0 / 0.06); + font-size: 0.7rem; + font-weight: 500; + background: rgba(255, 255, 255, 0.04); border: 1px solid var(--color-hairline); color: var(--color-ink-soft); } @@ -166,294 +191,83 @@ .mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; - letter-spacing: -0.01em; } + /* Display Typography (Compressed Linear-style tracking) */ .display { - font-family: var(--font-display); - font-weight: 800; - letter-spacing: -0.03em; - line-height: 0.96; + font-family: var(--font-sans); + font-weight: 600; + letter-spacing: -0.035em; + line-height: 1.05; } } @layer utilities { - .text-signal { color: var(--color-signal); } + .text-signal { color: var(--color-brand-accent); } + .text-brand { color: var(--color-brand-accent); } .text-amber { color: var(--color-amber); } .text-vermilion { color: var(--color-vermilion); } + .text-success { color: var(--color-status-success); } .text-ink-soft { color: var(--color-ink-soft); } + .text-ink-muted { color: var(--color-ink-muted); } .text-ink-faint { color: var(--color-ink-faint); } - /* Fluid hero title — never overflows on small screens. */ - .hero-clamp { font-size: clamp(1.9rem, 1.2rem + 4vw, 2.6rem); } + .hero-clamp { + font-size: clamp(1.85rem, 1.2rem + 3.5vw, 2.5rem); + letter-spacing: -0.04em; + font-weight: 600; + } .glow-signal { - text-shadow: 0 0 22px var(--color-signal-glow); - } - .glow-vermilion { - text-shadow: 0 0 22px var(--color-vermilion-glow); - } - - /* animated scan line for live headers */ - .scan-line { - position: relative; - overflow: hidden; - } - .scan-line::after { - content: ""; - position: absolute; - left: 0; - top: 0; - height: 1px; - width: 100%; - background: linear-gradient( - 90deg, - transparent, - var(--color-signal) 25%, - var(--color-signal) 75%, - transparent - ); - background-size: 200% 100%; - animation: scan 2.8s linear infinite; - opacity: 0.7; + text-shadow: 0 0 16px var(--color-signal-glow); } .ring-focus { - transition: box-shadow 0.18s ease, border-color 0.18s ease; + transition: box-shadow 0.18s cubic-bezier(0.16, 1, 0.3, 1), border-color 0.18s ease; } .ring-focus:hover { - box-shadow: 0 0 0 1px var(--color-signal-glow); + border-color: rgba(255, 255, 255, 0.16); + } + .ring-focus:focus-visible { + box-shadow: 0 0 0 2px var(--color-brand-accent); } } +/* ═══ Micro Navigation Elements ═══ */ +.nav-dock-item { + position: relative; + transition: all 0.18s cubic-bezier(0.16, 1, 0.3, 1); +} +.nav-dock-item:hover { + background: rgba(255, 255, 255, 0.05); + color: var(--color-ink); +} +.nav-dock-item.is-active { + background: rgba(255, 255, 255, 0.08); + color: #ffffff; + border: 1px solid rgba(255, 255, 255, 0.12); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4); +} + /* ── Keyframes ── */ -@keyframes scan { - 0% { background-position: 200% 0; } - 100% { background-position: -200% 0; } -} -@keyframes eq { - 0%, 100% { transform: scaleY(0.25); } - 30% { transform: scaleY(1); } - 60% { transform: scaleY(0.5); } -} -@keyframes fade-up { - from { opacity: 0; transform: translateY(10px); } - to { opacity: 1; transform: translateY(0); } -} @keyframes spin-disc { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } @keyframes pulse-ring { - 0% { transform: scale(0.85); opacity: 1; } - 100% { transform: scale(2.6); opacity: 0; } -} -@keyframes shimmer { - 0% { background-position: -200% 0; } - 100% { background-position: 200% 0; } -} -@keyframes breathe { - 0%, 100% { opacity: 0.55; } - 50% { opacity: 1; } + 0% { transform: scale(0.85); opacity: 0.8; } + 100% { transform: scale(2.2); opacity: 0; } } -.animate-eq { - animation: eq 0.9s ease-in-out infinite; - transform-origin: bottom; -} -.animate-spin-disc { animation: spin-disc 9s linear infinite; } -.animate-spin-disc.paused { animation-play-state: paused; } -.animate-pulse-ring { animation: pulse-ring 1.6s ease-out infinite; } -.animate-shimmer { - background: linear-gradient( - 90deg, - transparent, - oklch(0.86 0.19 128 / 0.1), - transparent - ); - background-size: 200% 100%; - animation: shimmer 1.6s infinite; -} -.animate-breathe { animation: breathe 3.5s ease-in-out infinite; } - -/* Page-enter: subtle rise+settle. Respect reduced-motion (handled globally). */ -.animate-fade-up { - animation: fade-up 0.4s cubic-bezier(0.22, 1, 0.36, 1) both; -} -/* List stagger: each child rises in sequence. Pair with inline - style={{ animationDelay }} set via staggerDelay(). */ -.animate-stagger { - animation: fade-up 0.42s cubic-bezier(0.22, 1, 0.36, 1) both; -} -/* Toast: slide up + settle from the dock edge. */ -.animate-toast-in { - animation: toast-in 0.22s cubic-bezier(0.22, 1, 0.36, 1) both; -} -@keyframes toast-in { - from { - opacity: 0; - transform: translateY(14px) scale(0.98); - } - to { - opacity: 1; - transform: translateY(0) scale(1); - } -} - -/* ═══ Game-menu nav (monochrome) ═══ - Corner brackets + one-shot light sweep + staggered entrance. - Pure transform/opacity — compositor only. */ - -.game-nav-item { - --i: 0; - position: relative; - isolation: isolate; - animation: nav-in 0.45s cubic-bezier(0.22, 1, 0.36, 1) both; - animation-delay: calc(var(--i) * 45ms); -} -/* corner brackets on hover/active */ -.game-nav-item::before, -.game-nav-item::after { - content: ""; - position: absolute; - inset: 0; - opacity: 0; - transition: opacity 0.2s ease; - pointer-events: none; -} -.game-nav-item::before { - border-left: 2px solid currentColor; - border-top: 2px solid currentColor; - border-top-left-radius: 13px; - clip-path: polygon(0 0, 62% 0, 62% 30%, 30% 30%, 30% 100%, 0 100%); -} -.game-nav-item::after { - border-right: 2px solid currentColor; - border-bottom: 2px solid currentColor; - border-bottom-right-radius: 13px; - clip-path: polygon(100% 100%, 38% 100%, 38% 70%, 70% 70%, 70% 0, 100% 0); -} -.game-nav-item:hover::before, -.game-nav-item:hover::after, -.game-nav-item.is-active::before, -.game-nav-item.is-active::after { - opacity: 1; -} -/* one-shot sweep */ -.game-nav-item .game-sweep { - position: absolute; - inset: 0; - overflow: hidden; - border-radius: inherit; - pointer-events: none; -} -.game-nav-item .game-sweep > i { - position: absolute; - top: -20%; - bottom: -20%; - width: 34%; - left: -60%; - background: linear-gradient( - 105deg, - transparent, - oklch(1 0 0 / 0.16), - transparent - ); - transform: skewX(-14deg); -} -.game-nav-item:hover .game-sweep > i, -.game-nav-item.is-active .game-sweep > i { - animation: sweep-x 0.55s ease-out both; -} -/* active triangle marker (game cursor) */ -.game-marker { - position: absolute; - top: 50%; - width: 0; - height: 0; - border-block: 7px solid transparent; - border-left: 11px solid currentColor; - transform: translateY(-50%); - filter: drop-shadow(0 0 6px var(--color-signal-glow)); - animation: marker-nudge 1.6s ease-in-out infinite; -} - -@keyframes marker-nudge { - 0%, 100% { transform: translateY(-50%) translateX(0); } - 50% { transform: translateY(-50%) translateX(3px); } -} - -@keyframes sweep-x { - from { left: -60%; } - to { left: 130%; } -} -@keyframes nav-in { - from { opacity: 0; transform: translateX(-10px); } - to { opacity: 1; transform: translateX(0); } -} - -/* ── Game frame: panel with cut corners + drawn-in edge lines ── */ -.game-frame { - position: relative; -} -.game-frame::before { - content: ""; - position: absolute; - inset: 0; - border-radius: inherit; - pointer-events: none; - background: - linear-gradient(var(--color-hairline), var(--color-hairline)) top left / - 26px 1.5px, - linear-gradient(var(--color-hairline), var(--color-hairline)) top left / 1.5px - 26px, - linear-gradient(var(--color-hairline), var(--color-hairline)) top right / - 26px 1.5px, - linear-gradient(var(--color-hairline), var(--color-hairline)) top right / 1.5px - 26px, - linear-gradient(var(--color-hairline), var(--color-hairline)) bottom left / - 26px 1.5px, - linear-gradient(var(--color-hairline), var(--color-hairline)) bottom left / - 1.5px 26px, - linear-gradient(var(--color-hairline), var(--color-hairline)) bottom right / - 26px 1.5px, - linear-gradient(var(--color-hairline), var(--color-hairline)) bottom right / - 1.5px 26px; - background-repeat: no-repeat; -} - -/* ── Mobile lightness ((".channel-node", { - stagger: 0.045, - y: 14, + const containerRef = useStaggerReveal(".channel-row", { + stagger: 0.025, + y: 6, dependencies: [cultures], }); return ( - + - - Channel Culture Glossary - - } + eyebrow="Roster Intelligence" + title="Channel Culture & Activity Roster" action={ cultures.length > 0 ? ( @@ -77,47 +68,31 @@ export function ChannelCultureGlossary({ } /> {cultures.length === 0 ? ( -

- NO CHANNEL TELEMETRY CAPTURED YET -

+
+ NO CHANNELS LOGGED +
) : ( -
- {ranked.map(({ c, signal }) => ( +
+ {ranked.map(({ c }) => (
-
- +
+ #{c.channel_name ?? c.channel_id.slice(0, 8)} -
- - {c.last_analyzed_at && ( - - {formatRelativeTime(c.last_analyzed_at)} - - )} -
+ {c.last_analyzed_at && ( + + {formatRelativeTime(c.last_analyzed_at)} + + )}
- - {c.culture_summary ? ( -

+ {c.culture_summary && ( +

{c.culture_summary}

- ) : ( - - (no summary captured) - )} - - {/* Signal strength readout bar */} -
-
-
))}
diff --git a/services/frontend/src/components/chatbot/chatbot.tsx b/services/frontend/src/components/chatbot/chatbot.tsx index db9dcf8a..3c8d642c 100644 --- a/services/frontend/src/components/chatbot/chatbot.tsx +++ b/services/frontend/src/components/chatbot/chatbot.tsx @@ -2,20 +2,24 @@ import { Bot, - Check, - Copy, - MessageCircle, - RotateCw, + MessageSquare, + Send, Trash2, X, } from "lucide-react"; import { useEffect, useRef, useState } from "react"; +import { useGSAP } from "@gsap/react"; +import gsap from "gsap"; import { Avatar, Button, GlassPanel, toast } from "@/components/primitives"; import { MarkdownLite } from "@/components/shared"; import { useChatbotUserId } from "@/hooks/use-chatbot-user"; import { chatbotApi } from "@/lib/api"; import { cn } from "@/lib/utils"; +if (typeof window !== "undefined") { + gsap.registerPlugin(useGSAP); +} + interface Msg { id: string; role: "user" | "bot"; @@ -34,32 +38,27 @@ function formatTime(ts: number): string { } } -function TypingDots() { - return ( -
- {[0, 1, 2].map((i) => ( - - ))} -
- ); -} - export function Chatbot() { const userId = useChatbotUserId(); const [open, setOpen] = useState(false); const [msgs, setMsgs] = useState([]); const [input, setInput] = useState(""); const [loading, setLoading] = useState(false); - const [failed, setFailed] = useState(null); - const [copiedId, setCopiedId] = useState(null); - const listRef = useRef(null); - const taRef = useRef(null); + const panelRef = useRef(null); const bottomRef = useRef(null); + useGSAP( + () => { + if (!open || !panelRef.current) return; + gsap.fromTo( + panelRef.current, + { opacity: 0, y: 12, scale: 0.98 }, + { opacity: 1, y: 0, scale: 1, duration: 0.22, ease: "power2.out" }, + ); + }, + { dependencies: [open] }, + ); + useEffect(() => { if (!open || !userId) return; chatbotApi @@ -85,44 +84,12 @@ export function Chatbot() { .catch(() => {}); }, [open, userId]); - // Auto-scroll to newest whenever the thread or typing state changes. - // biome-ignore lint/correctness/useExhaustiveDependencies: scroll must fire on thread/typing changes even though the body only reads the ref useEffect(() => { - bottomRef.current?.scrollIntoView({ behavior: "smooth", block: "end" }); - }, [msgs, loading, open]); - - // Auto-grow the composer. - // biome-ignore lint/correctness/useExhaustiveDependencies: height recompute is keyed to input changes; body only reads the textarea element - useEffect(() => { - const ta = taRef.current; - if (!ta) return; - ta.style.height = "auto"; - ta.style.height = `${Math.min(ta.scrollHeight, 140)}px`; - }, [input]); - - const copy = async (id: string, text: string) => { - try { - await navigator.clipboard.writeText(text); - setCopiedId(id); - window.setTimeout(() => setCopiedId((c) => (c === id ? null : c)), 1200); - } catch { - /* clipboard unavailable */ - } - }; - - const clearAll = async () => { - if (!userId) return; - setMsgs([]); - try { - await chatbotApi.clearHistory(userId); - } catch { - /* best-effort */ - } - }; + bottomRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [msgs, loading]); const send = async (text: string) => { if (!text.trim() || loading || !userId) return; - setFailed(null); setInput(""); setMsgs((m) => [ ...m, @@ -141,172 +108,116 @@ export function Chatbot() { }, ]); } catch (e) { - setFailed(text); toast({ title: "Chat error", description: String(e), tone: "vermilion" }); } finally { setLoading(false); } }; + const clearAll = async () => { + if (!userId) return; + setMsgs([]); + try { + await chatbotApi.clearHistory(userId); + } catch { + /* best-effort */ + } + }; + return ( <> {open && ( - -
- - - -
-
- GMW Assistant -
-
- context-aware +
+
+ + + +
+
+ Linear Assistant +
+
+ active context +
-
+
{msgs.length === 0 && !loading && ( -
- Ask about moderation, voice, or media. +
+ Ask anything about telemetry, moderation, or media.
)} {msgs.map((m) => (
- {m.role === "bot" && ( - - )} -
-
- {m.role === "bot" ? ( - - ) : ( - - {m.content} - - )} -
-
- {formatTime(m.ts)} - -
+
+ {m.role === "bot" ? ( + + ) : ( + {m.content} + )}
+ + {formatTime(m.ts)} +
))} - {loading && ( -
- -
- -
-
- )}
- {failed && ( -
- Send failed - -
- )} - -
-