bloby-bot 0.39.1 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/package.json +1 -1
  2. package/supervisor/channels/manager.ts +31 -0
  3. package/supervisor/channels/whatsapp.ts +43 -22
  4. package/workspace/client/index.html +0 -3
  5. package/workspace/client/src/App.tsx +9 -20
  6. package/workspace/client/src/components/Dashboard/DashboardPage.tsx +113 -117
  7. package/workspace/client/src/components/Layout/DashboardLayout.tsx +34 -32
  8. package/workspace/client/src/components/Layout/MobileNav.tsx +6 -103
  9. package/workspace/client/src/components/Layout/Sidebar.tsx +10 -11
  10. package/workspace/client/src/styles/globals.css +4 -89
  11. package/workspace/client/public/.well-known/assetlinks.json +0 -8
  12. package/workspace/client/public/bloby-cyberpunk.png +0 -0
  13. package/workspace/client/public/brand/blackrock.svg +0 -8
  14. package/workspace/client/public/kid-breakfast.png +0 -0
  15. package/workspace/client/public/wallpapers/bg.jpg +0 -0
  16. package/workspace/client/public/wallpapers/crypto_bg.png +0 -0
  17. package/workspace/client/public/wallpapers/wp-dusk.jpg +0 -0
  18. package/workspace/client/public/wallpapers/wp-mountain.jpg +0 -0
  19. package/workspace/client/public/wallpapers/wp-ocean.jpg +0 -0
  20. package/workspace/client/src/components/Dashboard/AiChatPage.tsx +0 -145
  21. package/workspace/client/src/components/Dashboard/CryptoPage.tsx +0 -470
  22. package/workspace/client/src/components/Dashboard/WishlistPage.tsx +0 -464
  23. package/workspace/client/src/components/Layout/MiniSidebar.tsx +0 -64
  24. package/workspace/client/src/components/Lock/PinInput.tsx +0 -107
  25. package/workspace/client/src/components/Lock/WorkspaceLock.tsx +0 -484
  26. package/workspace/client/src/components/StickyNotes/StickyNotesOverlay.tsx +0 -396
  27. package/workspace/client/src/components/StickyNotes/StickyNotesSettingsPage.tsx +0 -427
  28. package/workspace/client/src/components/Wallpaper/WallpaperBackground.tsx +0 -12
  29. package/workspace/client/src/components/Wallpaper/WallpaperContext.tsx +0 -160
  30. package/workspace/client/src/components/Wallpaper/WallpaperPicker.tsx +0 -67
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bloby-bot",
3
- "version": "0.39.1",
3
+ "version": "0.41.0",
4
4
  "releaseNotes": [
5
5
  "1. # voice note (PTT bubble)",
6
6
  "2. # audio file + caption",
@@ -116,6 +116,7 @@ export class ChannelManager {
116
116
  },
117
117
  (status) => this.handleStatusChange(status),
118
118
  (audioBase64) => this.transcribeAudio(audioBase64),
119
+ (fromMe, isSelfChat, isGroup) => this.shouldProcessWhatsAppAudio(fromMe, isSelfChat, isGroup),
119
120
  );
120
121
  this.providers.set('whatsapp', whatsapp);
121
122
 
@@ -140,6 +141,7 @@ export class ChannelManager {
140
141
  },
141
142
  (status) => this.handleStatusChange(status),
142
143
  (audioBase64) => this.transcribeAudio(audioBase64),
144
+ (fromMe, isSelfChat, isGroup) => this.shouldProcessWhatsAppAudio(fromMe, isSelfChat, isGroup),
143
145
  );
144
146
  this.providers.set('whatsapp', whatsapp);
145
147
  provider = whatsapp;
@@ -419,6 +421,35 @@ export class ChannelManager {
419
421
  return config.channels?.[channel];
420
422
  }
421
423
 
