fluxy-bot 0.8.9 → 0.9.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 (106) hide show
  1. package/README.md +53 -84
  2. package/bin/cli.js +1717 -6
  3. package/components.json +18 -18
  4. package/package.json +98 -109
  5. package/postcss.config.js +3 -3
  6. package/scripts/install.ps1 +1 -1
  7. package/scripts/install.sh +1 -1
  8. package/scripts/postinstall.js +47 -58
  9. package/shared/ai.ts +118 -202
  10. package/shared/config.ts +37 -39
  11. package/shared/logger.ts +6 -14
  12. package/shared/paths.ts +10 -16
  13. package/shared/relay.ts +89 -98
  14. package/supervisor/backend.ts +77 -89
  15. package/supervisor/chat/ARCHITECTURE.md +13 -14
  16. package/supervisor/chat/OnboardWizard.tsx +2194 -3419
  17. package/supervisor/chat/fluxy-main.tsx +495 -633
  18. package/supervisor/chat/fluxy.html +24 -36
  19. package/supervisor/chat/onboard-main.tsx +8 -8
  20. package/supervisor/chat/onboard.html +10 -13
  21. package/supervisor/chat/src/components/Chat/AudioBubble.tsx +94 -108
  22. package/supervisor/chat/src/components/Chat/ChatView.tsx +14 -36
  23. package/supervisor/chat/src/components/Chat/ImageLightbox.tsx +71 -89
  24. package/supervisor/chat/src/components/Chat/InputBar.tsx +498 -728
  25. package/supervisor/chat/src/components/Chat/MessageBubble.tsx +154 -246
  26. package/supervisor/chat/src/components/Chat/MessageList.tsx +114 -138
  27. package/supervisor/chat/src/components/Chat/TypingIndicator.tsx +38 -50
  28. package/supervisor/chat/src/components/LoginScreen.tsx +245 -296
  29. package/supervisor/chat/src/hooks/useChat.ts +210 -259
  30. package/supervisor/chat/src/hooks/useFluxyChat.ts +232 -322
  31. package/supervisor/chat/src/hooks/useSpeechRecognition.ts +126 -148
  32. package/supervisor/chat/src/lib/auth.ts +20 -23
  33. package/supervisor/chat/src/lib/ws-client.ts +113 -125
  34. package/supervisor/chat/src/styles/globals.css +76 -85
  35. package/supervisor/file-saver.ts +39 -52
  36. package/supervisor/fluxy-agent.ts +213 -292
  37. package/supervisor/index.ts +895 -1263
  38. package/supervisor/scheduler.ts +237 -299
  39. package/supervisor/tunnel.ts +113 -167
  40. package/supervisor/vite-dev.ts +51 -61
  41. package/supervisor/widget.js +135 -147
  42. package/supervisor/worker.ts +45 -49
  43. package/tsconfig.json +18 -24
  44. package/vite.config.ts +48 -55
  45. package/vite.fluxy.config.ts +34 -34
  46. package/worker/claude-auth.ts +202 -265
  47. package/worker/codex-auth.ts +144 -181
  48. package/worker/db.ts +63 -191
  49. package/worker/index.ts +601 -842
  50. package/workspace/MYSELF.md +1 -1
  51. package/workspace/PULSE.json +6 -6
  52. package/workspace/backend/index.ts +16 -19
  53. package/workspace/client/index.html +47 -60
  54. package/workspace/client/public/manifest.json +21 -21
  55. package/workspace/client/public/sw.js +29 -31
  56. package/workspace/client/src/App.tsx +91 -136
  57. package/workspace/client/src/components/Dashboard/DashboardPage.tsx +68 -72
  58. package/workspace/client/src/components/ErrorBoundary.tsx +11 -11
  59. package/workspace/client/src/components/Layout/DashboardLayout.tsx +35 -35
  60. package/workspace/client/src/components/Layout/Footer.tsx +12 -12
  61. package/workspace/client/src/components/Layout/MobileNav.tsx +23 -23
  62. package/workspace/client/src/components/Layout/Sidebar.tsx +81 -81
  63. package/workspace/client/src/components/ui/avatar.tsx +85 -85
  64. package/workspace/client/src/components/ui/badge.tsx +40 -41
  65. package/workspace/client/src/components/ui/button.tsx +56 -57
  66. package/workspace/client/src/components/ui/card.tsx +75 -75
  67. package/workspace/client/src/components/ui/dialog.tsx +114 -117
  68. package/workspace/client/src/components/ui/dropdown-menu.tsx +191 -199
  69. package/workspace/client/src/components/ui/input.tsx +17 -17
  70. package/workspace/client/src/components/ui/scroll-area.tsx +47 -47
  71. package/workspace/client/src/components/ui/select.tsx +145 -151
  72. package/workspace/client/src/components/ui/separator.tsx +21 -21
  73. package/workspace/client/src/components/ui/sheet.tsx +102 -102
  74. package/workspace/client/src/components/ui/skeleton.tsx +10 -10
  75. package/workspace/client/src/components/ui/switch.tsx +26 -26
  76. package/workspace/client/src/components/ui/tabs.tsx +69 -69
  77. package/workspace/client/src/components/ui/textarea.tsx +14 -14
  78. package/workspace/client/src/components/ui/tooltip.tsx +37 -37
  79. package/workspace/client/src/lib/utils.ts +1 -1
  80. package/workspace/client/src/main.tsx +3 -3
  81. package/workspace/client/src/styles/globals.css +76 -85
  82. package/workspace/skills/code-reviewer/.claude-plugin/plugin.json +3 -3
  83. package/workspace/skills/code-reviewer/skills/code-reviewer/SKILL.md +0 -6
  84. package/workspace/skills/daily-standup/.claude-plugin/plugin.json +3 -3
  85. package/workspace/skills/daily-standup/skills/daily-standup/SKILL.md +0 -7
  86. package/workspace/skills/workspace-helper/.claude-plugin/plugin.json +3 -3
  87. package/workspace/skills/workspace-helper/skills/workspace-helper/SKILL.md +0 -2
  88. package/bin/cli.backup.js +0 -2138
  89. package/cli/commands/daemon.ts +0 -42
  90. package/cli/commands/init.ts +0 -32
  91. package/cli/commands/start.ts +0 -113
  92. package/cli/commands/tunnel.temp.ts +0 -206
  93. package/cli/commands/tunnel.ts +0 -227
  94. package/cli/commands/update.ts +0 -163
  95. package/cli/core/base-adapter.ts +0 -156
  96. package/cli/core/cloudflared.ts +0 -113
  97. package/cli/core/config.ts +0 -73
  98. package/cli/core/os-detector.ts +0 -43
  99. package/cli/core/server.ts +0 -109
  100. package/cli/core/types.ts +0 -15
  101. package/cli/index.ts +0 -72
  102. package/cli/platforms/darwin.ts +0 -146
  103. package/cli/platforms/index.ts +0 -21
  104. package/cli/platforms/linux.ts +0 -156
  105. package/cli/platforms/win32.ts +0 -34
  106. package/cli/utils/ui.ts +0 -37
