235 lines
7.3 KiB
TypeScript
235 lines
7.3 KiB
TypeScript
import './index.css';
|
|
import 'react-grid-layout/css/styles.css';
|
|
import 'react-resizable/css/styles.css';
|
|
|
|
import React from 'react';
|
|
import ReactDOM from 'react-dom/client';
|
|
|
|
import { App } from './App';
|
|
import { initSentryRenderer } from './sentry';
|
|
import { initializeNotificationListeners } from './store';
|
|
|
|
import type { AppStartupStatus, AppStartupStep } from '@shared/types/api';
|
|
|
|
declare global {
|
|
interface Window {
|
|
__claudeTeamsUiDidInit?: boolean;
|
|
__claudeTeamsSplashStaticTimer?: number;
|
|
}
|
|
}
|
|
|
|
// Prepare Sentry before React renders. Actual init waits for telemetry config.
|
|
initSentryRenderer();
|
|
|
|
let root: ReactDOM.Root | null = null;
|
|
let latestStartupStatus: AppStartupStatus | null = null;
|
|
let startupTicker: number | undefined;
|
|
|
|
const SLOW_STEP_MS = 7_000;
|
|
const VERY_SLOW_STEP_MS = 14_000;
|
|
const TIMELINE_STEP_LIMIT = 3;
|
|
|
|
function getStartupErrorText(status: AppStartupStatus): string {
|
|
return status.error ? `Startup failed: ${status.error}` : 'Startup failed. Please restart.';
|
|
}
|
|
|
|
function formatDuration(ms: number): string {
|
|
const safeMs = Math.max(0, ms);
|
|
const seconds = Math.floor(safeMs / 1000);
|
|
if (seconds < 60) {
|
|
return `${seconds}s`;
|
|
}
|
|
const minutes = Math.floor(seconds / 60);
|
|
const rest = seconds % 60;
|
|
return `${minutes}m ${rest.toString().padStart(2, '0')}s`;
|
|
}
|
|
|
|
function getCurrentStartupStep(status: AppStartupStatus): AppStartupStep | null {
|
|
const steps = status.steps ?? [];
|
|
const active = [...steps].reverse().find((step) => !step.finishedAt);
|
|
return active ?? steps[steps.length - 1] ?? null;
|
|
}
|
|
|
|
function getStepElapsedMs(step: AppStartupStep | null, status: AppStartupStatus): number {
|
|
if (!step) {
|
|
return Date.now() - status.startedAt;
|
|
}
|
|
return step.finishedAt ? step.finishedAt - step.startedAt : Date.now() - step.startedAt;
|
|
}
|
|
|
|
function getSlowStepHint(step: AppStartupStep | null, elapsedMs: number): string {
|
|
if (!step || step.finishedAt || elapsedMs < SLOW_STEP_MS) {
|
|
return '';
|
|
}
|
|
|
|
const phase = step.phase;
|
|
if (phase.includes('shell-env-login') || phase.includes('shell-env-interactive')) {
|
|
return elapsedMs >= VERY_SLOW_STEP_MS
|
|
? 'Shell startup is still running. Slow shell profile scripts can delay first launch.'
|
|
: 'Reading your shell PATH. This can take a few seconds on first launch.';
|
|
}
|
|
if (phase.includes('node-runtime')) {
|
|
return 'Checking Node.js for the local MCP server. This can wait up to 5 seconds.';
|
|
}
|
|
if (phase.includes('packaged-server-copy')) {
|
|
return 'Preparing the packaged MCP server copy. This should only happen after updates.';
|
|
}
|
|
if (phase.includes('path') || phase.includes('standard-locations') || phase.includes('nvm')) {
|
|
return 'Searching local runtime paths. A large PATH or slow disk can make this step longer.';
|
|
}
|
|
if (phase.includes('doctor')) {
|
|
return 'Using diagnostics fallback to locate the runtime.';
|
|
}
|
|
if (phase.includes('settings')) {
|
|
return 'Loading encrypted local settings.';
|
|
}
|
|
|
|
return 'Still working on this startup step.';
|
|
}
|
|
|
|
function renderStartupTimeline(status: AppStartupStatus): void {
|
|
const timeline = document.getElementById('splash-timeline');
|
|
if (!timeline) return;
|
|
|
|
if (!status.error) {
|
|
timeline.replaceChildren();
|
|
return;
|
|
}
|
|
|
|
const steps = (status.steps ?? []).slice(-TIMELINE_STEP_LIMIT);
|
|
timeline.replaceChildren();
|
|
|
|
for (const step of steps) {
|
|
const row = document.createElement('div');
|
|
const isCurrent = !step.finishedAt && !status.ready && !status.error;
|
|
row.className = `splash-step${isCurrent ? ' is-current' : ''}`;
|
|
|
|
const dot = document.createElement('div');
|
|
dot.className = 'splash-step-dot';
|
|
|
|
const label = document.createElement('div');
|
|
label.className = 'splash-step-label';
|
|
label.textContent = step.message;
|
|
label.title = step.message;
|
|
|
|
const time = document.createElement('div');
|
|
time.className = 'splash-step-time';
|
|
const elapsed = formatDuration(getStepElapsedMs(step, status));
|
|
time.textContent = step.finishedAt ? `took ${elapsed}` : `running ${elapsed}`;
|
|
|
|
row.append(dot, label, time);
|
|
timeline.append(row);
|
|
}
|
|
}
|
|
|
|
function updateStartupSplash(status: AppStartupStatus): void {
|
|
const splash = document.getElementById('splash');
|
|
const statusElement = document.getElementById('splash-status');
|
|
const elapsedElement = document.getElementById('splash-elapsed');
|
|
const hintElement = document.getElementById('splash-hint');
|
|
if (!splash || !statusElement) return;
|
|
|
|
latestStartupStatus = status;
|
|
const currentStep = getCurrentStartupStep(status);
|
|
const elapsedMs = getStepElapsedMs(currentStep, status);
|
|
const hint = getSlowStepHint(currentStep, elapsedMs);
|
|
|
|
splash.classList.toggle('splash-status-error', Boolean(status.error) && !status.ready);
|
|
splash.classList.toggle('splash-status-slow', Boolean(hint) && !status.error && !status.ready);
|
|
statusElement.textContent =
|
|
status.error && !status.ready
|
|
? getStartupErrorText(status)
|
|
: (currentStep?.message ?? status.message);
|
|
if (elapsedElement) {
|
|
elapsedElement.textContent = formatDuration(elapsedMs);
|
|
}
|
|
if (hintElement) {
|
|
hintElement.textContent = status.error || status.ready ? '' : hint;
|
|
}
|
|
renderStartupTimeline(status);
|
|
}
|
|
|
|
function stopStaticSplashTimer(): void {
|
|
if (window.__claudeTeamsSplashStaticTimer === undefined) return;
|
|
window.clearInterval(window.__claudeTeamsSplashStaticTimer);
|
|
window.__claudeTeamsSplashStaticTimer = undefined;
|
|
}
|
|
|
|
function startStartupTicker(): void {
|
|
if (startupTicker !== undefined) return;
|
|
startupTicker = window.setInterval(() => {
|
|
if (latestStartupStatus) {
|
|
updateStartupSplash(latestStartupStatus);
|
|
}
|
|
}, 1000);
|
|
}
|
|
|
|
function stopStartupTicker(): void {
|
|
if (startupTicker === undefined) return;
|
|
window.clearInterval(startupTicker);
|
|
startupTicker = undefined;
|
|
}
|
|
|
|
function mountApp(): void {
|
|
if (root) return;
|
|
|
|
// React 18 StrictMode intentionally mounts/unmounts effects twice in dev,
|
|
// which can start duplicate IPC init chains. Make initialization a one-time
|
|
// module-level side effect guarded by a global flag.
|
|
if (!window.__claudeTeamsUiDidInit) {
|
|
window.__claudeTeamsUiDidInit = true;
|
|
initializeNotificationListeners();
|
|
}
|
|
|
|
root = ReactDOM.createRoot(document.getElementById('root')!);
|
|
root.render(
|
|
<React.StrictMode>
|
|
<App />
|
|
</React.StrictMode>
|
|
);
|
|
}
|
|
|
|
async function bootstrapRenderer(): Promise<void> {
|
|
const startupApi = window.electronAPI?.startup;
|
|
if (!startupApi) {
|
|
mountApp();
|
|
return;
|
|
}
|
|
|
|
let cleanup = (): void => undefined;
|
|
try {
|
|
let finished = false;
|
|
const handleStartupStatus = (nextStatus: AppStartupStatus): void => {
|
|
if (finished) {
|
|
return;
|
|
}
|
|
updateStartupSplash(nextStatus);
|
|
if (nextStatus.ready) {
|
|
finished = true;
|
|
cleanup();
|
|
stopStaticSplashTimer();
|
|
stopStartupTicker();
|
|
mountApp();
|
|
} else if (nextStatus.error) {
|
|
finished = true;
|
|
cleanup();
|
|
stopStaticSplashTimer();
|
|
stopStartupTicker();
|
|
} else {
|
|
stopStaticSplashTimer();
|
|
startStartupTicker();
|
|
}
|
|
};
|
|
|
|
cleanup = startupApi.onProgress(handleStartupStatus);
|
|
handleStartupStatus(await startupApi.getStatus());
|
|
} catch (error) {
|
|
console.warn(`[startup] status bridge unavailable: ${String(error)}`);
|
|
cleanup();
|
|
stopStaticSplashTimer();
|
|
stopStartupTicker();
|
|
mountApp();
|
|
}
|
|
}
|
|
|
|
void bootstrapRenderer();
|