424
+ /** Decide whether an inbound WhatsApp audio is worth transcribing.
425
+ * Mirrors the gates in handleInboundMessage so we don't burn Whisper calls
426
+ * (or, worse, leak the bot via "Whisper not enabled" replies) on messages
427
+ * that would be filtered out anyway.
428
+ *
429
+ * Audio carries no `@bloby` text trigger, so in assistant mode we only
430
+ * transcribe when the audio is admin's self-chat command. */
431
+ private shouldProcessWhatsAppAudio(fromMe: boolean, isSelfChat: boolean, isGroup: boolean): boolean {
432
+ const channelConfig = this.getChannelConfig('whatsapp');
433
+ if (!channelConfig) return false;
434
+
435
+ const mode = channelConfig.mode || 'channel';
436
+
437
+ // Group gating mirrors handleInboundMessage.
438
+ if (isGroup) {
439
+ if (mode === 'channel') return false;
440
+ if (!channelConfig.allowGroups) return false;
441
+ }
442
+
443
+ if (mode === 'channel') return fromMe && isSelfChat;
444
+ if (mode === 'assistant') return fromMe && isSelfChat;
445
+ if (mode === 'business') {
446
+ // Outbound non-self-chat messages are filtered out — same as handleInboundMessage.
447
+ if (fromMe && !isSelfChat) return false;
448
+ return true;
449
+ }
450
+ return false;
451
+ }
452
+
422
453
  /** Handle an incoming message from any channel — debounces rapid messages from the same sender.
423
454
  *
424
455
  * Per-mode behavior is decided here. To add a new mode: extend the gating block below
@@ -48,6 +48,16 @@ export type OnWhatsAppMessage = (
48
48
  /** Callback to transcribe audio via whisper */
49
49
  export type TranscribeFn = (audioBase64: string) => Promise<string | null>;
50
50
 
