@polderlabs/bizar-dash 3.0.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 (59) hide show
  1. package/dist/assets/index-B5X9g8B4.css +1 -0
  2. package/dist/assets/index-LqQuSp9d.js +388 -0
  3. package/dist/assets/index-LqQuSp9d.js.map +1 -0
  4. package/dist/index.html +18 -0
  5. package/package.json +67 -0
  6. package/src/cli.mjs +228 -0
  7. package/src/server/agents-store.mjs +190 -0
  8. package/src/server/api.mjs +913 -0
  9. package/src/server/browser.mjs +40 -0
  10. package/src/server/diagnostics-store.mjs +138 -0
  11. package/src/server/mods-loader.mjs +361 -0
  12. package/src/server/projects-store.mjs +198 -0
  13. package/src/server/providers-store.mjs +183 -0
  14. package/src/server/schedules-runner.mjs +150 -0
  15. package/src/server/schedules-store.mjs +233 -0
  16. package/src/server/search-store.mjs +120 -0
  17. package/src/server/server.mjs +388 -0
  18. package/src/server/state.mjs +357 -0
  19. package/src/server/tailscale-store.mjs +113 -0
  20. package/src/server/tasks-store.mjs +275 -0
  21. package/src/server/tui.mjs +844 -0
  22. package/src/server/watcher.mjs +81 -0
  23. package/src/web/App.tsx +316 -0
  24. package/src/web/components/Button.tsx +55 -0
  25. package/src/web/components/Card.tsx +40 -0
  26. package/src/web/components/EmptyState.tsx +30 -0
  27. package/src/web/components/Modal.tsx +137 -0
  28. package/src/web/components/SearchModal.tsx +185 -0
  29. package/src/web/components/Spinner.tsx +19 -0
  30. package/src/web/components/StatusBadge.tsx +25 -0
  31. package/src/web/components/Tag.tsx +28 -0
  32. package/src/web/components/Toast.tsx +142 -0
  33. package/src/web/components/Topbar.tsx +203 -0
  34. package/src/web/index.html +17 -0
  35. package/src/web/lib/api.ts +71 -0
  36. package/src/web/lib/markdown.tsx +59 -0
  37. package/src/web/lib/types.ts +388 -0
  38. package/src/web/lib/utils.ts +79 -0
  39. package/src/web/lib/ws.ts +132 -0
  40. package/src/web/main.tsx +12 -0
  41. package/src/web/styles/main.css +3148 -0
  42. package/src/web/views/Agents.tsx +406 -0
  43. package/src/web/views/Chat.tsx +527 -0
  44. package/src/web/views/Config.tsx +683 -0
  45. package/src/web/views/Mods.tsx +350 -0
  46. package/src/web/views/Overview.tsx +350 -0
  47. package/src/web/views/Plans.tsx +667 -0
  48. package/src/web/views/Schedules.tsx +299 -0
  49. package/src/web/views/Settings.tsx +571 -0
  50. package/src/web/views/Tasks.tsx +761 -0
  51. package/templates/mod/FORMAT.md +76 -0
  52. package/templates/mod/hello-mod/README.md +19 -0
  53. package/templates/mod/hello-mod/agents/greeter.md +8 -0
  54. package/templates/mod/hello-mod/commands/hello.md +6 -0
  55. package/templates/mod/hello-mod/mod.json +20 -0
  56. package/templates/mod/hello-mod/routes/ping.mjs +9 -0
  57. package/templates/mod/hello-mod/views/HelloView.tsx +10 -0
  58. package/tsconfig.json +23 -0
  59. package/vite.config.ts +24 -0
