-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathMainApp.tsx
More file actions
296 lines (264 loc) · 11.5 KB
/
MainApp.tsx
File metadata and controls
296 lines (264 loc) · 11.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
import React, { useEffect } from 'react';
import { useAppStore } from './store';
import InputBar from './components/InputBar';
import ChapterView from './components/ChapterView';
import AmendmentModal from './components/AmendmentModal';
import SessionInfo from './components/SessionInfo';
import SettingsModal from './components/SettingsModal';
import Loader from './components/Loader';
import MigrationRecovery from './components/MigrationRecovery';
import { LandingPage } from './components/LandingPage';
import { DefaultKeyBanner } from './components/DefaultKeyBanner';
import OscilloscopePanel from './components/oscilloscope/OscilloscopePanel';
import NotificationToast from './components/NotificationToast';
import { clientTelemetry } from './services/clientTelemetry';
import { validateApiKey } from './services/aiService';
import { prepareConnection } from './services/db/core/connection';
import { debugLog, debugWarn } from './utils/debug';
import { shouldBlockApp, type VersionCheckResult } from './services/db/core/versionGate';
import { Analytics } from '@vercel/analytics/react';
// Initialize diff trigger service for automatic semantic diff analysis
import './services/diff/DiffTriggerService';
// Import diff colors CSS
import './styles/diff-colors.css';
export const MainApp: React.FC = () => {
const [dbGate, setDbGate] = React.useState<{
status: 'checking' | 'blocked' | 'ready';
result: VersionCheckResult | null;
}>({ status: 'checking', result: null });
// Browser-side env diagnostics (masked) when LF_AI_DEBUG=1
useEffect(() => {
try {
const debug = typeof localStorage !== 'undefined' && localStorage.getItem('LF_AI_DEBUG') === '1';
if (!debug) return;
const mask = (k: any) => {
if (!k || typeof k !== 'string') return String(k ?? '');
return '*'.repeat(Math.max(0, k.length - 4)) + k.slice(-4);
};
// console.log('[Env Diagnostic] Keys (masked):', {
// GEMINI_API_KEY: mask((process as any).env?.GEMINI_API_KEY),
// OPENAI_API_KEY: mask((process as any).env?.OPENAI_API_KEY),
// DEEPSEEK_API_KEY: mask((process as any).env?.DEEPSEEK_API_KEY),
// CLAUDE_API_KEY: mask((process as any).env?.CLAUDE_API_KEY),
// OPENROUTER_API_KEY: mask((process as any).env?.OPENROUTER_API_KEY),
// });
} catch {}
}, []);
// inside App component, near the top
// Individual primitive selectors to avoid fresh object creation
const currentChapterId = useAppStore((s) => s.currentChapterId);
const appScreen = useAppStore((s) => s.appScreen);
const viewMode = useAppStore((s) => s.viewMode);
const isLoading = useAppStore((s) => s.isLoading);
const settings = useAppStore((s) => s.settings);
const isTranslationActive = useAppStore((s) => s.isTranslationActive);
const handleTranslate = useAppStore((s) => s.handleTranslate);
const handleFetch = useAppStore((s) => s.handleFetch);
const amendmentProposals = useAppStore((s) => s.amendmentProposals);
const acceptProposal = useAppStore((s) => s.acceptProposal);
const rejectProposal = useAppStore((s) => s.rejectProposal);
const editAndAcceptProposal = useAppStore((s) => s.editAndAcceptProposal);
// Track current proposal index for queue navigation
const [currentProposalIndex, setCurrentProposalIndex] = React.useState(0);
// Reset index when queue changes
React.useEffect(() => {
if (amendmentProposals.length === 0) {
setCurrentProposalIndex(0);
} else if (currentProposalIndex >= amendmentProposals.length) {
setCurrentProposalIndex(Math.max(0, amendmentProposals.length - 1));
}
}, [amendmentProposals.length, currentProposalIndex]);
const showSettingsModal = useAppStore((s) => s.showSettingsModal);
const setShowSettingsModal = useAppStore((s) => s.setShowSettingsModal);
const loadPromptTemplates = useAppStore((s) => s.loadPromptTemplates);
const getChapter = useAppStore((s) => s.getChapter);
const hasTranslationSettingsChanged = useAppStore((s) => s.hasTranslationSettingsChanged);
const isInitialized = useAppStore((s) => s.isInitialized);
debugLog('ui', 'full', '[App:init] isInitialized selector', { isInitialized });
const initializeStore = useAppStore((s) => s.initializeStore);
// Separate leaf selector for translation result (returns primitive/null)
const currentChapterTranslationResult = useAppStore((state) => {
const id = state.currentChapterId;
const ch = id ? state.getChapter(id) : null;
const result = ch?.translationResult || null;
// Diagnostic logging to track selector updates
if (typeof window !== 'undefined' && (window as any).LF_DEBUG_SELECTOR) {
console.log(`🔎 [Selector] currentChapterTranslationResult evaluated @${Date.now()}`, {
chapterId: id,
hasChapter: !!ch,
hasTranslationResult: !!result,
translationMetadata: result ? {
hasId: !!(result as any).id,
provider: result.usageMetrics?.provider,
model: result.usageMetrics?.model,
cost: result.usageMetrics?.estimatedCost
} : null
});
}
return result;
});
const hasCurrentChapter = useAppStore((state) => {
const id = state.currentChapterId;
if (!id) return false;
const chapter = state.getChapter(id);
return !!chapter;
});
// one-shot guard helpers
const requestedRef = React.useRef(new Map<string, string>());
// Memory optimization: Track previous chapter for cleanup
const previousChapterIdRef = React.useRef<string | null>(null);
// Warn user before page refresh/close if translation or image generation is in progress
const hasImagesInProgress = useAppStore((s) => s.hasImagesInProgress);
useEffect(() => {
const isWorking = isTranslationActive(currentChapterId ?? '') || hasImagesInProgress();
if (!isWorking) return;
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
e.preventDefault();
// Modern browsers ignore custom messages, but returnValue is required
e.returnValue = 'Translation or image generation in progress. Changes may be lost.';
return e.returnValue;
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
}, [currentChapterId, isTranslationActive, hasImagesInProgress]);
const settingsFingerprint = React.useMemo(
() =>
JSON.stringify({
provider: settings.provider,
model: settings.model,
temperature: settings.temperature,
}),
[settings.provider, settings.model, settings.temperature]
);
// Initialize store on first render, then handle URL params
useEffect(() => {
const init = async () => {
const versionCheck = await prepareConnection();
if (shouldBlockApp(versionCheck)) {
setDbGate({ status: 'blocked', result: versionCheck });
return;
}
setDbGate({ status: 'ready', result: versionCheck });
await initializeStore();
};
init();
}, [initializeStore]);
// Boot-time hydration is now handled automatically by the store initialization
// Auto-translate is now handled in chaptersSlice.loadChapterFromIDB —
// it fires AFTER hydration completes, so it knows whether a translation
// already exists in IDB. The old useEffect here was racy: it fired before
// hydration finished, saw no translation, and wasted API credits on
// duplicate translations. See chaptersSlice.ts loadChapterFromIDB.
// (requestedRef cleanup effect removed — auto-translate moved to store)
// Sanity check: selector subscription (optional but nice)
// Optional subscription for debugging
// useEffect(() => {
// const unsub = useAppStore.subscribe(
// (s) => (s.currentChapterId ? s.getChapterById(s.currentChapterId)?.translationResult : null),
// (next, prev) => {
// console.log('[subscribe] translationResult changed:', { prev, next });
// }
// );
// return unsub;
// }, []);
// Proactive Cache Worker effect
useEffect(() => {
// The worker logic is now in the chaptersSlice.
// This effect simply triggers it when the user or settings change.
const { preloadNextChapters } = useAppStore.getState();
preloadNextChapters();
}, [currentChapterId, settings.preloadCount, settings.provider, settings.model, settings.temperature]);
// Memory optimization: Clean up image state when navigating away from a chapter
useEffect(() => {
// If we have a previous chapter and it's different from current, clean it up
if (previousChapterIdRef.current && previousChapterIdRef.current !== currentChapterId) {
const { clearImageState } = useAppStore.getState();
clearImageState(previousChapterIdRef.current);
}
// Update the ref to current chapter
previousChapterIdRef.current = currentChapterId;
}, [currentChapterId]);
let content: React.ReactNode;
if (dbGate.status === 'checking') {
content = (
<div className="min-h-screen bg-gray-100 dark:bg-gray-900 flex items-center justify-center">
<Loader text="Checking database..." />
</div>
);
} else if (dbGate.status === 'blocked' && dbGate.result) {
content = (
<MigrationRecovery
versionCheck={dbGate.result}
onRetry={() => window.location.reload()}
/>
);
} else if (!isInitialized) {
content = (
<div className="min-h-screen bg-gray-100 dark:bg-gray-900 flex items-center justify-center">
<Loader text="Initializing Session..." />
</div>
);
} else if (appScreen === 'reader-loading') {
content = (
<div className="min-h-screen bg-gray-100 dark:bg-gray-900 flex items-center justify-center">
<Loader text="Opening Reader..." />
</div>
);
} else if (appScreen === 'library') {
content = (
<>
<LandingPage />
<SettingsModal
isOpen={showSettingsModal}
onClose={() => setShowSettingsModal(false)}
/>
{import.meta.env.PROD && <Analytics />}
</>
);
} else {
content = (
<div className="min-h-screen bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100 font-sans p-4 sm:p-6">
<main className="container mx-auto">
<DefaultKeyBanner />
<SessionInfo />
<OscilloscopePanel />
<ChapterView />
<SettingsModal
isOpen={showSettingsModal}
onClose={() => setShowSettingsModal(false)}
/>
{amendmentProposals.length > 0 && (
<AmendmentModal
proposals={amendmentProposals}
currentIndex={currentProposalIndex}
onAccept={(index) => {
acceptProposal(index);
// After accepting, reset to first proposal if queue still has items
setCurrentProposalIndex(0);
}}
onReject={(index) => {
rejectProposal(index);
// After rejecting, reset to first proposal if queue still has items
setCurrentProposalIndex(0);
}}
onEdit={(modifiedChange, index) => {
editAndAcceptProposal(modifiedChange, index);
// After editing and accepting, reset to first proposal if queue still has items
setCurrentProposalIndex(0);
}}
onNavigate={setCurrentProposalIndex}
/>
)}
</main>
{import.meta.env.PROD && <Analytics />}
</div>
);
}
return (
<>
<NotificationToast />
{content}
</>
);
};
export default MainApp;