| import * as ScrollArea from '@radix-ui/react-scroll-area' |
| import { motion } from 'framer-motion' |
| import { |
| Activity, |
| BarChart3, |
| Bot, |
| Clock3, |
| FileText, |
| Layers3, |
| MessageSquareText, |
| Sparkles, |
| X, |
| } from 'lucide-react' |
| import Button from './Button' |
|
|
| export default function WorkspacePanel({ |
| sidePanel, |
| selectedFile, |
| stats, |
| insights, |
| isAuthenticated, |
| runtimeStatus, |
| activeProvider, |
| activeModel, |
| onClose, |
| onPreviewAnalytics, |
| onAskAboutFile, |
| }) { |
| const isVisible = sidePanel && (sidePanel !== 'file' || selectedFile) |
| if (!isVisible) return null |
|
|
| return ( |
| <> |
| <motion.div |
| initial={{ opacity: 0 }} |
| animate={{ opacity: 1 }} |
| exit={{ opacity: 0 }} |
| className="fixed inset-0 z-40 bg-slate-950/35 backdrop-blur-sm xl:hidden" |
| onClick={onClose} |
| /> |
| |
| <motion.aside |
| initial={{ opacity: 0, x: 24 }} |
| animate={{ opacity: 1, x: 0 }} |
| exit={{ opacity: 0, x: 24 }} |
| className="fixed inset-y-0 right-0 z-50 flex w-[min(100vw,24rem)] flex-col border-l border-border/70 bg-background/94 shadow-panel backdrop-blur-2xl xl:static xl:z-auto xl:w-[360px] xl:bg-background/72" |
| > |
| <div className="flex items-center justify-between border-b border-border/70 px-4 py-4"> |
| <div> |
| <h2 className="font-display text-lg font-semibold text-foreground"> |
| {sidePanel === 'file' ? 'File Preview' : 'Workspace Insights'} |
| </h2> |
| <p className="text-sm leading-6 text-muted-foreground"> |
| {sidePanel === 'file' |
| ? 'Review extracted file context beside the conversation.' |
| : 'Quick status for the current model, history, and workspace health.'} |
| </p> |
| </div> |
| <button |
| type="button" |
| onClick={onClose} |
| className="rounded-full p-2 text-muted-foreground transition hover:bg-secondary hover:text-foreground" |
| aria-label="Close side panel" |
| > |
| <X className="h-4 w-4" /> |
| </button> |
| </div> |
| |
| <ScrollArea.Root className="flex-1 overflow-hidden"> |
| <ScrollArea.Viewport className="h-full w-full"> |
| {sidePanel === 'file' && selectedFile ? ( |
| <div className="space-y-4 p-4"> |
| <div className="surface-soft p-4"> |
| <div className="flex items-start gap-3"> |
| <div className="rounded-2xl bg-accent/10 p-3 text-accent"> |
| <FileText className="h-5 w-5" /> |
| </div> |
| <div> |
| <h3 className="text-sm font-semibold text-foreground">{selectedFile.filename}</h3> |
| <p className="text-xs uppercase tracking-[0.16em] text-muted-foreground"> |
| {selectedFile.kind} |
| </p> |
| </div> |
| </div> |
| <Button |
| variant="primary" |
| className="mt-4 w-full" |
| onClick={() => onAskAboutFile(selectedFile)} |
| > |
| <MessageSquareText className="h-4 w-4" /> |
| Ask about this file |
| </Button> |
| </div> |
| |
| <div className="surface-soft p-4"> |
| <p className="mb-3 text-xs font-semibold uppercase tracking-[0.16em] text-muted-foreground"> |
| Extracted context |
| </p> |
| <pre className="whitespace-pre-wrap break-words font-mono text-xs leading-6 text-foreground/85"> |
| {selectedFile.extracted_text || 'No extracted text available.'} |
| </pre> |
| </div> |
| </div> |
| ) : ( |
| <div className="space-y-4 p-4"> |
| <div className="grid gap-3"> |
| <StatCard |
| icon={Activity} |
| label="Backend" |
| value={formatRuntimeLabel(runtimeStatus?.status)} |
| note={formatRuntimeNote(runtimeStatus)} |
| tone={runtimeStatus?.status === 'ready' ? 'accent' : runtimeStatus?.status === 'degraded' ? 'danger' : 'default'} |
| /> |
| <StatCard |
| icon={Layers3} |
| label="Workspace Reach" |
| value={String(stats.totalSessions)} |
| note={`${stats.totalMessages} total messages across ${stats.activeDays} active days`} |
| /> |
| <StatCard |
| icon={Bot} |
| label="Active Model" |
| value={activeModel || 'Not selected'} |
| note={activeProvider ? `${activeProvider} currently selected` : 'Choose a provider to start chatting'} |
| /> |
| <StatCard |
| icon={Sparkles} |
| label="Current Mode" |
| value={stats.modeLabel} |
| note={stats.modeDescription} |
| /> |
| <StatCard |
| icon={BarChart3} |
| label="Visible Messages" |
| value={String(stats.messageCount)} |
| note="Messages in the current transcript" |
| /> |
| <StatCard |
| icon={Clock3} |
| label="Average Depth" |
| value={String(stats.averageMessagesPerSession)} |
| note="Average messages per conversation in your workspace" |
| /> |
| </div> |
| <InsightSection |
| title="Top modes" |
| emptyLabel={isAuthenticated ? 'No mode data yet.' : 'Sign in to unlock usage insights.'} |
| items={insights?.mode_breakdown} |
| /> |
| <InsightSection |
| title="Top models" |
| emptyLabel={isAuthenticated ? 'No model activity captured yet.' : 'Usage data will appear here after sign-in.'} |
| items={insights?.model_breakdown} |
| /> |
| <InsightSection |
| title="Recent conversations" |
| emptyLabel={isAuthenticated ? 'Recent conversations will appear here.' : 'Your recent chats will appear here after sign-in.'} |
| items={insights?.recent_titles} |
| valueFormatter={null} |
| /> |
| <Button variant="secondary" className="w-full" onClick={onPreviewAnalytics}> |
| Keep insights open |
| </Button> |
| </div> |
| )} |
| </ScrollArea.Viewport> |
| <ScrollArea.Scrollbar orientation="vertical" className="w-2.5 bg-transparent p-0.5"> |
| <ScrollArea.Thumb className="rounded-full bg-border/80" /> |
| </ScrollArea.Scrollbar> |
| </ScrollArea.Root> |
| </motion.aside> |
| </> |
| ) |
| } |
|
|
| function formatRuntimeLabel(status) { |
| if (status === 'ready') return 'Ready' |
| if (status === 'degraded') return 'Degraded' |
| return 'Checking' |
| } |
|
|
| function formatRuntimeNote(runtimeStatus) { |
| if (!runtimeStatus) return 'Backend status is still loading.' |
| const mongo = runtimeStatus.checks?.mongo || 'unknown' |
| const vectorMemory = runtimeStatus.checks?.vector_memory || 'unknown' |
| const providerCount = runtimeStatus.enabled_providers?.length || 0 |
| return `Mongo: ${mongo} / Memory: ${vectorMemory} / Providers: ${providerCount} / v${runtimeStatus.version || 'unknown'}` |
| } |
|
|
| function StatCard({ icon: Icon, label, value, note, tone = 'default' }) { |
| const iconToneClass = |
| tone === 'accent' |
| ? 'bg-accent/10 text-accent' |
| : tone === 'danger' |
| ? 'bg-danger/10 text-danger' |
| : 'bg-accent/10 text-accent' |
|
|
| return ( |
| <div className="surface-soft p-4"> |
| <div className="flex items-start justify-between gap-3"> |
| <div> |
| <p className="text-xs font-semibold uppercase tracking-[0.16em] text-muted-foreground"> |
| {label} |
| </p> |
| <p className="mt-2 font-display text-2xl font-semibold text-foreground">{value}</p> |
| <p className="mt-1 text-sm leading-6 text-muted-foreground">{note}</p> |
| </div> |
| <div className={`rounded-2xl p-3 ${iconToneClass}`}> |
| <Icon className="h-5 w-5" /> |
| </div> |
| </div> |
| </div> |
| ) |
| } |
|
|
| function InsightSection({ title, items, emptyLabel, valueFormatter = (value) => value }) { |
| const entries = Array.isArray(items) |
| ? items.map((item) => ({ label: item, value: null })) |
| : Object.entries(items || {}).slice(0, 5).map(([label, value]) => ({ label, value })) |
|
|
| return ( |
| <div className="surface-soft p-4"> |
| <p className="text-xs font-semibold uppercase tracking-[0.16em] text-muted-foreground"> |
| {title} |
| </p> |
| {entries.length ? ( |
| <div className="mt-3 space-y-2"> |
| {entries.map((entry) => ( |
| <div |
| key={entry.label} |
| className="flex items-center justify-between gap-3 rounded-2xl bg-background/80 px-3 py-2" |
| > |
| <span className="truncate text-sm text-foreground">{entry.label}</span> |
| {valueFormatter && entry.value !== null ? ( |
| <span className="text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground"> |
| {valueFormatter(entry.value)} |
| </span> |
| ) : null} |
| </div> |
| ))} |
| </div> |
| ) : ( |
| <p className="mt-3 text-sm text-muted-foreground">{emptyLabel}</p> |
| )} |
| </div> |
| ) |
| }
|
|
|