@@ -0,0 +1,59 @@
1
+ // src/lib/markdown.ts — pretty JSON syntax highlighter for the JSON tree panel.
2
+ // Replaces the old utils.highlightJSON; we render to React nodes, not strings.
3
+
4
+ import { Fragment } from 'react';
5
+
6
+ type Token = { kind: 'key' | 'string' | 'number' | 'boolean' | 'null' | 'punct' | 'space'; value: string };
7
+
8
+ function tokenize(json: string): Token[] {
9
+ const out: Token[] = [];
10
+ const re = /("(?:\\.|[^"\\])*")(\s*:)?|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)|(\btrue\b|\bfalse\b)|(\bnull\b)|([{}\[\],])|(\s+)/g;
11
+ let m: RegExpExecArray | null;
12
+ while ((m = re.exec(json)) !== null) {
13
+ if (m[1] !== undefined) {
14
+ out.push({ kind: m[2] ? 'key' : 'string', value: m[1] });
15
+ if (m[2]) out.push({ kind: 'punct', value: m[2] });
16
+ } else if (m[3] !== undefined) {
17
+ out.push({ kind: 'number', value: m[3] });
18
+ } else if (m[4] !== undefined) {
19
+ out.push({ kind: 'boolean', value: m[4] });
20
+ } else if (m[5] !== undefined) {
21
+ out.push({ kind: 'null', value: m[5] });
22
+ } else if (m[6] !== undefined) {
23
+ out.push({ kind: 'punct', value: m[6] });
24
+ } else if (m[7] !== undefined) {
25
+ out.push({ kind: 'space', value: m[7] });
26
+ }
27
+ }
28
+ return out;
29
+ }
30
+
31
+ const COLOR: Record<Token['kind'], string | undefined> = {
32
+ key: 'var(--syntax-key)',
33
+ string: 'var(--syntax-string)',
34
+ number: 'var(--syntax-number)',
35
+ boolean: 'var(--syntax-boolean)',
36
+ null: 'var(--syntax-null)',
37
+ punct: undefined,
38
+ space: undefined,
39
+ };
40
+
41
+ /** Render highlighted JSON as React fragments. */
42
+ export function JsonHighlight({ value }: { value: unknown }) {
43
+ const json = JSON.stringify(value, null, 2);
44
+ if (json === undefined) return null;
45
+ const tokens = tokenize(json);
46
+ return (
47
+ <>
48
+ {tokens.map((t, i) => {
49
+ const color = COLOR[t.kind];
50
+ if (!color) return <Fragment key={i}>{t.value}</Fragment>;
51
+ return (
52
+ <span key={i} style={{ color }}>
53
+ {t.value}
54
+ </span>
55
+ );
56
+ })}
57
+ </>
58
+ );
59
+ }
@@ -0,0 +1,388 @@
1
+ // src/lib/types.ts — TypeScript types for the Bizar dashboard.
2
+ // All shapes here mirror the JSON returned by src/server/api.mjs.
3
+
4
+ export type ThemeName = 'dark' | 'light' | 'system';
5
+
6
+ export type ThemeSettings = {
7
+ mode: ThemeName;
8
+ accent: string;
9
+ success: string;
10
+ warning: string;
11
+ error: string;
12
+ info: string;
13
+ fontFamily: string;
14
+ fontSize: number;
15
+ compactMode: boolean;
16
+ animations: boolean;
17
+ };
18
+
19
+ export type UiSettings = {
20
+ layout: 'topnav' | 'sidebar' | 'both';
21
+ showHeader: boolean;
22
+ showStatusBar: boolean;
23
+ defaultTab: string;
24
+ accentColor?: string;
25
+ };
26
+
27
+ export type Agent = {
28
+ name: string;
29
+ description: string;
30
+ model: string;
31
+ mode: string;
32
+ file: string;
33
+ path: string;
34
+ mtime: number;
35
+ tools?: string[];
36
+ color?: string;
37
+ prompt?: string;
38
+ permissions?: unknown;
39
+ };
40
+
41
+ export type ProjectRecord = {
42
+ id: string;
43
+ name: string;
44
+ path: string;
45
+ lastAccessed?: string;
46
+ status: 'active' | 'inactive' | 'error' | string;
47
+ summary?: string;
48
+ };
49
+
50
+ export type ProjectsResponse = {
51
+ projects: ProjectRecord[];
52
+ active: string | null;
53
+ };
54
+
55
+ export type Plan = {
56
+ slug: string;
57
+ title: string;
58
+ status: string;
59
+ source: 'worktree' | 'global' | string;
60
+ elementCount: number | null;
61
+ commentCount: number | null;
62
+ mtime: number;
63
+ planUrl: string | null;
64
+ };
65
+
66
+ export type CanvasElement = {
67
+ id: string;
68
+ type: string;
69
+ title?: string;
70
+ content?: string;
71
+ x: number;
72
+ y: number;
73
+ width: number;
74
+ height: number;
75
+ };
76
+
77
+ export type CanvasConnection = {
78
+ id: string;
79
+ fromElementId?: string;
80
+ from?: string;
81
+ toElementId?: string;
82
+ to?: string;
83
+ };
84
+
85
+ export type CanvasComment = {
86
+ id: string;
87
+ elementId?: string;
88
+ text: string;
89
+ author: string;
90
+ created: string;
91
+ thread?: { author: string; text: string }[];
92
+ };
93
+
94
+ export type CanvasViewport = { x: number; y: number; zoom: number };
95
+
96
+ export type Canvas = {
97
+ schemaVersion?: number;
98
+ title: string;
99
+ elements: CanvasElement[];
100
+ connections: CanvasConnection[];
101
+ comments: CanvasComment[];
102
+ viewport: CanvasViewport;
103
+ };
104
+
105
+ export type ConfigResponse = {
106
+ path: string;
107
+ data: unknown;
108
+ raw: string;
109
+ exists: boolean;
110
+ };
111
+
112
+ export type Settings = {
113
+ theme: ThemeSettings;
114
+ ui: UiSettings;
115
+ defaultAgent: string;
116
+ defaultModel: string;
117
+ notifications: {
118
+ onAgentComplete: boolean;
119
+ onPlanApproval: boolean;
120
+ };
121
+ dashboard: {
122
+ autoLaunchWeb: boolean;
123
+ };
124
+ service: {
125
+ enabled: boolean;
126
+ autostart: boolean;
127
+ };
128
+ about: {
129
+ version: string;
130
+ homepage: string;
131
+ license: string;
132
+ };
133
+ };
134
+
135
+ export type SettingsResponse = {
136
+ path: string;
137
+ data: Settings;
138
+ exists: boolean;
139
+ };
140
+
141
+ export type OverviewCounts = {
142
+ agents: number;
143
+ plans: number;
144
+ projects: number;
145
+ sessions: number;
146
+ activeProject?: string | null;
147
+ };
148
+
149
+ export type OverviewVersions = {
150
+ node: string;
151
+ platform: string;
152
+ projectRoot: string;
153
+ bizarRoot: string;
154
+ };
155
+
156
+ export type ActivityItem = {
157
+ ts: string;
158
+ kind: string;
159
+ [k: string]: unknown;
160
+ };
161
+
162
+ export type Overview = {
163
+ counts: OverviewCounts;
164
+ recentActivity: ActivityItem[];
165
+ versions: OverviewVersions;
166
+ generatedAt: string;
167
+ };
168
+
169
+ export type ChatMessage = {
170
+ role: 'user' | 'assistant' | 'system' | string;
171
+ content?: string;
172
+ message?: string;
173
+ agent?: string;
174
+ ts?: string | number;
175
+ pinned?: boolean;
176
+ };
177
+
178
+ export type ChatSession = {
179
+ id: string;
180
+ file: string;
181
+ mtime: number;
182
+ size: number;
183
+ };
184
+
185
+ export type ChatResponse = {
186
+ messages: ChatMessage[];
187
+ sessions: ChatSession[];
188
+ };
189
+
190
+ export type Task = {
191
+ id: string;
192
+ title: string;
193
+ description: string;
194
+ status: 'queued' | 'doing' | 'done' | string;
195
+ tags: string[];
196
+ priority: 'low' | 'normal' | 'high' | string;
197
+ assignee?: string | null;
198
+ parent?: string | null;
199
+ dependencies?: string[];
200
+ timeSpent?: number;
201
+ recurring?: { cron?: string; lastGenerated?: string } | null;
202
+ attachments?: string[];
203
+ comments?: { id: string; text: string; createdAt: string }[];
204
+ activity?: { id: string; type: string; ts: string; data?: unknown }[];
205
+ createdAt: string;
206
+ updatedAt: string;
207
+ completedAt?: string | null;
208
+ };
209
+
210
+ export type Schedule = {
211
+ id: string;
212
+ name: string;
213
+ type: 'cron' | 'interval' | 'once';
214
+ schedule: string;
215
+ action: {
216
+ type: 'command' | 'agent' | 'webhook';
217
+ target: string;
218
+ method?: string;
219
+ body?: unknown;
220
+ };
221
+ enabled: boolean;
222
+ createdAt: string;
223
+ updatedAt: string;
224
+ lastRun: string | null;
225
+ lastResult: string | null;
226
+ nextRun: string | null;
227
+ history: { ts: string; result: string; error: string | null }[];
228
+ };
229
+
230
+ export type Mod = {
231
+ id: string;
232
+ name: string;
233
+ version: string;
234
+ author: string;
235
+ description: string;
236
+ bizar: string;
237
+ type: string;
238
+ enabled: boolean;
239
+ permissions: string[];
240
+ entry: Record<string, string>;
241
+ files: { category: string; name: string; path: string }[];
242
+ path: string;
243
+ installedAt: string | null;
244
+ };
245
+
246
+ export type Provider = {
247
+ id: string;
248
+ name: string;
249
+ baseURL: string;
250
+ apiKey: string;
251
+ models: string[];
252
+ enabled: boolean;
253
+ };
254
+
255
+ export type McpServer = {
256
+ id: string;
257
+ command: string;
258
+ args: string[];
259
+ env: Record<string, string>;
260
+ enabled: boolean;
261
+ };
262
+
263
+ export type DiagnosticItem = {
264
+ line: string;
265
+ ts: string | null;
266
+ };
267
+
268
+ export type Diagnostics = {
269
+ version: string;
270
+ uptime: number;
271
+ uptimeMs: number;
272
+ nodeVersion: string;
273
+ platform: string;
274
+ memory: { rss: number; heapUsed: number; heapTotal: number };
275
+ counts: {
276
+ agents: number;
277
+ plans: number;
278
+ tasks: number;
279
+ projects: number;
280
+ activeProject: string | null;
281
+ mods: number;
282
+ schedules: number;
283
+ providers: number;
284
+ mcps: number;
285
+ };
286
+ errors: DiagnosticItem[];
287
+ service: { running: boolean; pid?: number; error?: string };
288
+ };
289
+
290
+ export type TailscaleStatus = {
291
+ installed: boolean;
292
+ version: string | null;
293
+ authenticated: boolean;
294
+ backend: string;
295
+ hostname: string;
296
+ settings: { enabled: boolean; port: number; https: boolean; hostname: string };
297
+ };
298
+
299
+ export type SearchResult = {
300
+ type: string;
301
+ score: number;
302
+ item: Record<string, unknown>;
303
+ };
304
+
305
+ export type Snapshot = {
306
+ overview: Overview;
307
+ agents: Agent[];
308
+ plans: Plan[];
309
+ projects: ProjectRecord[];
310
+ activeProject: ProjectRecord | null;
311
+ config: ConfigResponse;
312
+ settings: SettingsResponse;
313
+ tasks: Task[];
314
+ mods: Mod[];
315
+ schedules: Schedule[];
316
+ providers: Provider[];
317
+ mcps: McpServer[];
318
+ };
319
+
320
+ export type WsStatus = 'connecting' | 'connected' | 'disconnected';
321
+
322
+ export type WsMessage =
323
+ | { type: 'snapshot'; ts: number; data: Snapshot }
324
+ | { type: 'change'; event: string; path: string; ts: number }
325
+ | { type: 'tasks:change'; task: Task }
326
+ | { type: 'tasks:delete'; id: string }
327
+ | { type: 'settings:change'; settings: Settings }
328
+ | { type: 'agents:change' }
329
+ | { type: 'schedules:change' }
330
+ | { type: 'project:change'; project?: ProjectRecord }
331
+ | { type: 'chat:message'; message: ChatMessage }
332
+ | { type: 'pong'; ts: number }
333
+ | { type: 'ping' }
334
+ | { type: 'refresh' };
335
+
336
+ /** Resolve a theme to the actual key applied to <html data-theme="..."> */
337
+ export function applyTheme(themeName: ThemeName | ThemeSettings): 'dark' | 'light' {
338
+ const mode = typeof themeName === 'string' ? themeName : themeName.mode;
339
+ const resolved: 'dark' | 'light' =
340
+ mode === 'system'
341
+ ? typeof window !== 'undefined' &&
342
+ window.matchMedia('(prefers-color-scheme: light)').matches
343
+ ? 'light'
344
+ : 'dark'
345
+ : mode === 'light'
346
+ ? 'light'
347
+ : 'dark';
348
+ if (typeof document !== 'undefined') {
349
+ if (resolved === 'light') {
350
+ document.documentElement.setAttribute('data-theme', 'light');
351
+ } else {
352
+ document.documentElement.removeAttribute('data-theme');
353
+ }
354
+ }
355
+ return resolved;
356
+ }
357
+
358
+ /** Apply the full theme object — sets CSS variables. */
359
+ export function applyThemeTokens(theme: ThemeSettings) {
360
+ if (typeof document === 'undefined') return;
361
+ const root = document.documentElement;
362
+ root.style.setProperty('--accent', theme.accent);
363
+ root.style.setProperty('--success', theme.success);
364
+ root.style.setProperty('--warning', theme.warning);
365
+ root.style.setProperty('--error', theme.error);
366
+ root.style.setProperty('--info', theme.info);
367
+ // Derive accent-bg/border lightly
368
+ root.style.setProperty('--accent-bg', hexToRgba(theme.accent, 0.12));
369
+ root.style.setProperty('--accent-border', hexToRgba(theme.accent, 0.4));
370
+ if (theme.fontFamily) {
371
+ root.style.setProperty('--font-sans', `'${theme.fontFamily}', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif`);
372
+ }
373
+ if (theme.fontSize) {
374
+ root.style.setProperty('--base-font-size', `${theme.fontSize}px`);
375
+ }
376
+ root.dataset.compactMode = theme.compactMode ? 'true' : 'false';
377
+ root.dataset.animations = theme.animations ? 'true' : 'false';
378
+ }
379
+
380
+ function hexToRgba(hex: string, alpha: number) {
381
+ const m = /^#?([0-9a-f]{6})$/i.exec(hex || '');
382
+ if (!m) return `rgba(139, 92, 246, ${alpha})`;
383
+ const n = parseInt(m[1], 16);
384
+ const r = (n >> 16) & 255;
385
+ const g = (n >> 8) & 255;
386
+ const b = n & 255;
387
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`;
388
+ }
@@ -0,0 +1,79 @@
1
+ // src/lib/utils.ts — tiny shared helpers.
2
+
3
+ export function cn(...parts: (string | false | null | undefined)[]): string {
4
+ return parts.filter(Boolean).join(' ');
5
+ }
6
+
7
+ /** Compact relative time. */
8
+ export function formatRelative(ts: number | string | Date | undefined | null): string {
9
+ if (!ts && ts !== 0) return '';
10
+ const t = typeof ts === 'number' ? ts : new Date(ts).getTime();
11
+ if (Number.isNaN(t)) return '';
12
+ const diff = Date.now() - t;
13
+ if (diff < 0) return 'just now';
14
+ if (diff < 60_000) return 'just now';
15
+ if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
16
+ if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`;
17
+ if (diff < 7 * 86_400_000) return `${Math.floor(diff / 86_400_000)}d ago`;
18
+ return new Date(t).toLocaleDateString();
19
+ }
20
+
21
+ /** Full localized timestamp. */
22
+ export function formatTime(ts: number | string | Date | undefined | null): string {
23
+ if (!ts) return '';
24
+ const d = typeof ts === 'number' ? new Date(ts) : new Date(ts);
25
+ if (Number.isNaN(d.getTime())) return '';
26
+ return d.toLocaleString();
27
+ }
28
+
29
+ /** Trailing-suffix truncation. */
30
+ export function truncate(s: string | undefined | null, max = 160): string {
31
+ if (!s) return '';
32
+ return s.length > max ? s.slice(0, max) + '…' : s;
33
+ }
34
+
35
+ /** Debounce. */
36
+ export function debounce<T extends (...args: never[]) => void>(
37
+ fn: T,
38
+ ms = 200,
39
+ ): (...args: Parameters<T>) => void {
40
+ let timer: ReturnType<typeof setTimeout> | null = null;
41
+ return (...args: Parameters<T>) => {
42
+ if (timer) clearTimeout(timer);
43
+ timer = setTimeout(() => fn(...args), ms);
44
+ };
45
+ }
46
+
47
+ /** Color helpers (status badges). */
48
+ export const priorityColors: Record<string, string> = {
49
+ low: 'var(--text-dim)',
50
+ normal: 'var(--info)',
51
+ high: 'var(--error)',
52
+ };
53
+
54
+ export const statusBadgeKind = (
55
+ status: string,
56
+ ): 'success' | 'warning' | 'error' | 'info' | 'accent' | '' => {
57
+ switch (status) {
58
+ case 'approved':
59
+ case 'done':
60
+ return 'success';
61
+ case 'draft':
62
+ case 'queued':
63
+ return '';
64
+ case 'in-progress':
65
+ case 'doing':
66
+ return 'info';
67
+ case 'rejected':
68
+ return 'error';
69
+ default:
70
+ return 'accent';
71
+ }
72
+ };
73
+
74
+ /** Cheap hash for change-detection on text (e.g. raw JSON). */
75
+ export function hashText(s: string): string {
76
+ let h = 5381;
77
+ for (let i = 0; i < s.length; i++) h = ((h << 5) + h) ^ s.charCodeAt(i);
78
+ return (h >>> 0).toString(36);
79
+ }
@@ -0,0 +1,132 @@
1
+ // src/lib/ws.ts — WebSocket with auto-reconnect + status + handlers.
2
+ import type { WsMessage, WsStatus } from './types';
3
+
4
+ type Handler = (msg: WsMessage) => void;
5
+ type StatusHandler = (status: WsStatus) => void;
6
+
7
+ export class Ws {
8
+ private url: string;
9
+ private handlers = new Set<Handler>();
10
+ private statusHandlers = new Set<StatusHandler>();
11
+ private ws: WebSocket | null = null;
12
+ private reconnectDelay = 1000;
13
+ private readonly maxReconnectDelay = 15000;
14
+ private _status: WsStatus = 'connecting';
15
+ private pingTimer: ReturnType<typeof setInterval> | null = null;
16
+ private closed = false;
17
+
18
+ constructor(url?: string) {
19
+ this.url =
20
+ url ||
21
+ (typeof location !== 'undefined'
22
+ ? `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws`
23
+ : 'ws://localhost/ws');
24
+ this.connect();
25
+ }
26
+
27
+ on(handler: Handler): () => void {
28
+ this.handlers.add(handler);
29
+ return () => {
30
+ this.handlers.delete(handler);
31
+ };
32
+ }
33
+
34
+ onStatus(handler: StatusHandler): () => void {
35
+ this.statusHandlers.add(handler);
36
+ // Fire current status immediately
37
+ handler(this._status);
38
+ return () => {
39
+ this.statusHandlers.delete(handler);
40
+ };
41
+ }
42
+
43
+ get status(): WsStatus {
44
+ return this._status;
45
+ }
46
+
47
+ send(msg: unknown): boolean {
48
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
49
+ this.ws.send(JSON.stringify(msg));
50
+ return true;
51
+ }
52
+ return false;
53
+ }
54
+
55
+ close(): void {
56
+ this.closed = true;
57
+ if (this.pingTimer) clearInterval(this.pingTimer);
58
+ this.pingTimer = null;
59
+ try {
60
+ this.ws?.close();
61
+ } catch {
62
+ /* ignore */
63
+ }
64
+ }
65
+
66
+ private connect(): void {
67
+ this.setStatus('connecting');
68
+ try {
69
+ this.ws = new WebSocket(this.url);
70
+ } catch (err) {
71
+ console.warn('[ws] construct failed:', err);
72
+ this.scheduleReconnect();
73
+ return;
74
+ }
75
+ this.ws.addEventListener('open', () => {
76
+ this.reconnectDelay = 1000;
77
+ this.setStatus('connected');
78
+ // Keep-alive ping every 30s
79
+ if (this.pingTimer) clearInterval(this.pingTimer);
80
+ this.pingTimer = setInterval(() => {
81
+ if (this._status === 'connected') this.send({ type: 'ping' });
82
+ }, 30_000);
83
+ });
84
+ this.ws.addEventListener('close', () => {
85
+ this.setStatus('disconnected');
86
+ if (this.pingTimer) {
87
+ clearInterval(this.pingTimer);
88
+ this.pingTimer = null;
89
+ }
90
+ if (!this.closed) this.scheduleReconnect();
91
+ });
92
+ this.ws.addEventListener('error', () => {
93
+ // 'close' will follow — keep this for logs
94
+ console.warn('[ws] error');
95
+ });
96
+ this.ws.addEventListener('message', (e) => {
97
+ let msg: WsMessage;
98
+ try {
99
+ msg = JSON.parse(e.data);
100
+ } catch {
101
+ console.warn('[ws] bad message');
102
+ return;
103
+ }
104
+ for (const h of this.handlers) {
105
+ try {
106
+ h(msg);
107
+ } catch (err) {
108
+ console.error('[ws] handler error:', err);
109
+ }
110
+ }
111
+ });
112
+ }
113
+
114
+ private scheduleReconnect(): void {
115
+ setTimeout(() => this.connect(), this.reconnectDelay);
116
+ this.reconnectDelay = Math.min(
117
+ this.reconnectDelay * 1.6,
118
+ this.maxReconnectDelay,
119
+ );
120
+ }
121
+
122
+ private setStatus(s: WsStatus): void {
123
+ this._status = s;
124
+ for (const h of this.statusHandlers) {
125
+ try {
126
+ h(s);
127
+ } catch (err) {
128
+ console.error('[ws] status handler error:', err);
129
+ }
130
+ }
131
+ }
132
+ }
@@ -0,0 +1,12 @@
1
+ // src/main.tsx — entry point.
2
+ import { StrictMode } from 'react';
3
+ import { createRoot } from 'react-dom/client';
4
+ import { App } from './App';
5
+
6
+ const root = document.getElementById('root');
7
+ if (!root) throw new Error('Root element #root not found');
8
+ createRoot(root).render(
9
+ <StrictMode>
10
+ <App />
11
+ </StrictMode>,
12
+ );