@@ -1,667 +1,529 @@
1
1
  import React, { useEffect, useRef, useState } from 'react';
2
2
  import ReactDOM from 'react-dom/client';
3
- import {
4
- ArrowLeft,
5
- MoreVertical,
6
- Trash2,
7
- Wand2,
8
- Download,
9
- X,
10
- Share,
11
- Bell,
12
- BellRing,
13
- BellOff
14
- } from 'lucide-react';
3
+ import { ArrowLeft, MoreVertical, Trash2, Wand2, Download, X, Share, Bell, BellRing, BellOff } from 'lucide-react';
15
4
  import { WsClient } from './src/lib/ws-client';
16
5
  import { useFluxyChat } from './src/hooks/useFluxyChat';
17
6
  import OnboardWizard from './OnboardWizard';
18
7
  import LoginScreen from './src/components/LoginScreen';
19
- import {
20
- getAuthToken,
21
- setAuthToken,
22
- clearAuthToken,
23
- authFetch,
24
- onAuthFailure
25
- } from './src/lib/auth';
8
+ import { getAuthToken, setAuthToken, clearAuthToken, authFetch, onAuthFailure } from './src/lib/auth';
26
9
  import MessageList from './src/components/Chat/MessageList';
27
10
  import InputBar from './src/components/Chat/InputBar';
28
11
  import './src/styles/globals.css';
29
12
 