51
+ /** Callback that decides whether an audio message warrants transcription.
52
+ * Returning false makes the channel silently skip the audio (no Whisper call,
53
+ * no "Whisper not enabled" reply) — used to avoid leaking the bot in modes
54
+ * where the message would be filtered out downstream anyway. */
55
+ export type ShouldTranscribeAudioFn = (
56
+ fromMe: boolean,
57
+ isSelfChat: boolean,
58
+ isGroup: boolean,
59
+ ) => boolean;
60
+
51
61
  export class WhatsAppChannel implements ChannelProvider {
52
62
  readonly type: ChannelType = 'whatsapp';
53
63
 
@@ -58,6 +68,7 @@ export class WhatsAppChannel implements ChannelProvider {
58
68
  private onMessage: OnWhatsAppMessage;
59
69
  private onStatusChange: (status: ChannelStatus) => void;
60
70
  private transcribe: TranscribeFn | null = null;
71
+ private shouldTranscribeAudio: ShouldTranscribeAudioFn | null = null;
61
72
  private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
62
73
  private intentionalDisconnect = false;
63
74
 
@@ -76,10 +87,12 @@ export class WhatsAppChannel implements ChannelProvider {
76
87
  onMessage: OnWhatsAppMessage,
77
88
  onStatusChange: (status: ChannelStatus) => void,
78
89
  transcribe?: TranscribeFn,
90
+ shouldTranscribeAudio?: ShouldTranscribeAudioFn,
79
91
  ) {
80
92
  this.onMessage = onMessage;
81
93
  this.onStatusChange = onStatusChange;
82
94
  this.transcribe = transcribe || null;
95
+ this.shouldTranscribeAudio = shouldTranscribeAudio || null;
83
96
  }
84
97
 
85
98
  async connect(): Promise<void> {
@@ -441,6 +454,29 @@ export class WhatsAppChannel implements ChannelProvider {
441
454
  continue;
442
455
  }
443
456
 
457
+ // Resolve sender/chat identity up front so audio gating can consult mode/role.
458
+ const fromMe = msg.key.fromMe || false;
459
+ const rawSender = msg.key.remoteJid || '';
460
+ const participant = msg.key.participant || '';
461
+ const isGroup = rawSender.endsWith('@g.us');
462
+
463
+ // chatJid: where to reply (group JID for groups, peer JID otherwise).
464
+ const chatJid = rawSender;
465
+
466
+ // The actual sender JID:
467
+ // - groups: always `participant` (remoteJid is the group)
468
+ // - 1:1: `participant` if Baileys provided one (newer protocol), else remoteJid
469
+ const actualSender = isGroup
470
+ ? participant || rawSender
471
+ : (participant || rawSender);
472
+
473
+ // Translate LID JIDs to phone JIDs (only handles our own LID)
474
+ const sender = this.translateJid(actualSender);
475
+ const pushName = msg.pushName || undefined;
476
+
477
+ // Self-chat: only meaningful for 1:1 — remoteJid is our own number AND no participant.
478
+ const isSelfChat = !isGroup && !participant && this.ownPhoneJid !== null && this.translateJid(rawSender) === this.ownPhoneJid;
479
+
444
480
  // Extract text — or transcribe audio if it's a voice note
445
481
  let rawText = this.extractText(msg.message);
446
482
  const images: WhatsAppImageAttachment[] = [];
@@ -459,6 +495,13 @@ export class WhatsAppChannel implements ChannelProvider {
459
495
  }
460
496
 
461
497
  if (!rawText && this.isAudioMessage(msg.message)) {
498
+ // Mode-aware gate: don't transcribe (and don't reveal the bot with a
499
+ // "Whisper not enabled" reply) when the message would be filtered out
500
+ // downstream — e.g. a friend's voice note in assistant mode.
501
+ if (this.shouldTranscribeAudio && !this.shouldTranscribeAudio(fromMe, isSelfChat, isGroup)) {
502
+ log.info(`[whatsapp] Audio skipped by mode gate (fromMe=${fromMe}, selfChat=${isSelfChat}, group=${isGroup})`);
503
+ continue;
504
+ }
462
505
  // Voice note / audio — download and transcribe
463
506
  if (!this.transcribe) {
464
507
  log.info('[whatsapp] Audio message received but no transcribe function configured — skipping');
@@ -494,28 +537,6 @@ export class WhatsAppChannel implements ChannelProvider {
494
537
  // Escape special characters to prevent prompt injection via message content
495
538
  const text = this.escapeMessageText(rawText);
496
539
 
497
- const fromMe = msg.key.fromMe || false;
498
- const rawSender = msg.key.remoteJid || '';
499
- const participant = msg.key.participant || '';
500
- const isGroup = rawSender.endsWith('@g.us');
501
-
502
- // chatJid: where to reply (group JID for groups, peer JID otherwise).
503
- const chatJid = rawSender;
504
-
505
- // The actual sender JID:
506
- // - groups: always `participant` (remoteJid is the group)
507
- // - 1:1: `participant` if Baileys provided one (newer protocol), else remoteJid
508
- const actualSender = isGroup
509
- ? participant || rawSender
510
- : (participant || rawSender);
511
-
512
- // Translate LID JIDs to phone JIDs (only handles our own LID)
513
- const sender = this.translateJid(actualSender);
514
- const pushName = msg.pushName || undefined;
515
-
516
- // Self-chat: only meaningful for 1:1 — remoteJid is our own number AND no participant.
517
- const isSelfChat = !isGroup && !participant && this.ownPhoneJid !== null && this.translateJid(rawSender) === this.ownPhoneJid;
518
-
519
540
  log.info(`[whatsapp] Message from ${sender} (chat=${chatJid}, group=${isGroup}, fromMe=${fromMe}, selfChat=${isSelfChat}, images=${images.length}): ${text.slice(0, 80)}`);
520
541
 
521
542
  this.onMessage(sender, pushName, text, fromMe, isSelfChat, chatJid, isGroup, images.length > 0 ? images : undefined);
@@ -11,9 +11,6 @@
11
11
  <link rel="apple-touch-icon" href="/bloby-icon-192.png" />
12
12
  <link rel="manifest" href="/manifest.json" />
13
13
  <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&display=swap" rel="stylesheet">
14
- <link rel="preconnect" href="https://fonts.googleapis.com">
15
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
16
- <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
17
14
  <title>Bloby - AI agent with its own workspace</title>
18
15
  </head>
19
16
  <body class="bg-background text-foreground" style="background-color:#0A0A0A">
@@ -4,11 +4,6 @@ import ErrorBoundary from './components/ErrorBoundary';
4
4
  import DashboardLayout from './components/Layout/DashboardLayout';
5
5
  import DashboardPage from './components/Dashboard/DashboardPage';
6
6
  import WorkspaceTour from './components/deleteme_onboarding/WorkspaceTour';
7
- import { WorkspaceLock } from './components/Lock/WorkspaceLock';
8
- import StickyNotesSettingsPage from './components/StickyNotes/StickyNotesSettingsPage';
9
- import AiChatPage from './components/Dashboard/AiChatPage';
10
- import WishlistPage from './components/Dashboard/WishlistPage';
11
- import CryptoPage from './components/Dashboard/CryptoPage';
12
7
 
13
8
  function DashboardError() {
14
9
  return (
@@ -21,9 +16,9 @@ function DashboardError() {
21
16
  playsInline
22
17
  style={{ height: 120, width: 120, borderRadius: '50%', objectFit: 'cover', marginBottom: 32 }}
23
18
  />
24
- <h1 style={{ fontSize: 20, fontWeight: 600, marginBottom: 8 }}>Your app crashed</h1>
19
+ <h1 style={{ fontSize: 20, fontWeight: 600, marginBottom: 8 }}>Oopss.. Something wrong is not right</h1>
25
20
  <p style={{ fontSize: 14, color: 'rgba(255,255,255,0.5)', maxWidth: 320, lineHeight: 1.5 }}>
26
- Ask the agent to fix it using the chat.
21
+ If your agent is working, this is normal. If not, go poke them
27
22
  </p>
28
23
  </div>
29
24
  );
@@ -162,19 +157,13 @@ export default function App() {
162
157
  return (
163
158
  <>
164
159
  <ErrorBoundary fallback={<DashboardError />}>
165
- <WorkspaceLock>
166
- <DashboardLayout userName={userName} botName={botName}>
167
- <Routes>
168
- <Route path="/" element={<DashboardPage />} />
169
- <Route path="/sticky-notes" element={<StickyNotesSettingsPage />} />
170
- <Route path="/aichat" element={<AiChatPage />} />
171
- <Route path="/wishlist" element={<WishlistPage />} />
172
- <Route path="/crypto" element={<CryptoPage />} />
173
- <Route path="*" element={<DashboardPage />} />
174
- </Routes>
175
- </DashboardLayout>
176
- <WorkspaceTour disabled={showOnboard} />
177
- </WorkspaceLock>
160
+ <DashboardLayout userName={userName} botName={botName}>
161
+ <Routes>
162
+ <Route path="/" element={<DashboardPage />} />
163
+ <Route path="*" element={<DashboardPage />} />
164
+ </Routes>
165
+ </DashboardLayout>
166
+ <WorkspaceTour disabled={showOnboard} />
178
167
  </ErrorBoundary>
179
168
 
180
169
  {showOnboard && (
@@ -1,138 +1,134 @@
1
- import { useEffect, useState } from 'react';
2
- import { useNavigate } from 'react-router';
3
- import { Heart, Zap, Eye, ArrowUpRight } from 'lucide-react';
1
+ import { Search, Mail, DollarSign, TrendingUp } from 'lucide-react';
2
+ import { AreaChart, Area, BarChart, Bar, ResponsiveContainer } from 'recharts';
4
3
 
5
- interface WishlistStats {
6
- total: number;
7
- watching: number;
8
- urgent: number;
9
- purchased: number;
10
- }
4
+ const GRADIENT = 'linear-gradient(to right, #4FF2FE 10%, #BC20DE 55%, #FF6B8A 100%)';
5
+ const CARD = 'relative rounded-xl overflow-hidden';
6
+ const BORDER = 'absolute inset-0 rounded-xl bg-gradient-to-b from-white/[0.08] via-white/[0.02] to-transparent pointer-events-none';
7
+ const INNER = 'relative rounded-xl bg-[#141414] m-px p-3.5 h-full';
8
+
9
+ const rev = [{ v: 82 }, { v: 89 }, { v: 94 }, { v: 101 }, { v: 108 }, { v: 112 }, { v: 125 }];
10
+ const fol = [{ v: 12 }, { v: 18 }, { v: 9 }, { v: 24 }, { v: 31 }, { v: 19 }, { v: 27 }];
11
11
 
12
- interface WishlistItem {
13
- id: number;
14
- name: string;
15
- last_deal_found: string | null;
16
- created_at: string;
12
+ function StripeSvg() {
13
+ return <svg className="h-3.5 w-3.5 text-[#635BFF]" viewBox="0 0 24 24" fill="currentColor"><path d="M13.976 9.15c-2.172-.806-3.356-1.426-3.356-2.409 0-.831.683-1.305 1.901-1.305 2.227 0 4.515.858 6.09 1.631l.89-5.494C18.252.975 15.697 0 12.165 0 9.667 0 7.589.654 6.104 1.872 4.56 3.147 3.757 4.992 3.757 7.218c0 4.039 2.467 5.76 6.476 7.219 2.585.92 3.445 1.574 3.445 2.583 0 .98-.84 1.545-2.354 1.545-1.875 0-4.965-.921-6.99-2.109l-.9 5.555C5.175 22.99 8.385 24 11.714 24c2.641 0 4.843-.624 6.328-1.813 1.664-1.305 2.525-3.236 2.525-5.732 0-4.128-2.524-5.851-6.591-7.305z" /></svg>;
14
+ }
15
+ function GmailSvg() {
16
+ return <svg className="h-3.5 w-3.5 text-[#EA4335]" viewBox="0 0 24 24" fill="currentColor"><path d="M24 5.457v13.909c0 .904-.732 1.636-1.636 1.636h-3.819V11.73L12 16.64l-6.545-4.91v9.273H1.636A1.636 1.636 0 0 1 0 19.366V5.457c0-2.023 2.309-3.178 3.927-1.964L5.455 4.64 12 9.548l6.545-4.91 1.528-1.145C21.69 2.28 24 3.434 24 5.457z" /></svg>;
17
+ }
18
+ function XSvg() {
19
+ return <svg className="h-3.5 w-3.5 text-white" viewBox="0 0 24 24" fill="currentColor"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" /></svg>;
17
20
  }
18
21
 
19
22
  export default function DashboardPage() {
20
- const navigate = useNavigate();
21
- const [wlStats, setWlStats] = useState<WishlistStats | null>(null);
22
- const [wlLatestDeal, setWlLatestDeal] = useState<{ name: string; deal: string } | null>(null);
23
-
24
- useEffect(() => {
25
- fetch('/app/api/wishlist/stats')
26
- .then((r) => (r.ok ? r.json() : null))
27
- .then((s) => { if (s) setWlStats(s); })
28
- .catch(() => {});
29
- fetch('/app/api/wishlist')
30
- .then((r) => (r.ok ? r.json() : null))
31
- .then((items: WishlistItem[] | null) => {
32
- if (!items) return;
33
- const withDeal = items.find((i) => i.last_deal_found);
34
- if (withDeal) setWlLatestDeal({ name: withDeal.name, deal: withDeal.last_deal_found! });
35
- })
36
- .catch(() => {});
37
- }, []);
38
-
39
23
  return (
40
- <div className="flex flex-col h-full px-4 sm:px-6 md:px-20 pt-10 sm:pt-16 pb-32 max-w-5xl mx-auto w-full">
41
- <div className="grid grid-cols-1 gap-5">
24
+ <div className="flex flex-col h-full px-4 sm:px-6 pt-8 sm:pt-12 pb-6 max-w-4xl mx-auto w-full overflow-y-auto">
25
+ <h1
26
+ className="text-xl sm:text-2xl font-bold mb-1 tracking-tight w-fit"
27
+ style={{ backgroundImage: GRADIENT, WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent', backgroundClip: 'text' }}
28
+ >
29
+ Let's get started
30
+ </h1>
31
+ <p className="text-muted-foreground text-xs mb-6">Your workspace at a glance.</p>
42
32
 
43
- {/* Wishlist glass card */}
44
- <button
45
- type="button"
46
- onClick={() => navigate('/wishlist')}
47
- className="glass-card text-left cursor-pointer hover:brightness-110 transition group p-6 sm:p-7"
48
- >
49
- <div className="relative">
50
- <div className="flex items-start gap-4 mb-6">
51
- <div className="h-12 w-12 rounded-2xl flex items-center justify-center bg-gradient-to-br from-pink-300/40 to-rose-500/20 border border-white/10 backdrop-blur-xl shrink-0">
52
- <Heart className="h-5 w-5 text-pink-200" fill="currentColor" />
53
- </div>
54
- <div className="flex-1 min-w-0">
55
- <h3 className="text-base font-semibold text-white tracking-tight">Wishlist</h3>
56
- <p className="text-xs text-white/60 mt-0.5">Items you're tracking, urgent picks, and the latest deal.</p>
33
+ <div className="grid grid-cols-3 gap-2.5">
34
+
35
+ {/* Stripe — 2 cols */}
36
+ <div className={`${CARD} col-span-2`}>
37
+ <div className={BORDER} />
38
+ <div className={INNER}>
39
+ <div className="flex items-center justify-between">
40
+ <div className="flex items-center gap-2">
41
+ <div className="h-7 w-7 rounded-lg bg-[#635BFF]/10 flex items-center justify-center"><StripeSvg /></div>
42
+ <span className="text-xs font-bold">Stripe</span>
57
43
  </div>
58
- <div className="h-9 w-9 rounded-full bg-white/10 border border-white/10 flex items-center justify-center text-white/80 group-hover:bg-white/15 transition">
59
- <ArrowUpRight className="h-4 w-4" />
44
+ <div className="flex items-center gap-1 text-emerald-500">
45
+ <TrendingUp className="h-3 w-3" />
46
+ <span className="text-[10px] font-bold">+12.5%</span>
60
47
  </div>
61
48
  </div>
49
+ <p className="text-2xl font-bold tracking-tight mt-2">$12,480</p>
50
+ <p className="text-[10px] text-muted-foreground/50 mb-1">MRR</p>
51
+ <div className="h-12 overflow-hidden">
52
+ <ResponsiveContainer width="100%" height={48}>
53
+ <AreaChart data={rev}>
54
+ <defs>
55
+ <linearGradient id="sg" x1="0" y1="0" x2="1" y2="0"><stop offset="0%" stopColor="#4FF2FE" /><stop offset="50%" stopColor="#BC20DE" /><stop offset="100%" stopColor="#FE546B" /></linearGradient>
56
+ <linearGradient id="sf" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stopColor="#BC20DE" stopOpacity={0.12} /><stop offset="100%" stopColor="#BC20DE" stopOpacity={0} /></linearGradient>
57
+ </defs>
58
+ <Area type="monotone" dataKey="v" stroke="url(#sg)" strokeWidth={1.5} fill="url(#sf)" />
59
+ </AreaChart>
60
+ </ResponsiveContainer>
61
+ </div>
62
+ </div>
63
+ </div>
62
64
 
63
- <div className="grid grid-cols-3 gap-3 sm:gap-5">
64
- <Stat
65
- icon={Eye}
66
- label="Watching"
67
- value={wlStats?.watching ?? 0}
68
- gradientFrom="#5DE0E6"
69
- gradientTo="#85C7FF"
70
- />
71
- <Stat
72
- icon={Zap}
73
- label="Urgent"
74
- value={wlStats?.urgent ?? 0}
75
- gradientFrom="#FFB37A"
76
- gradientTo="#FF7AAA"
77
- />
78
- <Stat
79
- icon={Heart}
80
- label="Total"
81
- value={wlStats?.total ?? 0}
82
- gradientFrom="#C9A7E8"
83
- gradientTo="#F2B5C7"
84
- />
65
+ {/* X 1 col */}
66
+ <div className={`${CARD} col-span-1`}>
67
+ <div className={BORDER} />
68
+ <div className={INNER}>
69
+ <div className="flex items-center gap-2 mb-2">
70
+ <div className="h-7 w-7 rounded-lg bg-white/[0.06] flex items-center justify-center"><XSvg /></div>
71
+ <span className="text-xs font-bold">X</span>
72
+ </div>
73
+ <p className="text-2xl font-bold tracking-tight">24.8K</p>
74
+ <div className="flex items-center gap-1 text-emerald-500 mb-1">
75
+ <TrendingUp className="h-2.5 w-2.5" />
76
+ <span className="text-[10px] font-bold">+1.4K</span>
85
77
  </div>
78
+ <div className="h-10 overflow-hidden">
79
+ <ResponsiveContainer width="100%" height={40}>
80
+ <BarChart data={fol} barCategoryGap="25%">
81
+ <defs>
82
+ <linearGradient id="xg" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stopColor="#BC20DE" stopOpacity={0.3} /><stop offset="100%" stopColor="#4FF2FE" stopOpacity={0.05} /></linearGradient>
83
+ </defs>
84
+ <Bar dataKey="v" fill="url(#xg)" radius={[2, 2, 0, 0]} />
85
+ </BarChart>
86
+ </ResponsiveContainer>
87
+ </div>
88
+ </div>
89
+ </div>
86
90
 
87
- {wlLatestDeal && (
88
- <div className="mt-6 pt-5 border-t border-white/10 flex items-start gap-3">
89
- <div className="h-7 px-2 rounded-full bg-amber-300/15 border border-amber-200/20 text-amber-200 text-[10px] font-semibold uppercase tracking-wider flex items-center">
90
- Latest deal
91
- </div>
92
- <p className="text-sm text-white/85 truncate flex-1">
93
- <span className="text-white/55">{wlLatestDeal.name}:</span> {wlLatestDeal.deal}
94
- </p>
91
+ {/* Gmail — 1 col */}
92
+ <div className={`${CARD} col-span-1`}>
93
+ <div className={BORDER} />
94
+ <div className={INNER}>
95
+ <div className="flex items-center gap-2 mb-2.5">
96
+ <div className="h-7 w-7 rounded-lg bg-[#EA4335]/10 flex items-center justify-center"><GmailSvg /></div>
97
+ <span className="text-xs font-bold">Gmail</span>
98
+ <span className="ml-auto text-[10px] text-muted-foreground/50">3 new</span>
99
+ </div>
100
+ {['Sarah Chen', 'Stripe', 'Alex R.'].map((n) => (
101
+ <div key={n} className="flex items-center gap-2 py-1.5">
102
+ <div className="h-5 w-5 rounded-full bg-white/[0.06] text-[9px] font-bold flex items-center justify-center shrink-0">{n[0]}</div>
103
+ <span className="text-[11px] truncate">{n}</span>
95
104
  </div>
96
- )}
105
+ ))}
97
106
  </div>
98
- </button>
107
+ </div>
99
108
 
100
- </div>
101
- </div>
102
- );
103
- }
109
+ {/* Research — 2 cols */}
110
+ <div className={`${CARD} col-span-2`}>
111
+ <div className={BORDER} />
112
+ <div className={INNER}>
113
+ <div className="flex items-center gap-2 mb-2.5">
114
+ <div className="h-7 w-7 rounded-lg bg-[#9235F9]/10 flex items-center justify-center"><Search className="h-3.5 w-3.5 text-[#9235F9]" /></div>
115
+ <span className="text-xs font-bold">Research</span>
116
+ <span className="ml-auto text-[10px] font-bold text-muted-foreground bg-white/[0.06] px-2 py-0.5 rounded-full">3</span>
117
+ </div>
118
+ {[
119
+ { t: 'Competitor pricing', s: 'Done', c: 'text-emerald-500 bg-emerald-500/10' },
120
+ { t: 'Market trends Q1', s: 'Done', c: 'text-emerald-500 bg-emerald-500/10' },
121
+ { t: 'User feedback', s: 'Review', c: 'text-orange-400 bg-orange-400/10' },
122
+ ].map((r) => (
123
+ <div key={r.t} className="flex items-center justify-between py-1.5">
124
+ <span className="text-[11px]">{r.t}</span>
125
+ <span className={`text-[9px] font-bold px-1.5 py-0.5 rounded-full ${r.c}`}>{r.s}</span>
126
+ </div>
127
+ ))}
128
+ </div>
129
+ </div>
104
130
 
105
- function Stat({
106
- icon: Icon,
107
- label,
108
- value,
109
- gradientFrom,
110
- gradientTo,
111
- }: {
112
- icon: React.ComponentType<{ className?: string }>;
113
- label: string;
114
- value: number;
115
- gradientFrom: string;
116
- gradientTo: string;
117
- }) {
118
- const gradient = `linear-gradient(135deg, ${gradientFrom}, ${gradientTo})`;
119
- return (
120
- <div className="relative">
121
- <div className="flex items-center gap-1.5 mb-2">
122
- <Icon className="h-3 w-3 text-white/70" />
123
- <span
124
- className="text-[10px] font-semibold uppercase tracking-wider"
125
- style={{
126
- backgroundImage: gradient,
127
- WebkitBackgroundClip: 'text',
128
- WebkitTextFillColor: 'transparent',
129
- backgroundClip: 'text',
130
- }}
131
- >
132
- {label}
133
- </span>
134
131
  </div>
135
- <p className="text-3xl sm:text-4xl font-semibold tracking-tight text-white tabular-nums">{value}</p>
136
132
  </div>
137
133
  );
138
134
  }
@@ -1,10 +1,7 @@
1
1
  import { useState, useEffect } from 'react';
2
2
  import type { ReactNode } from 'react';
3
+ import Sidebar from './Sidebar';
3
4
  import MobileNav from './MobileNav';
4
- import MiniSidebar from './MiniSidebar';
5
- import StickyNotesOverlay from '../StickyNotes/StickyNotesOverlay';
6
- import { WallpaperProvider } from '../Wallpaper/WallpaperContext';
7
- import WallpaperBackground from '../Wallpaper/WallpaperBackground';
8
5
 
9
6
  interface Props {
10
7
  children: ReactNode;
@@ -17,9 +14,16 @@ export default function DashboardLayout({ children, userName, botName = 'Bloby'
17
14
 
18
15
  useEffect(() => {
19
16
  const check = () => {
17
+ console.log('[health] checking /app/api/health…');
20
18
  fetch('/app/api/health', { signal: AbortSignal.timeout(3000) })
21
- .then((r) => setStatus(r.ok ? 'healthy' : 'restarting'))
22
- .catch(() => setStatus('restarting'));
19
+ .then((r) => {
20
+ console.log(`[health] response: ${r.status} ok=${r.ok}`);
21
+ setStatus(r.ok ? 'healthy' : 'restarting');
22
+ })
23
+ .catch((err) => {
24
+ console.warn('[health] fetch failed:', err.message ?? err);
25
+ setStatus('restarting');
26
+ });
23
27
  };
24
28
  check();
25
29
  const id = setInterval(check, 10_000);
@@ -27,36 +31,34 @@ export default function DashboardLayout({ children, userName, botName = 'Bloby'
27
31
  }, []);
28
32
 
29
33
  return (
30
- <WallpaperProvider>
31
- <WallpaperBackground />
32
-
33
- <div className="flex h-dvh flex-col">
34
- {/* Mobile header */}
35
- <header className="flex items-center justify-between px-4 py-3 md:hidden">
36
- <MobileNav userName={userName} botName={botName} backendStatus={status} />
37
- <div className="flex items-center gap-2">
38
- <img src="/bloby.png" alt={botName} className="h-6 w-auto" />
39
- <span className="font-semibold text-base text-white drop-shadow">{botName}</span>
40
- </div>
41
- <div className="w-10" />
42
- </header>
43
-
44
- <div className="flex flex-1 overflow-hidden">
45
- {/* Floating mini-sidebar (desktop only) */}
46
- <div id="tour-sidebar" className="contents">
47
- <MiniSidebar />
48
- </div>
34
+ <div className="flex h-dvh flex-col">
35
+ {/* Mobile header */}
36
+ <header className="flex items-center justify-between px-4 py-3 md:hidden">
37
+ <MobileNav userName={userName} botName={botName} backendStatus={status} />
38
+ <div className="flex items-center gap-2">
39
+ <img src="/bloby.png" alt={botName} className="h-6 w-auto" />
40
+ <span className="font-semibold text-base">{botName}</span>
41
+ </div>
42
+ <div className="w-10" />
43
+ </header>
49
44
 
50
- {/* Main content */}
51
- <div className="flex flex-1 flex-col overflow-hidden">
52
- <main id="tour-dashboard" className="relative flex-1 overflow-y-auto">
53
- {children}
54
- <StickyNotesOverlay />
55
- </main>
45
+ <div className="flex flex-1 overflow-hidden">
46
+ {/* Desktop sidebar floating card with shiny border */}
47
+ <div id="tour-sidebar" className="hidden md:flex shrink-0 pl-3 pt-3 pb-3">
48
+ <div className="relative rounded-3xl overflow-hidden h-full">
49
+ {/* Shiny border highlight */}
50
+ <div className="absolute inset-0 rounded-3xl bg-gradient-to-b from-white/[0.12] via-white/[0.04] to-transparent pointer-events-none" />
51
+ <div className="relative rounded-3xl bg-[#1A1A1A] m-px h-full">
52
+ <Sidebar userName={userName} botName={botName} backendStatus={status} />
53
+ </div>
56
54
  </div>
57
55
  </div>
58
56
 
57
+ {/* Main content + footer */}
58
+ <div className="flex flex-1 flex-col overflow-hidden">
59
+ <main id="tour-dashboard" className="flex-1 overflow-y-auto">{children}</main>
60
+ </div>
59
61
  </div>
60
- </WallpaperProvider>
62
+ </div>
61
63
  );
62
64
  }