-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathApp.tsx
More file actions
783 lines (749 loc) · 37.7 KB
/
Copy pathApp.tsx
File metadata and controls
783 lines (749 loc) · 37.7 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
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
import { useEffect, useMemo, useState, useCallback, useRef } from 'react';
import { Sidebar } from './components/Sidebar';
import { SessionsView } from './components/SessionsView';
import { SearchView } from './components/SearchView';
import { UsageView } from './components/UsageView';
import { ConfigView } from './components/ConfigView';
import { SettingsView } from './components/SettingsView';
import { AccountModal } from './components/AccountModal';
import { SessionInfoDrawer } from './components/SessionInfoDrawer';
import { StatusBar } from './components/StatusBar';
import { Resizer } from './components/Resizer';
import { useProfile } from './lib/profile';
import { useSourceAuth, deriveName } from './lib/sourceAuth';
import { useExcludeRules, computeEffectiveExcluded } from './lib/excludeRules';
import { useCurrentSource, srcKey, type SessionSource } from './lib/sources';
import { sessionTimestamp } from './lib/format';
import { useDemoMode } from './lib/demoMode';
import { DEMO_SESSIONS, DEMO_USAGE, DEMO_PROFILE, DEMO_RATE_LIMITS } from './lib/demoData';
import { useRateLimitsConsent, useRateLimits, type RateLimitsState } from './lib/rateLimits';
import { RateLimitsConsentModal } from './components/RateLimitsConsentModal';
import { useTranslation } from './lib/I18nProvider';
import type { SessionMeta, SessionsUpdate, View, UsageSummary } from './types';
export type ThemeMode = 'light' | 'dark' | 'system';
function getSystemTheme(): 'light' | 'dark' {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
// Keeps every nav target mounted so React state, scroll position, deep-search
// results, and (most importantly) SessionDetail's loaded messages survive when
// the user switches views.
//
// When active: `display: contents` makes the wrapper transparent to the outer
// flex row — the view component is laid out as a direct child of the flex
// container, just like the old conditional render.
//
// When inactive: `position: absolute + visibility: hidden + pointer-events:
// none` takes the wrapper out of flex flow so the active view gets the full
// width, but the subtree still paints into a real DOM box with real
// dimensions. That matters because SessionList's `@tanstack/react-virtual`
// uses a ResizeObserver on every row — under `display: none` every row
// reports height 0, and switching back triggers a flood of re-measures
// (visible as the list height "snapping" into place). Keeping a real layout
// while invisible avoids that re-measure entirely.
//
// Side note: the parent of these ViewSlots (`flex-1 flex ... relative`)
// becomes the positioning context for the absolute wrapper, so the hidden
// view inherits the row's full width — slightly wider than the active view
// (it doesn't subtract the sidebar) but row height isn't width-sensitive,
// so the cached measurements still apply when it becomes active again.
function ViewSlot({ active, children }: { active: boolean; children: React.ReactNode }) {
if (active) return <div style={{ display: 'contents' }}>{children}</div>;
return (
<div
aria-hidden
style={{
position: 'absolute',
inset: 0,
display: 'flex',
minHeight: 0,
visibility: 'hidden',
pointerEvents: 'none',
}}
>
{children}
</div>
);
}
function replaceSourceRows(previous: SessionMeta[], update: SessionsUpdate | { source: SessionMeta['source']; sessions: SessionMeta[] }) {
return [
...previous.filter(session => session.source !== update.source),
...update.sessions,
].sort((a, b) => (b.mtime || 0) - (a.mtime || 0));
}
function mergeUnseenSourceRows(previous: SessionMeta[], incoming: SessionMeta[], seenSources: ReadonlyMap<SessionSource, unknown>) {
const present = new Set(previous.map(session => session.source));
const missingRows = incoming.filter(session => !seenSources.has(session.source) && !present.has(session.source));
if (missingRows.length === 0) return previous;
return [...previous, ...missingRows].sort((a, b) => (b.mtime || 0) - (a.mtime || 0));
}
export default function App() {
const { t } = useTranslation();
const [view, setView] = useState<View>(() => {
// Whitelist the persisted view — a corrupt/poisoned localStorage write
// would otherwise leave the main pane unmatched and render a blank app.
const saved = localStorage.getItem('view');
return saved === 'sessions' || saved === 'favorites' || saved === 'excluded'
|| saved === 'usage' || saved === 'config' || saved === 'settings' || saved === 'search'
? saved : 'sessions';
});
const [themeMode, setThemeMode] = useState<ThemeMode>(() => {
const saved = localStorage.getItem('theme-mode') as ThemeMode | null;
return saved || 'system';
});
const [theme, setTheme] = useState<'light' | 'dark'>(() => themeMode === 'system' ? getSystemTheme() : themeMode);
const [realSessions, setSessions] = useState<SessionMeta[]>([]);
const [realFavorites, setFavorites] = useState<Set<string>>(new Set());
const [excluded, setExcluded] = useState<Set<string>>(new Set());
const [excludeRules, setExcludeRules] = useExcludeRules();
const [demoMode, setDemoMode] = useDemoMode();
const [currentSource] = useCurrentSource();
// Mirror current source in a ref so async callbacks (reload, SWR push) can
// discard stale results without recreating themselves every time source flips.
const currentSourceRef = useRef(currentSource);
useEffect(() => { currentSourceRef.current = currentSource; }, [currentSource]);
// Two separate monotonic counters so each path only invalidates its own
// class of writes:
// • reloadSeqRef — explicit active-source reload + userdata refresh
// • usageSeqRef — usage-only refresh (SWR push handler + silentRefresh)
// A SWR push must not cancel an in-flight explicit reload's session/favorites
// writes, only its own usage write. Sharing one counter meant the lower-
// priority usage refresh could trample the freshly-arrived sessions list.
const reloadSeqRef = useRef(0);
const usageSeqRef = useRef(0);
const sessionRevisionsRef = useRef(new Map<SessionSource, string>());
const previousSourceRef = useRef(currentSource);
// Coalesces the visibilitychange + focus pair that both fire on a single dock
// re-activation, so silentRefresh doesn't rescan/probe twice per activation.
const lastSilentRefreshRef = useRef(0);
// Bumped on every explicit reload — ConfigView watches this so the Workspace
// pane re-reads CLAUDE.md / skills / commands / hooks from disk on ⌘R or
// the sidebar Rescan button. The renderer never watches the FS itself, so
// without this an edit to ~/.claude/CLAUDE.md (or a new skill) wouldn't
// surface until the pane is reopened or the window is restarted.
const [refreshTick, setRefreshTick] = useState(0);
const [demoAliases, setDemoAliases] = useState<Record<string, string | null>>({});
const [rlConsent, setRlConsent] = useRateLimitsConsent();
const [rlPromptOpen, setRlPromptOpen] = useState(false);
// Codex's probe spawns the local `codex app-server` — no OAuth token, no
// consent required. Claude still gates behind the user's explicit opt-in.
const rlEnabled = !demoMode && (currentSource === 'codex' || rlConsent === 'granted');
const { state: realRateLimits, refresh: refreshRateLimits } = useRateLimits(rlEnabled, currentSource);
const rateLimitsState: RateLimitsState = demoMode
? { limits: DEMO_RATE_LIMITS, limitsSource: currentSource, fetchedAt: Date.now(), loading: false, error: null, debug: null }
: realRateLimits;
// In demo mode, layer user-set aliases on top of the static DEMO_SESSIONS so
// Rename works in demo for screenshots without touching the real aliases.json.
const sessions = useMemo(
() => {
const base = demoMode
? DEMO_SESSIONS.map(s => {
const k = srcKey(s);
return demoAliases[k] !== undefined ? { ...s, alias: demoAliases[k] } : s;
})
: realSessions;
// Filter by the AI tool the user is currently browsing.
return base.filter(s => s.source === currentSource);
},
[demoMode, realSessions, demoAliases, currentSource]
);
const favorites = useMemo(
() => demoMode
? new Set(DEMO_SESSIONS.filter(s => s.favorite).map(s => `${s.source}:${s.id}`))
: realFavorites,
[demoMode, realFavorites]
);
const effectiveExcluded = useMemo(
() => computeEffectiveExcluded(sessions, excluded, excludeRules),
[sessions, excluded, excludeRules]
);
const [usageBySource, setUsageBySource] = useState<Partial<Record<SessionSource, UsageSummary>>>({});
const [usageErrorBySource, setUsageErrorBySource] = useState<Partial<Record<SessionSource, string>>>({});
const storeUsage = useCallback((source: SessionSource, value: UsageSummary) => {
setUsageBySource(previous => previous[source] === value
? previous
: { ...previous, [source]: value });
setUsageErrorBySource(previous => {
if (previous[source] == null) return previous;
const next = { ...previous };
delete next[source];
return next;
});
}, []);
const invalidateUsage = useCallback((source: SessionSource) => {
setUsageBySource(previous => {
if (previous[source] == null) return previous;
const next = { ...previous };
delete next[source];
return next;
});
}, []);
const currentUsage = usageBySource[currentSource] || null;
const usage = demoMode ? DEMO_USAGE : currentUsage;
const [loading, setLoading] = useState(true);
const [statusMsg, setStatusMsg] = useState('');
useEffect(() => {
if (previousSourceRef.current === currentSource) return;
previousSourceRef.current = currentSource;
// Source switches paint from the renderer's existing per-source snapshot.
// Cancel ownership of an explicit reload from the source we just left so
// its stale completion cannot keep the newly-active source in a skeleton.
reloadSeqRef.current++;
setLoading(false);
}, [currentSource]);
const reportUsageError = useCallback((source: SessionSource, error: unknown) => {
const detail = error instanceof Error ? error.message : String(error);
setUsageErrorBySource(previous => ({ ...previous, [source]: detail }));
const message = t('status.error', { error: detail });
setStatusMsg(message);
setTimeout(() => setStatusMsg(current => current === message ? '' : current), 4000);
}, [t]);
// Per-source active selection so flipping Claude ↔ Codex restores each
// tool's last-selected session instead of bleeding state across sources.
const [activeId, setActiveId] = useState<string | null>(() => localStorage.getItem(`active-id:${currentSource}`));
const activeIdSourceRef = useRef(currentSource);
const [realProfile, setProfile] = useProfile(currentSource);
// Demo mode overlays a fixed profile so a localStorage-customised name
// never leaks into demo views.
const profile = demoMode ? DEMO_PROFILE : realProfile;
const [profileOpen, setProfileOpen] = useState(false);
const [infoOpen, setInfoOpen] = useState(false);
const { auth } = useSourceAuth(currentSource);
const activeSession = sessions.find(s => srcKey(s) === activeId) || null;
// Auto-populate profile from the active source's auth status if the user
// hasn't customised it. Customisation is now tracked per source so claude
// and codex identities stay independent.
useEffect(() => {
if (!auth?.email) return;
const wasCustomized = localStorage.getItem(`profile-customized:${currentSource}`) === '1';
if (wasCustomized) return;
const derivedName = deriveName(auth.email);
if (derivedName && profile.name !== derivedName) {
setProfile({ name: derivedName, avatarInitial: derivedName[0]?.toUpperCase() || '?' });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [auth?.email, currentSource]);
useEffect(() => {
document.documentElement.dataset.theme = theme;
// Repaint the Windows native caption-button overlay to match the new theme.
// No-op on macOS / Linux — the main process ignores the call there.
window.api.setTitleBarTheme?.(theme).catch(() => {});
}, [theme]);
// Resolve theme based on mode + listen for system changes
useEffect(() => {
localStorage.setItem('theme-mode', themeMode);
if (themeMode === 'system') {
const mql = window.matchMedia('(prefers-color-scheme: dark)');
const update = () => setTheme(mql.matches ? 'dark' : 'light');
update();
mql.addEventListener('change', update);
return () => mql.removeEventListener('change', update);
}
setTheme(themeMode);
}, [themeMode]);
useEffect(() => { localStorage.setItem('view', view); }, [view]);
useEffect(() => {
if (activeIdSourceRef.current !== currentSource) return;
const key = `active-id:${currentSource}`;
if (activeId) localStorage.setItem(key, activeId);
else localStorage.removeItem(key);
// Drop any lingering selection from the previous session so the new detail view
// doesn't load with random highlights left over from where the user was dragging.
try { window.getSelection()?.removeAllRanges(); } catch {}
}, [activeId, currentSource]);
// When the user flips Claude ↔ Codex, swap activeId to whatever that source
// had selected last time. Without this the activeId from the old source
// would stay set and the "source-not-found" cleanup effect below would
// immediately drop it, costing the user their previous selection.
useEffect(() => {
activeIdSourceRef.current = currentSource;
setActiveId(localStorage.getItem(`active-id:${currentSource}`));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentSource]);
// Smart clear: drop activeId whenever the active session falls outside the
// current view — view change, favorite toggle, exclude toggle, source flip,
// or session list update. Used to depend only on `view`, so unfavoriting
// the selected session in Favorites left a ghost detail pane.
useEffect(() => {
if (!activeId) return;
if (!activeId.startsWith(`${currentSource}:`)) return;
if (view !== 'sessions' && view !== 'favorites' && view !== 'excluded') return;
let inView = false;
if (view === 'sessions') inView = !effectiveExcluded.has(activeId);
else if (view === 'favorites') inView = favorites.has(activeId);
else if (view === 'excluded') inView = effectiveExcluded.has(activeId);
// Also confirm the session is still in the current source's set; a source
// flip can leave activeId pointing at a row that's no longer reachable.
if (inView && !sessions.some(s => srcKey(s) === activeId)) inView = false;
if (!inView) setActiveId(null);
}, [view, activeId, favorites, effectiveExcluded, sessions, currentSource]);
const reload = useCallback(async () => {
// Stale guard: capture source + a monotonic request id at call time. If
// the user flips Claude↔Codex OR a fresher reload starts before this one
// settles, drop the result so hero numbers / usage / streak only commit
// when neither source nor seq has shifted.
const reqSource = currentSource;
const reqSeq = ++reloadSeqRef.current;
const usageReqSeq = ++usageSeqRef.current;
// Signal Workspace (ConfigView) to refetch CLAUDE.md / skills / etc.
// Sessions + workspace live on disk side-by-side; a refresh that updates
// one without the other left users wondering why their fresh
// ~/.claude/CLAUDE.md edit didn't show up.
setRefreshTick(t => t + 1);
setLoading(true);
try {
const [s, f, e, usageResult] = await Promise.all([
window.api.listSessions({ force: true, source: reqSource }),
window.api.listFavorites(),
window.api.listExcludes(),
window.api.getUsage(reqSource).then(
value => ({ value, error: null }),
error => ({ value: null, error }),
),
]);
if (reqSource !== currentSourceRef.current || reqSeq !== reloadSeqRef.current) return;
// `sessions:list(force)` returns the current SWR snapshot immediately.
// A faster background push may already have installed fresher rows while
// this Promise.all was waiting for Usage/userdata. Only seed a source the
// renderer does not have yet; normal refresh results arrive via push.
setSessions(previous => sessionRevisionsRef.current.has(reqSource)
? previous
: replaceSourceRows(previous, { source: reqSource, sessions: s }));
setFavorites(new Set(f));
setExcluded(new Set(e));
if (usageReqSeq === usageSeqRef.current) {
if (usageResult.value) storeUsage(reqSource, usageResult.value);
else reportUsageError(reqSource, usageResult.error);
}
setLoading(false);
} catch (err: any) {
// Only surface the error if this is still the freshest reload for the
// current source. A stale failure (user already moved on / triggered a
// new reload) shouldn't flash a status bar message or kill the loading
// skeleton of the in-flight successor.
if (reqSource !== currentSourceRef.current || reqSeq !== reloadSeqRef.current) return;
setStatusMsg(t('status.error', { error: err.message }));
setLoading(false);
}
}, [currentSource, reportUsageError, storeUsage, t]);
// Initial inventory load is intentionally source-independent. Source flips
// paint by filtering this in-memory list, then the source-activation effect
// below starts a payload-free background refresh.
useEffect(() => {
let cancelled = false;
setLoading(true);
Promise.all([
window.api.listSessions({ force: true }),
window.api.listFavorites(),
window.api.listExcludes(),
]).then(([s, f, e]) => {
if (cancelled) return;
// Preserve a progressive SWR push that won the race with these small
// favorites/excludes IPC calls.
setSessions(previous => mergeUnseenSourceRows(previous, s, sessionRevisionsRef.current));
setFavorites(new Set(f));
setExcluded(new Set(e));
// The response is an authoritative in-memory snapshot at its completion
// point even when the current source is empty. Fresh filesystem rows
// continue through SWR pushes, so an empty source never traps the app in
// its startup skeleton.
setLoading(false);
}).catch((err: any) => {
if (cancelled) return;
setStatusMsg(t('status.error', { error: err.message }));
setLoading(false);
});
return () => { cancelled = true; };
// This is the one boot-time inventory request. Locale changes update the
// translator without re-running a full filesystem scan.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Paint from the per-source snapshots immediately, then refresh Usage in the
// background on every source activation. The fresh request keeps rolling
// today/3d/7d/30d windows correct after a source sat inactive overnight,
// while retaining the instant no-skeleton path for an already-cached value.
useEffect(() => {
const reqSource = currentSource;
const reqSeq = ++usageSeqRef.current;
window.api.getUsage(reqSource).then(u => {
if (reqSource === currentSourceRef.current && reqSeq === usageSeqRef.current) storeUsage(reqSource, u);
}).catch(error => {
if (reqSource === currentSourceRef.current && reqSeq === usageSeqRef.current) reportUsageError(reqSource, error);
});
}, [currentSource, reportUsageError, storeUsage]);
// Source switching remains renderer-local on the critical path. Once the
// new source has painted, ask main to refresh only that source without
// returning its multi-megabyte inventory through the request IPC. Any real
// changes arrive through the normal source-scoped SWR push.
useEffect(() => {
window.api.refreshSessions(currentSource).catch(() => {});
}, [currentSource]);
// SWR push: main process pushes a fresh session list when its background
// rescan completes. We replace state silently so the UI updates without a
// visible "Loading…" cycle. Refresh usage in parallel since it's derived.
useEffect(() => {
if (!window.api.onSessionsUpdated) return;
return window.api.onSessionsUpdated((update) => {
if (update.error && update.source === currentSourceRef.current) {
setLoading(false);
const message = t('status.error', { error: update.error });
setStatusMsg(message);
setTimeout(() => setStatusMsg(current => current === message ? '' : current), 4000);
}
if (sessionRevisionsRef.current.get(update.source) === update.revision) return;
sessionRevisionsRef.current.set(update.source, update.revision);
setSessions(previous => replaceSourceRows(previous, update));
if (update.source === currentSourceRef.current) setLoading(false);
if (update.source !== currentSourceRef.current) {
invalidateUsage(update.source);
return;
}
// Capture source + bump the USAGE-only seq so a concurrent silent
// refresh's slower usage payload can't trample this one — without
// invalidating a separately-running explicit reload's sessions/favorites
// writes.
const reqSource = update.source;
const reqSeq = ++usageSeqRef.current;
window.api.getUsage(reqSource).then(u => {
if (reqSource === currentSourceRef.current && reqSeq === usageSeqRef.current) storeUsage(reqSource, u);
}).catch(error => {
if (reqSource === currentSourceRef.current && reqSeq === usageSeqRef.current) reportUsageError(reqSource, error);
});
});
}, [invalidateUsage, reportUsageError, storeUsage, t]);
// Components can dispatch this event to force a fresh listSessions() — e.g.
// after the user renames a session via the context-menu dialog.
useEffect(() => {
const h = () => reload();
window.addEventListener('sessions:reload', h);
return () => window.removeEventListener('sessions:reload', h);
}, [reload]);
// History pane EmptyState dispatches this when the user clicks "Search
// content" — switch to the dedicated Search view and re-broadcast the query
// so SearchView can auto-submit it. Two-event hop is needed because
// SearchView's input state is owned inside the component, and we want the
// query to land + grep to fire without an extra click after the view swap.
useEffect(() => {
const h = (e: Event) => {
const detail = (e as CustomEvent).detail as { q?: string };
const q = (detail?.q || '').trim();
if (!q) return;
setView('search');
// Defer one frame so SearchView's mount/visibility transition lands
// before we fire the auto-submit. Without this the SearchView listener
// hasn't mounted yet on first navigation and the event is missed.
requestAnimationFrame(() => {
window.dispatchEvent(new CustomEvent('search:autoSubmit', { detail: { q } }));
});
};
window.addEventListener('nav:contentSearch', h);
return () => window.removeEventListener('nav:contentSearch', h);
}, []);
// Optimistic alias patch — updates the in-memory session entry without a full
// reload(). Demo mode tracks aliases in a separate overlay map so DEMO_SESSIONS
// stays untouched and the real aliases.json on disk isn't polluted by demo ids.
// The event MUST carry `source` so a UUID collision between Claude and Codex
// doesn't rename both rows.
useEffect(() => {
const h = (e: Event) => {
const detail = (e as CustomEvent).detail as { source: SessionSource; id: string; alias: string | null };
if (!detail?.id || !detail?.source) return;
const key = `${detail.source}:${detail.id}`;
if (demoMode) {
setDemoAliases(prev => ({ ...prev, [key]: detail.alias }));
} else {
setSessions(prev => prev.map(s => (s.source === detail.source && s.id === detail.id) ? { ...s, alias: detail.alias } : s));
}
};
window.addEventListener('sessions:patchAlias', h);
return () => window.removeEventListener('sessions:patchAlias', h);
}, [demoMode]);
// Background poller — re-scan every 5 min while window is visible, and on
// focus / visibility-restore. Crucially this is a SILENT refresh: never
// toggles the loading skeleton, so swapping focus to the app (dock click,
// Cmd-Tab, deactivate/reactivate) doesn't blank the UI for 200-500ms while
// the rescan runs. The main process pushes fresh sessions through
// onSessionsUpdated when its background walk finishes; that handler updates
// state in place. Only initial mount + explicit ⌘R / Rescan show the
// skeleton.
const silentRefresh = useCallback(() => {
// Dock activation fires visibilitychange AND focus back-to-back; collapse
// them into one refresh. The 5-min poll and genuinely-spaced focuses are
// far apart so they're unaffected.
const now = performance.now();
if (now - lastSilentRefreshRef.current < 500) return;
lastSilentRefreshRef.current = now;
const reqSource = currentSource;
// Uses the USAGE-only seq — sessions data arrives via SWR push, which has
// its own write path. We only protect against a stale usage payload.
const reqSeq = ++usageSeqRef.current;
window.api.listSessions({ force: true, source: reqSource }).catch(() => {});
window.api.getUsage(reqSource).then(u => {
if (reqSource === currentSourceRef.current && reqSeq === usageSeqRef.current) storeUsage(reqSource, u);
}).catch(error => {
if (reqSource === currentSourceRef.current && reqSeq === usageSeqRef.current) reportUsageError(reqSource, error);
});
}, [currentSource, reportUsageError, storeUsage]);
const retryUsage = useCallback(() => {
const reqSource = currentSource;
const reqSeq = ++usageSeqRef.current;
setUsageErrorBySource(previous => {
if (previous[reqSource] == null) return previous;
const next = { ...previous };
delete next[reqSource];
return next;
});
window.api.getUsage(reqSource).then(u => {
if (reqSource === currentSourceRef.current && reqSeq === usageSeqRef.current) storeUsage(reqSource, u);
}).catch(error => {
if (reqSource === currentSourceRef.current && reqSeq === usageSeqRef.current) reportUsageError(reqSource, error);
});
}, [currentSource, reportUsageError, storeUsage]);
useEffect(() => {
let timer: number | null = null;
const tick = () => { if (!document.hidden) silentRefresh(); };
const start = () => { stop(); timer = window.setInterval(tick, 5 * 60_000); };
const stop = () => { if (timer != null) { clearInterval(timer); timer = null; } };
const onVis = () => {
if (document.hidden) stop();
else { silentRefresh(); start(); }
};
start();
document.addEventListener('visibilitychange', onVis);
window.addEventListener('focus', tick);
return () => {
stop();
document.removeEventListener('visibilitychange', onVis);
window.removeEventListener('focus', tick);
};
}, [silentRefresh]);
// Intercept link clicks → open in default browser (avoid Electron in-app navigation popup)
useEffect(() => {
const handler = (e: MouseEvent) => {
const target = (e.target as HTMLElement | null)?.closest('a');
if (!target) return;
const href = target.getAttribute('href');
if (!href) return;
// Pure in-page anchors fall through to native behavior.
if (href.startsWith('#')) return;
// Allowlist scheme: open in OS default browser. Anything else (file:,
// javascript:, data:, vscode:, etc.) is blocked here even though
// DOMPurify already strips dangerous href schemes. Defense in depth:
// some anchors may not be rendered through marked + DOMPurify (e.g.
// future generated UI), and an unhandled href would otherwise let
// Electron decide. Better to refuse.
if (/^https?:\/\//i.test(href) || /^mailto:/i.test(href)) {
e.preventDefault();
window.api.openExternal(href).catch(err => console.warn('openExternal failed:', err));
return;
}
// Unknown scheme — refuse silently (preventDefault so Electron doesn't
// try to navigate the renderer).
e.preventDefault();
};
document.addEventListener('click', handler);
return () => document.removeEventListener('click', handler);
}, []);
// Keyboard: ⌘K palette, ⌘F focus search, ⌘R reload, ⌘I info, Esc close
useEffect(() => {
const handler = (e: KeyboardEvent) => {
const cmd = e.metaKey || e.ctrlKey;
if (cmd && e.key === 'k') {
// ⌘K opens the Search view.
e.preventDefault();
setView('search');
} else if (cmd && e.key === 'f') {
e.preventDefault();
// Both views stay mounted via ViewSlot, so two distinct ids exist
// simultaneously — focus the one whose view is actually visible.
// Fall back to the History input for views that don't host their
// own search field (Workspace / Usage / Settings).
const id = view === 'search' ? 'deep-search-input' : 'history-search-input';
const el = document.getElementById(id) as HTMLInputElement | null;
el?.focus();
el?.select();
} else if (cmd && e.key === 'r') {
e.preventDefault();
reload();
setStatusMsg(t('status.rescanned'));
setTimeout(() => setStatusMsg(''), 1500);
} else if (cmd && e.key === 'i') {
e.preventDefault();
if (activeSession) setInfoOpen(true);
} else if (cmd && e.key === ',') {
e.preventDefault();
setView('config');
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [reload, activeSession, view]);
const toggleFav = useCallback(async (id: string) => {
const isFav = await window.api.toggleFavorite(currentSource, id);
const key = `${currentSource}:${id}`;
setFavorites(prev => {
const next = new Set(prev);
if (isFav) next.add(key); else next.delete(key);
return next;
});
setSessions(prev => prev.map(s => s.id === id && (s.source) === currentSource ? { ...s, favorite: isFav } : s));
}, [currentSource]);
const toggleExc = useCallback(async (id: string) => {
const isEx = await window.api.toggleExclude(currentSource, id);
const key = `${currentSource}:${id}`;
setExcluded(prev => {
const next = new Set(prev);
if (isEx) next.add(key); else next.delete(key);
return next;
});
setSessions(prev => prev.map(s => s.id === id && (s.source) === currentSource ? { ...s, excluded: isEx } : s));
setStatusMsg(isEx ? t('status.excluded') : t('status.restored'));
setTimeout(() => setStatusMsg(''), 2500);
}, [currentSource, t]);
return (
<div className="h-full w-full flex flex-col bg-bg text-text">
<div className="drag-region h-9 titlebar flex items-center justify-center select-none">
<span className="flex items-center gap-1.5 text-[11.5px] min-w-0 px-32">
<span className="font-semibold text-text/85 flex-shrink-0">Lens</span>
<span className="text-text-muted/60 flex-shrink-0" aria-hidden>·</span>
<span className="text-text-muted truncate">{t('app.tagline')}</span>
</span>
</div>
<div className="flex-1 flex min-h-0 overflow-hidden gap-1.5 p-1.5 relative">
<Sidebar
view={view}
onViewChange={setView}
theme={theme}
onThemeChange={mode => setThemeMode(mode)}
counts={{
// effectiveExcluded / favorites are keyed `source:id`. Sidebar count
// was reading them with bare `s.id` so cross-source UUID collisions
// could double-count or miss. Use srcKey() like the favorites line
// already does.
sessions: sessions.filter(s => !effectiveExcluded.has(srcKey(s))).length,
favorites: sessions.filter(s => favorites.has(srcKey(s))).length,
excluded: sessions.filter(s => effectiveExcluded.has(srcKey(s))).length,
}}
totalTokens={usage ? usage.buckets.total.input + usage.buckets.total.output + usage.buckets.total.cacheRead + usage.buckets.total.cacheCreate : 0}
onReload={reload}
profile={profile}
onOpenProfile={() => setProfileOpen(true)}
rateLimits={rateLimitsState}
quotaEnabled={rlEnabled || demoMode}
demoMode={demoMode}
/>
<Resizer cssVar="--sidebar-width" storageKey="sidebar-width" min={180} max={280} side="left" />
{/*
Every view stays mounted (see ViewSlot) so navigating away and back
keeps detail-pane messages, deep-search hits, scroll positions, and
input state intact. The previous conditional render unmounted the
whole subtree, forcing a fresh IPC fetch on re-entry.
*/}
<ViewSlot active={view === 'sessions' || view === 'favorites' || view === 'excluded'}>
<SessionsView
// While inactive, freeze the `view` prop on the last active
// sessions/favorites/excluded variant so SessionsView doesn't
// try to reconcile against a value it doesn't understand
// (e.g. 'search'). The component is hidden so the user can't see
// the freeze; on next entry the live view value flows through.
view={(view === 'sessions' || view === 'favorites' || view === 'excluded') ? view : 'sessions'}
sessions={sessions}
favorites={favorites}
excluded={effectiveExcluded}
manualExcluded={excluded}
excludeRules={excludeRules}
onExcludeRulesChange={setExcludeRules}
demoMode={demoMode}
usage={usage}
loading={loading}
activeId={activeId}
onActiveIdChange={setActiveId}
onToggleFavorite={toggleFav}
onToggleExclude={toggleExc}
onStatus={setStatusMsg}
onOpenInfo={() => setInfoOpen(true)}
/>
</ViewSlot>
<ViewSlot active={view === 'search'}>
<SearchView
isActive={view === 'search'}
demoMode={demoMode}
sessions={sessions}
favorites={favorites}
excluded={effectiveExcluded}
loading={loading}
onSelectSession={(id) => {
// Jumping from Search to History: the chosen session may be
// outside History's current filters. Strategy:
// - Always drop `project` (the dimension most likely to hide
// the row, and the user's project pick in History rarely
// matters when they came from a search result).
// - Drop `time` ONLY if the session's lastTs falls outside
// the current time window. Otherwise keep the user's
// time choice (they picked 7-day for a reason).
// - Always keep `sort`.
//
// SessionsView is unmounted while we're on Search, so the
// dispatched event has no listener. Write v2 storage in place
// so the freshly-mounted SessionsView reads the relaxed filter.
const target = sessions.find(s => srcKey(s) === id);
const lastTs = target ? sessionTimestamp(target) : 0;
const wouldHideByTime = (timeStr: string | undefined) => {
if (!lastTs || !timeStr || timeStr === 'all') return false;
const days = parseInt(timeStr, 10);
if (!Number.isFinite(days)) return false;
return lastTs < Date.now() - days * 86400000;
};
try {
const raw = localStorage.getItem('session-filters');
const obj = raw ? JSON.parse(raw) : {};
const cur = (obj.sessions && typeof obj.sessions === 'object') ? obj.sessions : {};
const next = { ...cur, project: '' };
if (wouldHideByTime(cur.time)) next.time = 'all';
obj.sessions = next;
localStorage.setItem('session-filters', JSON.stringify(obj));
} catch {}
window.dispatchEvent(new CustomEvent('history:relaxForTarget', { detail: { lastTs } }));
setActiveId(id);
// If the target row is currently excluded by user rules, History
// would filter it out — jump to Excluded view instead so the
// selection is actually visible.
if (effectiveExcluded.has(id)) {
setView('excluded');
} else {
setView('sessions');
}
}}
onToggleFavorite={toggleFav}
onStatus={setStatusMsg}
/>
</ViewSlot>
<ViewSlot active={view === 'usage'}>
<UsageView usage={usage} error={usageErrorBySource[currentSource] || null} demoMode={demoMode} rlConsent={rlConsent} rateLimits={rateLimitsState} isActive={view === 'usage'} onRetry={retryUsage} onOpenRlPrompt={() => setRlPromptOpen(true)} onRefreshRateLimits={refreshRateLimits} />
</ViewSlot>
<ViewSlot active={view === 'config'}>
<ConfigView demoMode={demoMode} onStatus={setStatusMsg} refreshTick={refreshTick} isActive={view === 'config'} />
</ViewSlot>
<ViewSlot active={view === 'settings'}>
<SettingsView themeMode={themeMode} resolvedTheme={theme} onThemeChange={setThemeMode} demoMode={demoMode} onDemoModeChange={setDemoMode} rlConsent={rlConsent} onRlConsentChange={setRlConsent} />
</ViewSlot>
</div>
<AccountModal
open={profileOpen}
onOpenChange={setProfileOpen}
profile={profile}
onChange={setProfile}
/>
<RateLimitsConsentModal
open={rlPromptOpen}
onAccept={() => { setRlConsent('granted'); setRlPromptOpen(false); }}
onDeny={() => { setRlConsent('denied'); setRlPromptOpen(false); }}
/>
<SessionInfoDrawer
open={infoOpen}
onOpenChange={setInfoOpen}
session={activeSession}
/>
<StatusBar sessions={sessions} message={statusMsg} />
</div>
);
}