30
13
  function FluxyApp() {
31
- const clientRef = useRef<WsClient | null>(null);
32
- const [connected, setConnected] = useState(false);
33
- const [botName, setBotName] = useState('Fluxy');
34
- const [whisperEnabled, setWhisperEnabled] = useState(false);
35
- const [menuOpen, setMenuOpen] = useState(false);
36
- const [showWizard, setShowWizard] = useState(false);
37
- const [reloadTrigger, setReloadTrigger] = useState(0);
38
- const menuRef = useRef<HTMLDivElement>(null);
39
- const wasConnected = useRef(false);
40
-
41
- // Push notifications
42
- const [pushState, setPushState] = useState<
43
- 'loading' | 'unsupported' | 'denied' | 'subscribed' | 'unsubscribed'
44
- >('loading');
45
-
46
- function urlBase64ToUint8Array(base64String: string) {
47
- const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
48
- const base64 = (base64String + padding)
49
- .replace(/-/g, '+')
50
- .replace(/_/g, '/');
51
- const raw = atob(base64);
52
- const arr = new Uint8Array(raw.length);
53
- for (let i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i);
54
- return arr;
14
+ const clientRef = useRef<WsClient | null>(null);
15
+ const [connected, setConnected] = useState(false);
16
+ const [botName, setBotName] = useState('Fluxy');
17
+ const [whisperEnabled, setWhisperEnabled] = useState(false);
18
+ const [menuOpen, setMenuOpen] = useState(false);
19
+ const [showWizard, setShowWizard] = useState(false);
20
+ const [reloadTrigger, setReloadTrigger] = useState(0);
21
+ const menuRef = useRef<HTMLDivElement>(null);
22
+ const wasConnected = useRef(false);
23
+
24
+ // Push notifications
25
+ const [pushState, setPushState] = useState<'loading' | 'unsupported' | 'denied' | 'subscribed' | 'unsubscribed'>('loading');
26
+
27
+ function urlBase64ToUint8Array(base64String: string) {
28
+ const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
29
+ const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
30
+ const raw = atob(base64);
31
+ const arr = new Uint8Array(raw.length);
32
+ for (let i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i);
33
+ return arr;
34
+ }
35
+
36
+ async function subscribePush() {
37
+ try {
38
+ const permission = await Notification.requestPermission();
39
+ if (permission !== 'granted') { setPushState('denied'); return; }
40
+
41
+ const reg = await navigator.serviceWorker.ready;
42
+ const res = await fetch('/api/push/vapid-public-key');
43
+ const { publicKey } = await res.json();
44
+
45
+ const subscription = await reg.pushManager.subscribe({
46
+ userVisibleOnly: true,
47
+ applicationServerKey: urlBase64ToUint8Array(publicKey),
48
+ });
49
+
50
+ const sub = subscription.toJSON();
51
+ await authFetch('/api/push/subscribe', {
52
+ method: 'POST',
53
+ headers: { 'Content-Type': 'application/json' },
54
+ body: JSON.stringify({ endpoint: sub.endpoint, keys: sub.keys }),
55
+ });
56
+
57
+ setPushState('subscribed');
58
+ } catch (err) {
59
+ console.error('[push] Subscribe failed:', err);
55
60
  }
56
-
57
- async function subscribePush() {
58
- try {
59
- const permission = await Notification.requestPermission();
60
- if (permission !== 'granted') {
61
- setPushState('denied');
62
- return;
63
- }
64
-
65
- const reg = await navigator.serviceWorker.ready;
66
- const res = await fetch('/api/push/vapid-public-key');
67
- const { publicKey } = await res.json();
68
-
69
- const subscription = await reg.pushManager.subscribe({
70
- userVisibleOnly: true,
71
- applicationServerKey: urlBase64ToUint8Array(publicKey)
72
- });
73
-
74
- const sub = subscription.toJSON();
75
- await authFetch('/api/push/subscribe', {
76
- method: 'POST',
77
- headers: { 'Content-Type': 'application/json' },
78
- body: JSON.stringify({ endpoint: sub.endpoint, keys: sub.keys })
79
- });
80
-
81
- setPushState('subscribed');
82
- } catch (err) {
83
- console.error('[push] Subscribe failed:', err);
84
- }
61
+ }
62
+
63
+ async function unsubscribePush() {
64
+ try {
65
+ const reg = await navigator.serviceWorker.ready;
66
+ const subscription = await reg.pushManager.getSubscription();
67
+ if (subscription) {
68
+ const endpoint = subscription.endpoint;
69
+ await subscription.unsubscribe();
70
+ await authFetch('/api/push/unsubscribe', {
71
+ method: 'DELETE',
72
+ headers: { 'Content-Type': 'application/json' },
73
+ body: JSON.stringify({ endpoint }),
74
+ });
75
+ }
76
+ setPushState('unsubscribed');
77
+ } catch (err) {
78
+ console.error('[push] Unsubscribe failed:', err);
85
79
  }
86
-
87
- async function unsubscribePush() {
88
- try {
89
- const reg = await navigator.serviceWorker.ready;
90
- const subscription = await reg.pushManager.getSubscription();
91
- if (subscription) {
92
- const endpoint = subscription.endpoint;
93
- await subscription.unsubscribe();
94
- await authFetch('/api/push/unsubscribe', {
95
- method: 'DELETE',
96
- headers: { 'Content-Type': 'application/json' },
97
- body: JSON.stringify({ endpoint })
98
- });
99
- }
100
- setPushState('unsubscribed');
101
- } catch (err) {
102
- console.error('[push] Unsubscribe failed:', err);
80
+ }
81
+
82
+ // Install App (PWA)
83
+ const [showIosModal, setShowIosModal] = useState(false);
84
+ const isIos = /iPad|iPhone|iPod/.test(navigator.userAgent);
85
+ const isStandalone = window.matchMedia('(display-mode: standalone)').matches || (navigator as any).standalone;
86
+ const isMobile = /Android|iPhone|iPad|iPod|webOS|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
87
+ const isInIframe = window.self !== window.top;
88
+
89
+ // Listen for install result from parent (when in iframe)
90
+ useEffect(() => {
91
+ const handler = (e: MessageEvent) => {
92
+ if (e.data?.type === 'fluxy:show-ios-install') {
93
+ setShowIosModal(true);
94
+ }
95
+ };
96
+ window.addEventListener('message', handler);
97
+ return () => window.removeEventListener('message', handler);
98
+ }, []);
99
+
100
+ // Auth state
101
+ const [authChecked, setAuthChecked] = useState(false);
102
+ const [authRequired, setAuthRequired] = useState(false);
103
+ const [authenticated, setAuthenticated] = useState(false);
104
+ const [totpEnabled, setTotpEnabled] = useState(false);
105
+
106
+ // Check auth on mount
107
+ useEffect(() => {
108
+ (async () => {
109
+ try {
110
+ const res = await fetch('/api/onboard/status');
111
+ const data = await res.json();
112
+ if (data.totpEnabled) setTotpEnabled(true);
113
+
114
+ if (!data.portalConfigured) {
115
+ // No password set — skip auth entirely
116
+ setAuthRequired(false);
117
+ setAuthenticated(true);
118
+ setAuthChecked(true);
119
+ return;
103
120
  }
104
- }
105
121
 
106
- // Install App (PWA)
107
- const [showIosModal, setShowIosModal] = useState(false);
108
- const isIos = /iPad|iPhone|iPod/.test(navigator.userAgent);
109
- const isStandalone =
110
- window.matchMedia('(display-mode: standalone)').matches ||
111
- (navigator as any).standalone;
112
- const isMobile =
113
- /Android|iPhone|iPad|iPod|webOS|BlackBerry|IEMobile|Opera Mini/i.test(
114
- navigator.userAgent
115
- );
116
- const isInIframe = window.self !== window.top;
117
-
118
- // Listen for install result from parent (when in iframe)
119
- useEffect(() => {
120
- const handler = (e: MessageEvent) => {
121
- if (e.data?.type === 'fluxy:show-ios-install') {
122
- setShowIosModal(true);
123
- }
124
- };
125
- window.addEventListener('message', handler);
126
- return () => window.removeEventListener('message', handler);
127
- }, []);
128
-
129
- // Auth state
130
- const [authChecked, setAuthChecked] = useState(false);
131
- const [authRequired, setAuthRequired] = useState(false);
132
- const [authenticated, setAuthenticated] = useState(false);
133
- const [totpEnabled, setTotpEnabled] = useState(false);
134
-
135
- // Check auth on mount
136
- useEffect(() => {
137
- (async () => {
138
- try {
139
- const res = await fetch('/api/onboard/status');
140
- const data = await res.json();
141
- if (data.totpEnabled) setTotpEnabled(true);
142
-
143
- if (!data.portalConfigured) {
144
- // No password set — skip auth entirely
145
- setAuthRequired(false);
146
- setAuthenticated(true);
147
- setAuthChecked(true);
148
- return;
149
- }
150
-
151
- setAuthRequired(true);
152
-
153
- // Check if we have a valid token in localStorage
154
- const token = getAuthToken();
155
- if (token) {
156
- const vRes = await fetch(
157
- `/api/portal/validate-token?token=${encodeURIComponent(token)}`
158
- );
159
- const vData = await vRes.json();
160
- if (vData.valid) {
161
- setAuthenticated(true);
162
- setAuthChecked(true);
163
- return;
164
- }
165
- clearAuthToken();
166
- }
167
-
168
- setAuthenticated(false);
169
- setAuthChecked(true);
170
- } catch {
171
- // Worker not ready — skip auth, let it retry later
172
- setAuthenticated(true);
173
- setAuthChecked(true);
174
- }
175
- })();
176
- }, []);
122
+ setAuthRequired(true);
123
+
124
+ // Check if we have a valid token in localStorage
125
+ const token = getAuthToken();
126
+ if (token) {
127
+ const vRes = await fetch(`/api/portal/validate-token?token=${encodeURIComponent(token)}`);
128
+ const vData = await vRes.json();
129
+ if (vData.valid) {
130
+ setAuthenticated(true);
131
+ setAuthChecked(true);
132
+ return;
133
+ }
134
+ clearAuthToken();
135
+ }
177
136
 
178
- const handleLogin = (token: string) => {
179
- setAuthToken(token);
137
+ setAuthenticated(false);
138
+ setAuthChecked(true);
139
+ } catch {
140
+ // Worker not ready — skip auth, let it retry later
180
141
  setAuthenticated(true);
142
+ setAuthChecked(true);
143
+ }
144
+ })();
145
+ }, []);
146
+
147
+ const handleLogin = (token: string) => {
148
+ setAuthToken(token);
149
+ setAuthenticated(true);
150
+ };
151
+
152
+ // Handle mid-session token expiry (authFetch gets 401)
153
+ useEffect(() => {
154
+ onAuthFailure(() => {
155
+ setAuthenticated(false);
156
+ setAuthRequired(true);
157
+ });
158
+ }, []);
159
+
160
+ // Check push state on mount
161
+ useEffect(() => {
162
+ if (!authenticated) return;
163
+ (async () => {
164
+ if (!('PushManager' in window) || !('serviceWorker' in navigator)) {
165
+ setPushState('unsupported');
166
+ return;
167
+ }
168
+ if (Notification.permission === 'denied') {
169
+ setPushState('denied');
170
+ return;
171
+ }
172
+ try {
173
+ const reg = await navigator.serviceWorker.ready;
174
+ const subscription = await reg.pushManager.getSubscription();
175
+ if (subscription) {
176
+ const res = await fetch(`/api/push/status?endpoint=${encodeURIComponent(subscription.endpoint)}`);
177
+ const data = await res.json();
178
+ setPushState(data.subscribed ? 'subscribed' : 'unsubscribed');
179
+ } else {
180
+ setPushState('unsubscribed');
181
+ }
182
+ } catch {
183
+ setPushState('unsupported');
184
+ }
185
+ })();
186
+ }, [authenticated]);
187
+
188
+ // Connect WebSocket only when authenticated
189
+ useEffect(() => {
190
+ if (!authenticated) return;
191
+
192
+ const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
193
+ const host = location.host;
194
+ const client = new WsClient(`${proto}//${host}/fluxy/ws`, getAuthToken);
195
+ clientRef.current = client;
196
+
197
+ const unsub = client.onStatus((isConnected) => {
198
+ setConnected(isConnected);
199
+ // On reconnect, trigger a reload from DB to catch missed messages
200
+ if (isConnected && wasConnected.current) {
201
+ setReloadTrigger((n) => n + 1);
202
+ }
203
+ wasConnected.current = isConnected;
204
+ });
205
+
206
+ // Forward rebuild/HMR events to parent (dashboard) via postMessage
207
+ const unsubRebuilding = client.on('app:rebuilding', () => {
208
+ console.log('[fluxy] app:rebuilding received');
209
+ window.parent?.postMessage({ type: 'fluxy:rebuilding' }, '*');
210
+ });
211
+ const unsubRebuilt = client.on('app:rebuilt', () => {
212
+ console.log('[fluxy] app:rebuilt received');
213
+ window.parent?.postMessage({ type: 'fluxy:rebuilt' }, '*');
214
+ });
215
+ const unsubBuildError = client.on('app:build-error', (data: { error: string }) => {
216
+ console.log('[fluxy] app:build-error received:', data.error);
217
+ window.parent?.postMessage({ type: 'fluxy:build-error', error: data.error }, '*');
218
+ });
219
+ const unsubHmr = client.on('app:hmr-update', () => {
220
+ console.log('[fluxy] Vite HMR update — changes applied automatically');
221
+ window.parent?.postMessage({ type: 'fluxy:hmr-update' }, '*');
222
+ });
223
+
224
+ client.connect();
225
+
226
+ return () => {
227
+ unsub();
228
+ unsubRebuilding();
229
+ unsubRebuilt();
230
+ unsubBuildError();
231
+ unsubHmr();
232
+ client.disconnect();
181
233
  };
234
+ }, [authenticated]);
235
+
236
+ // Try to load settings (will work when worker is up, fail silently when down)
237
+ useEffect(() => {
238
+ if (!authenticated) return;
239
+ authFetch('/api/settings')
240
+ .then((r) => r.json())
241
+ .then((s) => {
242
+ if (s.agent_name) setBotName(s.agent_name);
243
+ if (s.whisper_enabled === 'true') setWhisperEnabled(true);
244
+ })
245
+ .catch(() => {});
246
+ }, [authenticated]);
247
+
248
+ // Close menu on outside click
249
+ useEffect(() => {
250
+ if (!menuOpen) return;
251
+ const handler = (e: MouseEvent) => {
252
+ if (menuRef.current && !menuRef.current.contains(e.target as Node)) setMenuOpen(false);
253
+ };
254
+ document.addEventListener('mousedown', handler);
255
+ return () => document.removeEventListener('mousedown', handler);
256
+ }, [menuOpen]);
182
257
 
183
- // Handle mid-session token expiry (authFetch gets 401)
184
- useEffect(() => {
185
- onAuthFailure(() => {
186
- setAuthenticated(false);
187
- setAuthRequired(true);
188
- });
189
- }, []);
190
-
191
- // Check push state on mount
192
- useEffect(() => {
193
- if (!authenticated) return;
194
- (async () => {
195
- if (!('PushManager' in window) || !('serviceWorker' in navigator)) {
196
- setPushState('unsupported');
197
- return;
198
- }
199
- if (Notification.permission === 'denied') {
200
- setPushState('denied');
201
- return;
202
- }
203
- try {
204
- const reg = await navigator.serviceWorker.ready;
205
- const subscription = await reg.pushManager.getSubscription();
206
- if (subscription) {
207
- const res = await fetch(
208
- `/api/push/status?endpoint=${encodeURIComponent(subscription.endpoint)}`
209
- );
210
- const data = await res.json();
211
- setPushState(
212
- data.subscribed ? 'subscribed' : 'unsubscribed'
213
- );
214
- } else {
215
- setPushState('unsubscribed');
216
- }
217
- } catch {
218
- setPushState('unsupported');
219
- }
220
- })();
221
- }, [authenticated]);
222
-
223
- // Connect WebSocket only when authenticated
224
- useEffect(() => {
225
- if (!authenticated) return;
226
-
227
- const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
228
- const host = location.host;
229
- const client = new WsClient(`${proto}//${host}/fluxy/ws`, getAuthToken);
230
- clientRef.current = client;
231
-
232
- const unsub = client.onStatus(isConnected => {
233
- setConnected(isConnected);
234
- // On reconnect, trigger a reload from DB to catch missed messages
235
- if (isConnected && wasConnected.current) {
236
- setReloadTrigger(n => n + 1);
237
- }
238
- wasConnected.current = isConnected;
239
- });
240
-
241
- // Forward rebuild/HMR events to parent (dashboard) via postMessage
242
- const unsubRebuilding = client.on('app:rebuilding', () => {
243
- console.log('[fluxy] app:rebuilding received');
244
- window.parent?.postMessage({ type: 'fluxy:rebuilding' }, '*');
245
- });
246
- const unsubRebuilt = client.on('app:rebuilt', () => {
247
- console.log('[fluxy] app:rebuilt received');
248
- window.parent?.postMessage({ type: 'fluxy:rebuilt' }, '*');
249
- });
250
- const unsubBuildError = client.on(
251
- 'app:build-error',
252
- (data: { error: string }) => {
253
- console.log('[fluxy] app:build-error received:', data.error);
254
- window.parent?.postMessage(
255
- { type: 'fluxy:build-error', error: data.error },
256
- '*'
257
- );
258
- }
259
- );
260
- const unsubHmr = client.on('app:hmr-update', () => {
261
- console.log(
262
- '[fluxy] Vite HMR update — changes applied automatically'
263
- );
264
- window.parent?.postMessage({ type: 'fluxy:hmr-update' }, '*');
265
- });
266
-
267
- client.connect();
268
-
269
- return () => {
270
- unsub();
271
- unsubRebuilding();
272
- unsubRebuilt();
273
- unsubBuildError();
274
- unsubHmr();
275
- client.disconnect();
276
- };
277
- }, [authenticated]);
278
-
279
- // Try to load settings (will work when worker is up, fail silently when down)
280
- useEffect(() => {
281
- if (!authenticated) return;
282
- authFetch('/api/settings')
283
- .then(r => r.json())
284
- .then(s => {
285
- if (s.agent_name) setBotName(s.agent_name);
286
- if (s.whisper_enabled === 'true') setWhisperEnabled(true);
287
- })
288
- .catch(() => {});
289
- }, [authenticated]);
290
-
291
- // Close menu on outside click
292
- useEffect(() => {
293
- if (!menuOpen) return;
294
- const handler = (e: MouseEvent) => {
295
- if (menuRef.current && !menuRef.current.contains(e.target as Node))
296
- setMenuOpen(false);
297
- };
298
- document.addEventListener('mousedown', handler);
299
- return () => document.removeEventListener('mousedown', handler);
300
- }, [menuOpen]);
301
-
302
- const {
303
- messages,
304
- streaming,
305
- streamBuffer,
306
- tools,
307
- hasMore,
308
- loadOlder,
309
- sendMessage,
310
- stopStreaming,
311
- clearContext
312
- } = useFluxyChat(clientRef.current, reloadTrigger, authenticated);
313
-
314
- // Auth gate: show spinner while checking, login screen if needed
315
- if (!authChecked) {
316
- return (
317
- <div className="flex items-center justify-center h-dvh">
318
- <div className="w-6 h-6 border-2 border-white/10 border-t-white/50 rounded-full animate-spin" />
319
- </div>
320
- );
321
- }
322
-
323
- if (authRequired && !authenticated) {
324
- return <LoginScreen onLogin={handleLogin} totpEnabled={totpEnabled} />;
325
- }
258
+ const { messages, streaming, streamBuffer, tools, hasMore, loadOlder, sendMessage, stopStreaming, clearContext } =
259
+ useFluxyChat(clientRef.current, reloadTrigger, authenticated);
326
260
 
261
+ // Auth gate: show spinner while checking, login screen if needed
262
+ if (!authChecked) {
327
263
  return (
328
- <div className="flex flex-col h-dvh overflow-hidden">
329
- {/* Header */}
330
- <div className="flex items-center gap-3 px-4 py-3 border-b border-border shrink-0">
264
+ <div className="flex items-center justify-center h-dvh">
265
+ <div className="w-6 h-6 border-2 border-white/10 border-t-white/50 rounded-full animate-spin" />
266
+ </div>
267
+ );
268
+ }
269
+
270
+ if (authRequired && !authenticated) {
271
+ return <LoginScreen onLogin={handleLogin} totpEnabled={totpEnabled} />;
272
+ }
273
+
274
+ return (
275
+ <div className="flex flex-col h-dvh overflow-hidden">
276
+ {/* Header */}
277
+ <div className="flex items-center gap-3 px-4 py-3 border-b border-border shrink-0">
278
+ <button
279
+ onClick={() => window.parent?.postMessage({ type: 'fluxy:close' }, '*')}
280
+ className="flex items-center justify-center h-7 w-7 -ml-1 rounded-full text-muted-foreground hover:text-foreground hover:bg-white/[0.06] transition-colors"
281
+ aria-label="Close chat"
282
+ >
283
+ <ArrowLeft className="h-5 w-5" />
284
+ </button>
285
+ <img src="/fluxy.png" alt={botName} className="h-5 w-auto" />
286
+ <span className="text-sm font-semibold">{botName}</span>
287
+ <div className={`h-2 w-2 rounded-full ${connected ? 'bg-green-500' : 'bg-red-500'}`} />
288
+ {pushState !== 'loading' && pushState !== 'unsupported' && (
289
+ <button
290
+ onClick={() => {
291
+ if (pushState === 'subscribed') unsubscribePush();
292
+ else if (pushState === 'unsubscribed') subscribePush();
293
+ }}
294
+ disabled={pushState === 'denied'}
295
+ className="flex items-center justify-center h-7 w-7 rounded-full text-muted-foreground hover:text-foreground hover:bg-white/[0.06] transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
296
+ title={
297
+ pushState === 'denied' ? 'Notifications blocked — enable in browser settings' :
298
+ pushState === 'subscribed' ? 'Disable push notifications' : 'Enable push notifications'
299
+ }
300
+ >
301
+ {pushState === 'denied' ? (
302
+ <BellOff className="h-4 w-4" />
303
+ ) : pushState === 'subscribed' ? (
304
+ <BellRing className="h-4 w-4 text-blue-400" />
305
+ ) : (
306
+ <Bell className="h-4 w-4" />
307
+ )}
308
+ </button>
309
+ )}
310
+ <div className="flex-1" />
311
+ <div className="relative" ref={menuRef}>
312
+ <button
313
+ onClick={() => setMenuOpen((v) => !v)}
314
+ className="flex items-center justify-center h-7 w-7 rounded-full text-muted-foreground hover:text-foreground hover:bg-white/[0.06] transition-colors"
315
+ >
316
+ <MoreVertical className="h-4 w-4" />
317
+ </button>
318
+ {menuOpen && (
319
+ <div className="absolute right-0 top-full mt-1 min-w-[160px] rounded-md border border-border bg-popover py-1 shadow-lg z-50">
320
+ <button
321
+ onClick={() => { setShowWizard(true); setMenuOpen(false); }}
322
+ className="flex w-full items-center gap-2 px-3 py-2 text-sm text-muted-foreground hover:text-foreground hover:bg-white/[0.06] transition-colors"
323
+ >
324
+ <Wand2 className="h-4 w-4" />
325
+ Setup Wizard
326
+ </button>
327
+ <button
328
+ onClick={() => { clearContext(); setMenuOpen(false); }}
329
+ className="flex w-full items-center gap-2 px-3 py-2 text-sm text-muted-foreground hover:text-foreground hover:bg-white/[0.06] transition-colors"
330
+ >
331
+ <Trash2 className="h-4 w-4" />
332
+ Clear context
333
+ </button>
334
+ {isMobile && !isStandalone && (
331
335
  <button
332
- onClick={() =>
333
- window.parent?.postMessage({ type: 'fluxy:close' }, '*')
336
+ onClick={() => {
337
+ setMenuOpen(false);
338
+ if (isInIframe) {
339
+ // Ask parent window to handle install
340
+ window.parent.postMessage({ type: 'fluxy:install-app' }, '*');
341
+ } else {
342
+ // Direct page — show iOS instructions (beforeinstallprompt won't help here)
343
+ setShowIosModal(true);
334
344
  }
335
- className="flex items-center justify-center h-7 w-7 -ml-1 rounded-full text-muted-foreground hover:text-foreground hover:bg-white/[0.06] transition-colors"
336
- aria-label="Close chat"
345
+ }}
346
+ className="flex w-full items-center gap-2 px-3 py-2 text-sm text-muted-foreground hover:text-foreground hover:bg-white/[0.06] transition-colors"
337
347
  >
338
- <ArrowLeft className="h-5 w-5" />
348
+ <Download className="h-4 w-4" />
349
+ Install App
339
350
  </button>
340
- <img src="/fluxy.png" alt={botName} className="h-5 w-auto" />
341
- <span className="text-sm font-semibold">{botName}</span>
342
- <div
343
- className={`h-2 w-2 rounded-full ${connected ? 'bg-green-500' : 'bg-red-500'}`}
344
- />
345
- {pushState !== 'loading' && pushState !== 'unsupported' && (
346
- <button
347
- onClick={() => {
348
- if (pushState === 'subscribed') unsubscribePush();
349
- else if (pushState === 'unsubscribed')
350
- subscribePush();
351
- }}
352
- disabled={pushState === 'denied'}
353
- className="flex items-center justify-center h-7 w-7 rounded-full text-muted-foreground hover:text-foreground hover:bg-white/[0.06] transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
354
- title={
355
- pushState === 'denied'
356
- ? 'Notifications blocked — enable in browser settings'
357
- : pushState === 'subscribed'
358
- ? 'Disable push notifications'
359
- : 'Enable push notifications'
360
- }
361
- >
362
- {pushState === 'denied' ? (
363
- <BellOff className="h-4 w-4" />
364
- ) : pushState === 'subscribed' ? (
365
- <BellRing className="h-4 w-4 text-blue-400" />
366
- ) : (
367
- <Bell className="h-4 w-4" />
368
- )}
369
- </button>
370
- )}
371
- <div className="flex-1" />
372
- <div className="relative" ref={menuRef}>
373
- <button
374
- onClick={() => setMenuOpen(v => !v)}
375
- className="flex items-center justify-center h-7 w-7 rounded-full text-muted-foreground hover:text-foreground hover:bg-white/[0.06] transition-colors"
376
- >
377
- <MoreVertical className="h-4 w-4" />
378
- </button>
379
- {menuOpen && (
380
- <div className="absolute right-0 top-full mt-1 min-w-[160px] rounded-md border border-border bg-popover py-1 shadow-lg z-50">
381
- <button
382
- onClick={() => {
383
- setShowWizard(true);
384
- setMenuOpen(false);
385
- }}
386
- className="flex w-full items-center gap-2 px-3 py-2 text-sm text-muted-foreground hover:text-foreground hover:bg-white/[0.06] transition-colors"
387
- >
388
- <Wand2 className="h-4 w-4" />
389
- Setup Wizard
390
- </button>
391
- <button
392
- onClick={() => {
393
- clearContext();
394
- setMenuOpen(false);
395
- }}
396
- className="flex w-full items-center gap-2 px-3 py-2 text-sm text-muted-foreground hover:text-foreground hover:bg-white/[0.06] transition-colors"
397
- >
398
- <Trash2 className="h-4 w-4" />
399
- Clear context
400
- </button>
401
- {isMobile && !isStandalone && (
402
- <button
403
- onClick={() => {
404
- setMenuOpen(false);
405
- if (isInIframe) {
406
- // Ask parent window to handle install
407
- window.parent.postMessage(
408
- { type: 'fluxy:install-app' },
409
- '*'
410
- );
411
- } else {
412
- // Direct page — show iOS instructions (beforeinstallprompt won't help here)
413
- setShowIosModal(true);
414
- }
415
- }}
416
- className="flex w-full items-center gap-2 px-3 py-2 text-sm text-muted-foreground hover:text-foreground hover:bg-white/[0.06] transition-colors"
417
- >
418
- <Download className="h-4 w-4" />
419
- Install App
420
- </button>
421
- )}
422
- </div>
423
- )}
424
- </div>
351
+ )}
425
352
  </div>
426
-
427
- {/* Chat body */}
428
- <div className="flex-1 min-h-0 flex flex-col overflow-hidden">
429
- <MessageList
430
- messages={messages}
431
- streaming={streaming}
432
- streamBuffer={streamBuffer}
433
- tools={tools}
434
- hasMore={hasMore}
435
- onLoadOlder={loadOlder}
436
- />
437
- <InputBar
438
- onSend={sendMessage}
439
- onStop={stopStreaming}
440
- streaming={streaming}
441
- whisperEnabled={whisperEnabled}
442
- onTranscribe={audio => {
443
- return new Promise((resolve, reject) => {
444
- const client = clientRef.current;
445
- if (!client?.connected) {
446
- reject(new Error('WebSocket not connected'));
447
- return;
448
- }
449
- const unsub = client.on('whisper:result', data => {
450
- unsub();
451
- clearTimeout(timer);
452
- resolve(data);
453
- });
454
- const timer = setTimeout(() => {
455
- unsub();
456
- reject(new Error('Transcription timeout'));
457
- }, 30000);
458
- client.send('whisper:transcribe', { audio });
459
- });
460
- }}
461
- />
353
+ )}
354
+ </div>
355
+ </div>
356
+
357
+ {/* Chat body */}
358
+ <div className="flex-1 min-h-0 flex flex-col overflow-hidden">
359
+ <MessageList messages={messages} streaming={streaming} streamBuffer={streamBuffer} tools={tools} hasMore={hasMore} onLoadOlder={loadOlder} />
360
+ <InputBar
361
+ onSend={sendMessage}
362
+ onStop={stopStreaming}
363
+ streaming={streaming}
364
+ whisperEnabled={whisperEnabled}
365
+ onTranscribe={(audio) => {
366
+ return new Promise((resolve, reject) => {
367
+ const client = clientRef.current;
368
+ if (!client?.connected) { reject(new Error('WebSocket not connected')); return; }
369
+ const unsub = client.on('whisper:result', (data) => {
370
+ unsub();
371
+ clearTimeout(timer);
372
+ resolve(data);
373
+ });
374
+ const timer = setTimeout(() => { unsub(); reject(new Error('Transcription timeout')); }, 30000);
375
+ client.send('whisper:transcribe', { audio });
376
+ });
377
+ }}
378
+ />
379
+ </div>
380
+
381
+ {/* Setup Wizard overlay */}
382
+ {showWizard && (
383
+ <OnboardWizard
384
+ onSave={(payload) => {
385
+ return new Promise((resolve, reject) => {
386
+ const client = clientRef.current;
387
+ if (!client?.connected) {
388
+ reject(new Error('WebSocket not connected'));
389
+ return;
390
+ }
391
+ const unsub = client.on('settings:saved', (data) => {
392
+ unsub();
393
+ clearTimeout(timer);
394
+ resolve(data);
395
+ });
396
+ const unsubErr = client.on('settings:save-error', (data) => {
397
+ unsubErr();
398
+ clearTimeout(timer);
399
+ reject(new Error(data.error || 'Save failed'));
400
+ });
401
+ const timer = setTimeout(() => { unsub(); unsubErr(); reject(new Error('Save timeout')); }, 10000);
402
+ client.send('settings:save', payload);
403
+ });
404
+ }}
405
+ onTunnelSwitch={(newMode) => {
406
+ return new Promise((resolve, reject) => {
407
+ const client = clientRef.current;
408
+ if (!client?.connected) { reject(new Error('Not connected')); return; }
409
+ const unsub = client.on('tunnel:switched', (data) => { unsub(); unsubErr(); clearTimeout(t); resolve(data); });
410
+ const unsubErr = client.on('tunnel:switch-error', (data) => { unsub(); unsubErr(); clearTimeout(t); reject(new Error(data.error)); });
411
+ const t = setTimeout(() => { unsub(); unsubErr(); reject(new Error('Timeout')); }, 30000);
412
+ client.send('tunnel:switch', { mode: newMode });
413
+ });
414
+ }}
415
+ onComplete={() => {
416
+ setShowWizard(false);
417
+ // Reload settings (bot name, whisper, etc.)
418
+ authFetch('/api/settings')
419
+ .then((r) => r.json())
420
+ .then((s) => {
421
+ if (s.agent_name) setBotName(s.agent_name);
422
+ setWhisperEnabled(s.whisper_enabled === 'true');
423
+ })
424
+ .catch(() => {});
425
+ // Notify dashboard so it can refresh
426
+ window.parent?.postMessage({ type: 'fluxy:onboard-complete' }, '*');
427
+ }}
428
+ />
429
+ )}
430
+
431
+ {/* iOS Install Instructions Modal */}
432
+ {showIosModal && (
433
+ <div className="fixed inset-0 z-[300] flex items-end sm:items-center justify-center p-4">
434
+ <div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setShowIosModal(false)} />
435
+ <div className="relative w-full max-w-[360px] bg-[#1c1c1e] border border-white/[0.08] rounded-2xl shadow-2xl overflow-hidden">
436
+ <div className="flex items-center justify-between px-5 pt-5 pb-2">
437
+ <h2 className="text-[17px] font-semibold text-white">Install App</h2>
438
+ <button
439
+ onClick={() => setShowIosModal(false)}
440
+ className="h-7 w-7 rounded-full bg-white/[0.08] flex items-center justify-center text-white/50 hover:text-white/80 transition-colors"
441
+ >
442
+ <X className="h-4 w-4" />
443
+ </button>
462
444
  </div>
463
-
464
- {/* Setup Wizard overlay */}
465
- {showWizard && (
466
- <OnboardWizard
467
- onSave={payload => {
468
- return new Promise((resolve, reject) => {
469
- const client = clientRef.current;
470
- if (!client?.connected) {
471
- reject(new Error('WebSocket not connected'));
472
- return;
473
- }
474
- const unsub = client.on('settings:saved', data => {
475
- unsub();
476
- clearTimeout(timer);
477
- resolve(data);
478
- });
479
- const unsubErr = client.on(
480
- 'settings:save-error',
481
- data => {
482
- unsubErr();
483
- clearTimeout(timer);
484
- reject(
485
- new Error(data.error || 'Save failed')
486
- );
487
- }
488
- );
489
- const timer = setTimeout(() => {
490
- unsub();
491
- unsubErr();
492
- reject(new Error('Save timeout'));
493
- }, 10000);
494
- client.send('settings:save', payload);
495
- });
496
- }}
497
- onTunnelSwitch={newMode => {
498
- return new Promise((resolve, reject) => {
499
- const client = clientRef.current;
500
- if (!client?.connected) {
501
- reject(new Error('Not connected'));
502
- return;
503
- }
504
- const unsub = client.on('tunnel:switched', data => {
505
- unsub();
506
- unsubErr();
507
- clearTimeout(t);
508
- resolve(data);
509
- });
510
- const unsubErr = client.on(
511
- 'tunnel:switch-error',
512
- data => {
513
- unsub();
514
- unsubErr();
515
- clearTimeout(t);
516
- reject(new Error(data.error));
517
- }
518
- );
519
- const t = setTimeout(() => {
520
- unsub();
521
- unsubErr();
522
- reject(new Error('Timeout'));
523
- }, 30000);
524
- client.send('tunnel:switch', { mode: newMode });
525
- });
526
- }}
527
- onComplete={() => {
528
- setShowWizard(false);
529
- // Reload settings (bot name, whisper, etc.)
530
- authFetch('/api/settings')
531
- .then(r => r.json())
532
- .then(s => {
533
- if (s.agent_name) setBotName(s.agent_name);
534
- setWhisperEnabled(s.whisper_enabled === 'true');
535
- })
536
- .catch(() => {});
537
- // Notify dashboard so it can refresh
538
- window.parent?.postMessage(
539
- { type: 'fluxy:onboard-complete' },
540
- '*'
541
- );
542
- }}
543
- />
544
- )}
545
-
546
- {/* iOS Install Instructions Modal */}
547
- {showIosModal && (
548
- <div className="fixed inset-0 z-[300] flex items-end sm:items-center justify-center p-4">
549
- <div
550
- role="presentation"
551
- className="absolute inset-0 bg-black/60 backdrop-blur-sm"
552
- onClick={() => setShowIosModal(false)}
553
- />
554
- <div className="relative w-full max-w-[360px] bg-[#1c1c1e] border border-white/[0.08] rounded-2xl shadow-2xl overflow-hidden">
555
- <div className="flex items-center justify-between px-5 pt-5 pb-2">
556
- <h2 className="text-[17px] font-semibold text-white">
557
- Install App
558
- </h2>
559
- <button
560
- onClick={() => setShowIosModal(false)}
561
- className="h-7 w-7 rounded-full bg-white/[0.08] flex items-center justify-center text-white/50 hover:text-white/80 transition-colors"
562
- >
563
- <X className="h-4 w-4" />
564
- </button>
565
- </div>
566
- <div className="px-5 pb-6 pt-1">
567
- <p className="text-white/50 text-[13px] mb-5 leading-relaxed">
568
- Add Fluxy to your home screen for a full-screen
569
- app experience.
570
- </p>
571
- {isIos ? (
572
- <div className="space-y-3.5">
573
- <div className="flex items-start gap-3">
574
- <span className="flex-shrink-0 w-7 h-7 rounded-full bg-[#007AFF]/15 text-[#007AFF] text-[13px] font-semibold flex items-center justify-center mt-0.5">
575
- 1
576
- </span>
577
- <div className="flex-1">
578
- <p className="text-white text-[14px] font-medium">
579
- Tap the Share button
580
- </p>
581
- <p className="text-white/40 text-[12px] mt-0.5 flex items-center gap-1.5">
582
- The{' '}
583
- <Share className="h-3.5 w-3.5 inline" />{' '}
584
- icon at the bottom of Safari
585
- </p>
586
- </div>
587
- </div>
588
- <div className="flex items-start gap-3">
589
- <span className="flex-shrink-0 w-7 h-7 rounded-full bg-[#007AFF]/15 text-[#007AFF] text-[13px] font-semibold flex items-center justify-center mt-0.5">
590
- 2
591
- </span>
592
- <div className="flex-1">
593
- <p className="text-white text-[14px] font-medium">
594
- Scroll down and tap
595
- </p>
596
- <p className="text-white/40 text-[12px] mt-0.5">
597
- "Add to Home Screen"
598
- </p>
599
- </div>
600
- </div>
601
- <div className="flex items-start gap-3">
602
- <span className="flex-shrink-0 w-7 h-7 rounded-full bg-[#007AFF]/15 text-[#007AFF] text-[13px] font-semibold flex items-center justify-center mt-0.5">
603
- 3
604
- </span>
605
- <div className="flex-1">
606
- <p className="text-white text-[14px] font-medium">
607
- Tap "Add"
608
- </p>
609
- <p className="text-white/40 text-[12px] mt-0.5">
610
- Fluxy will appear on your home
611
- screen
612
- </p>
613
- </div>
614
- </div>
615
- </div>
616
- ) : (
617
- <div className="space-y-3.5">
618
- <div className="flex items-start gap-3">
619
- <span className="flex-shrink-0 w-7 h-7 rounded-full bg-[#007AFF]/15 text-[#007AFF] text-[13px] font-semibold flex items-center justify-center mt-0.5">
620
- 1
621
- </span>
622
- <div className="flex-1">
623
- <p className="text-white text-[14px] font-medium">
624
- Open browser menu
625
- </p>
626
- <p className="text-white/40 text-[12px] mt-0.5">
627
- Tap the three-dot menu in your
628
- browser
629
- </p>
630
- </div>
631
- </div>
632
- <div className="flex items-start gap-3">
633
- <span className="flex-shrink-0 w-7 h-7 rounded-full bg-[#007AFF]/15 text-[#007AFF] text-[13px] font-semibold flex items-center justify-center mt-0.5">
634
- 2
635
- </span>
636
- <div className="flex-1">
637
- <p className="text-white text-[14px] font-medium">
638
- Tap "Install app" or "Add to
639
- Home screen"
640
- </p>
641
- <p className="text-white/40 text-[12px] mt-0.5">
642
- Fluxy will be installed as a
643
- standalone app
644
- </p>
645
- </div>
646
- </div>
647
- </div>
648
- )}
649
- <button
650
- onClick={() => setShowIosModal(false)}
651
- className="w-full mt-5 py-2.5 bg-white/[0.06] hover:bg-white/[0.1] text-white text-[14px] font-medium rounded-xl transition-colors"
652
- >
653
- Got it
654
- </button>
655
- </div>
445
+ <div className="px-5 pb-6 pt-1">
446
+ <p className="text-white/50 text-[13px] mb-5 leading-relaxed">
447
+ Add Fluxy to your home screen for a full-screen app experience.
448
+ </p>
449
+ {isIos ? (
450
+ <div className="space-y-3.5">
451
+ <div className="flex items-start gap-3">
452
+ <span className="flex-shrink-0 w-7 h-7 rounded-full bg-[#007AFF]/15 text-[#007AFF] text-[13px] font-semibold flex items-center justify-center mt-0.5">1</span>
453
+ <div className="flex-1">
454
+ <p className="text-white text-[14px] font-medium">
455
+ Tap the Share button
456
+ </p>
457
+ <p className="text-white/40 text-[12px] mt-0.5 flex items-center gap-1.5">
458
+ The <Share className="h-3.5 w-3.5 inline" /> icon at the bottom of Safari
459
+ </p>
460
+ </div>
461
+ </div>
462
+ <div className="flex items-start gap-3">
463
+ <span className="flex-shrink-0 w-7 h-7 rounded-full bg-[#007AFF]/15 text-[#007AFF] text-[13px] font-semibold flex items-center justify-center mt-0.5">2</span>
464
+ <div className="flex-1">
465
+ <p className="text-white text-[14px] font-medium">
466
+ Scroll down and tap
467
+ </p>
468
+ <p className="text-white/40 text-[12px] mt-0.5">
469
+ "Add to Home Screen"
470
+ </p>
656
471
  </div>
472
+ </div>
473
+ <div className="flex items-start gap-3">
474
+ <span className="flex-shrink-0 w-7 h-7 rounded-full bg-[#007AFF]/15 text-[#007AFF] text-[13px] font-semibold flex items-center justify-center mt-0.5">3</span>
475
+ <div className="flex-1">
476
+ <p className="text-white text-[14px] font-medium">
477
+ Tap "Add"
478
+ </p>
479
+ <p className="text-white/40 text-[12px] mt-0.5">
480
+ Fluxy will appear on your home screen
481
+ </p>
482
+ </div>
483
+ </div>
657
484
  </div>
658
- )}
485
+ ) : (
486
+ <div className="space-y-3.5">
487
+ <div className="flex items-start gap-3">
488
+ <span className="flex-shrink-0 w-7 h-7 rounded-full bg-[#007AFF]/15 text-[#007AFF] text-[13px] font-semibold flex items-center justify-center mt-0.5">1</span>
489
+ <div className="flex-1">
490
+ <p className="text-white text-[14px] font-medium">
491
+ Open browser menu
492
+ </p>
493
+ <p className="text-white/40 text-[12px] mt-0.5">
494
+ Tap the three-dot menu in your browser
495
+ </p>
496
+ </div>
497
+ </div>
498
+ <div className="flex items-start gap-3">
499
+ <span className="flex-shrink-0 w-7 h-7 rounded-full bg-[#007AFF]/15 text-[#007AFF] text-[13px] font-semibold flex items-center justify-center mt-0.5">2</span>
500
+ <div className="flex-1">
501
+ <p className="text-white text-[14px] font-medium">
502
+ Tap "Install app" or "Add to Home screen"
503
+ </p>
504
+ <p className="text-white/40 text-[12px] mt-0.5">
505
+ Fluxy will be installed as a standalone app
506
+ </p>
507
+ </div>
508
+ </div>
509
+ </div>
510
+ )}
511
+ <button
512
+ onClick={() => setShowIosModal(false)}
513
+ className="w-full mt-5 py-2.5 bg-white/[0.06] hover:bg-white/[0.1] text-white text-[14px] font-medium rounded-xl transition-colors"
514
+ >
515
+ Got it
516
+ </button>
517
+ </div>
518
+ </div>
659
519
  </div>
660
- );
520
+ )}
521
+ </div>
522
+ );
661
523
  }
662
524
 
663
525
  ReactDOM.createRoot(document.getElementById('root')!).render(
664
- <React.StrictMode>
665
- <FluxyApp />
666
- </React.StrictMode>
526
+ <React.StrictMode>
527
+ <FluxyApp />
528
+ </React.StrictMode>,
667
529
  );