vibemon 1.5.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.
@@ -0,0 +1,927 @@
1
+ /**
2
+ * Multi-window management for Vibe Monitor
3
+ * Manages multiple windows, one per project
4
+ * Supports both multi-window and single-window modes
5
+ */
6
+
7
+ const { BrowserWindow, screen } = require('electron');
8
+ const path = require('path');
9
+ const Store = require('electron-store');
10
+ const {
11
+ WINDOW_WIDTH,
12
+ WINDOW_HEIGHT,
13
+ WINDOW_GAP,
14
+ MAX_WINDOWS,
15
+ MAX_PROJECT_LIST,
16
+ SNAP_THRESHOLD,
17
+ SNAP_DEBOUNCE,
18
+ LOCK_MODES,
19
+ ALWAYS_ON_TOP_MODES,
20
+ ACTIVE_STATES
21
+ } = require('../shared/config.cjs');
22
+
23
+ // Platform-specific always-on-top level
24
+ // macOS: 'floating' (required for tray menu visibility)
25
+ // Windows/Linux: 'screen-saver' (required for window visibility in WSL/Windows)
26
+ const ALWAYS_ON_TOP_LEVEL = process.platform === 'darwin' ? 'floating' : 'screen-saver';
27
+
28
+ class MultiWindowManager {
29
+ constructor() {
30
+ this.windows = new Map(); // Map<projectId, { window, state }>
31
+ this.snapTimers = new Map(); // Map<projectId, timerId>
32
+ this.onWindowClosed = null; // callback: (projectId) => void
33
+
34
+ // Persistent settings
35
+ this.store = new Store({
36
+ defaults: {
37
+ windowMode: 'multi', // 'multi' or 'single'
38
+ lockedProject: null,
39
+ lockMode: 'on-thinking', // 'first-project' or 'on-thinking'
40
+ alwaysOnTopMode: 'active-only', // 'active-only', 'all', or 'disabled'
41
+ projectList: [] // Persisted project list for lock menu
42
+ }
43
+ });
44
+
45
+ // Window mode: 'multi' (multiple windows) or 'single' (one window with lock)
46
+ this.windowMode = this.store.get('windowMode');
47
+ this.lockedProject = this.store.get('lockedProject');
48
+ this.lockMode = this.store.get('lockMode');
49
+ this.alwaysOnTopMode = this.store.get('alwaysOnTopMode');
50
+
51
+ // Project list (tracks all projects seen) - persisted
52
+ this.projectList = this.store.get('projectList') || [];
53
+ }
54
+
55
+ // ============================================================================
56
+ // Window Mode Management
57
+ // ============================================================================
58
+
59
+ /**
60
+ * Get current window mode
61
+ * @returns {'multi'|'single'}
62
+ */
63
+ getWindowMode() {
64
+ return this.windowMode;
65
+ }
66
+
67
+ /**
68
+ * Set window mode
69
+ * @param {'multi'|'single'} mode
70
+ */
71
+ setWindowMode(mode) {
72
+ if (mode !== 'multi' && mode !== 'single') return;
73
+
74
+ this.windowMode = mode;
75
+ this.store.set('windowMode', mode);
76
+
77
+ // When switching to single mode, close extra windows
78
+ if (mode === 'single' && this.windows.size > 1) {
79
+ const projectIds = Array.from(this.windows.keys());
80
+ // Keep only the first (or locked) window
81
+ const keepProject = this.lockedProject || projectIds[0];
82
+ for (const projectId of projectIds) {
83
+ if (projectId !== keepProject) {
84
+ this.closeWindow(projectId);
85
+ }
86
+ }
87
+ }
88
+
89
+ // Clear lock when switching to multi mode
90
+ if (mode === 'multi') {
91
+ this.lockedProject = null;
92
+ this.store.set('lockedProject', null);
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Check if in multi-window mode
98
+ * @returns {boolean}
99
+ */
100
+ isMultiMode() {
101
+ return this.windowMode === 'multi';
102
+ }
103
+
104
+ // ============================================================================
105
+ // Lock Management (Single Window Mode)
106
+ // ============================================================================
107
+
108
+ /**
109
+ * Add project to the project list (persisted)
110
+ * Uses LRU (Least Recently Used) strategy to limit list size
111
+ * @param {string} project
112
+ */
113
+ addProjectToList(project) {
114
+ if (!project) return;
115
+
116
+ // Remove if already exists (will be re-added at end for LRU)
117
+ const existingIndex = this.projectList.indexOf(project);
118
+ if (existingIndex !== -1) {
119
+ this.projectList.splice(existingIndex, 1);
120
+ }
121
+
122
+ // Add to end (most recently used)
123
+ this.projectList.push(project);
124
+
125
+ // Enforce max limit (remove oldest entries)
126
+ while (this.projectList.length > MAX_PROJECT_LIST) {
127
+ this.projectList.shift();
128
+ }
129
+
130
+ this.store.set('projectList', this.projectList);
131
+ }
132
+
133
+ /**
134
+ * Get list of all known projects
135
+ * @returns {string[]}
136
+ */
137
+ getProjectList() {
138
+ return this.projectList;
139
+ }
140
+
141
+ /**
142
+ * Lock to a specific project (single mode only)
143
+ * @param {string} projectId
144
+ * @returns {boolean}
145
+ */
146
+ lockProject(projectId) {
147
+ if (this.windowMode !== 'single') return false;
148
+ if (!projectId) return false;
149
+
150
+ this.addProjectToList(projectId);
151
+ this.lockedProject = projectId;
152
+ this.store.set('lockedProject', projectId);
153
+ return true;
154
+ }
155
+
156
+ /**
157
+ * Unlock project (single mode only)
158
+ */
159
+ unlockProject() {
160
+ this.lockedProject = null;
161
+ this.store.set('lockedProject', null);
162
+ }
163
+
164
+ /**
165
+ * Get locked project
166
+ * @returns {string|null}
167
+ */
168
+ getLockedProject() {
169
+ return this.lockedProject;
170
+ }
171
+
172
+ /**
173
+ * Get current lock mode
174
+ * @returns {'first-project'|'on-thinking'}
175
+ */
176
+ getLockMode() {
177
+ return this.lockMode;
178
+ }
179
+
180
+ /**
181
+ * Get all available lock modes
182
+ * @returns {Object}
183
+ */
184
+ getLockModes() {
185
+ return LOCK_MODES;
186
+ }
187
+
188
+ /**
189
+ * Set lock mode (single mode only)
190
+ * @param {'first-project'|'on-thinking'} mode
191
+ * @returns {boolean}
192
+ */
193
+ setLockMode(mode) {
194
+ if (!LOCK_MODES[mode]) return false;
195
+
196
+ this.lockMode = mode;
197
+ this.lockedProject = null; // Reset lock when mode changes
198
+ this.store.set('lockMode', mode);
199
+ this.store.set('lockedProject', null);
200
+ return true;
201
+ }
202
+
203
+ /**
204
+ * Apply auto-lock based on lock mode
205
+ * Called when a status update is received
206
+ * @param {string} projectId
207
+ * @param {string} state - Current state (thinking, working, etc.)
208
+ */
209
+ applyAutoLock(projectId, state) {
210
+ if (this.windowMode !== 'single') return;
211
+ if (!projectId) return;
212
+
213
+ this.addProjectToList(projectId);
214
+
215
+ if (this.lockMode === 'first-project') {
216
+ // Lock to first project if not already locked
217
+ if (this.projectList.length === 1 && this.lockedProject === null) {
218
+ this.lockedProject = projectId;
219
+ this.store.set('lockedProject', projectId);
220
+ }
221
+ } else if (this.lockMode === 'on-thinking') {
222
+ // Lock when entering thinking state
223
+ if (state === 'thinking') {
224
+ this.lockedProject = projectId;
225
+ this.store.set('lockedProject', projectId);
226
+ }
227
+ }
228
+ }
229
+
230
+ // ============================================================================
231
+ // Window Position Calculation
232
+ // ============================================================================
233
+
234
+ /**
235
+ * Calculate window position by index
236
+ * Index 0 = rightmost (top-right corner)
237
+ * Each subsequent index moves left by (WINDOW_WIDTH + WINDOW_GAP)
238
+ * @param {number} index - Window index (0 = rightmost)
239
+ * @returns {{x: number, y: number}}
240
+ */
241
+ calculatePosition(index) {
242
+ const { workArea } = screen.getPrimaryDisplay();
243
+ const x = workArea.x + workArea.width - WINDOW_WIDTH - (index * (WINDOW_WIDTH + WINDOW_GAP));
244
+ const y = workArea.y;
245
+ return { x, y };
246
+ }
247
+
248
+ /**
249
+ * Check if more windows can be created
250
+ * Considers MAX_WINDOWS limit and screen width
251
+ * @returns {boolean}
252
+ */
253
+ canCreateWindow() {
254
+ // Check MAX_WINDOWS limit
255
+ if (this.windows.size >= MAX_WINDOWS) {
256
+ return false;
257
+ }
258
+
259
+ // Check if there's enough screen space
260
+ const { workArea } = screen.getPrimaryDisplay();
261
+ const requiredWidth = (this.windows.size + 1) * (WINDOW_WIDTH + WINDOW_GAP) - WINDOW_GAP;
262
+ if (requiredWidth > workArea.width) {
263
+ return false;
264
+ }
265
+
266
+ return true;
267
+ }
268
+
269
+ /**
270
+ * Create a window for a project
271
+ * In single mode: reuses existing window or respects lock
272
+ * In multi mode: creates new window per project
273
+ * @param {string} projectId - Project identifier
274
+ * @returns {{window: BrowserWindow|null, blocked: boolean, switchedProject: string|null}}
275
+ */
276
+ createWindow(projectId) {
277
+ // Return existing window if it exists for this project
278
+ const existing = this.windows.get(projectId);
279
+ if (existing) {
280
+ return { window: existing.window, blocked: false, switchedProject: null };
281
+ }
282
+
283
+ // Single window mode handling
284
+ if (this.windowMode === 'single') {
285
+ // If locked to different project, block
286
+ if (this.lockedProject && this.lockedProject !== projectId) {
287
+ return { window: null, blocked: true, switchedProject: null };
288
+ }
289
+
290
+ // If window exists for different project, switch it
291
+ if (this.windows.size > 0) {
292
+ const [oldProjectId, entry] = this.windows.entries().next().value;
293
+
294
+ // Clear timers for the old project
295
+ const snapTimer = this.snapTimers.get(oldProjectId);
296
+ if (snapTimer) {
297
+ clearTimeout(snapTimer);
298
+ this.snapTimers.delete(oldProjectId);
299
+ }
300
+
301
+ // Remove old entry and re-register with new projectId
302
+ // Note: These operations are atomic within Node.js event loop tick
303
+ this.windows.delete(oldProjectId);
304
+ this.windows.set(projectId, entry);
305
+ // Update mutable projectId for event handlers using closure
306
+ entry.currentProjectId = projectId;
307
+ // Reset state for new project (clear previous project's data)
308
+ entry.state = { project: projectId };
309
+ return { window: entry.window, blocked: false, switchedProject: oldProjectId };
310
+ }
311
+ }
312
+
313
+ // Check if we can create more windows (multi mode)
314
+ if (!this.canCreateWindow()) {
315
+ return { window: null, blocked: false, switchedProject: null };
316
+ }
317
+
318
+ // Calculate position for new window (will be the newest, so rightmost)
319
+ const index = 0;
320
+ const position = this.calculatePosition(index);
321
+
322
+ // Shift existing windows to the left
323
+ // Windows will be arranged after ready-to-show
324
+
325
+ // macOS: Use 'panel' type to prevent focus stealing
326
+ const windowOptions = {
327
+ width: WINDOW_WIDTH,
328
+ height: WINDOW_HEIGHT,
329
+ x: position.x,
330
+ y: position.y,
331
+ frame: false,
332
+ transparent: true,
333
+ alwaysOnTop: this.alwaysOnTopMode !== 'disabled',
334
+ resizable: false,
335
+ skipTaskbar: false,
336
+ hasShadow: true,
337
+ show: false,
338
+ icon: path.join(__dirname, '..', 'assets', 'icon.png'),
339
+ webPreferences: {
340
+ preload: path.join(__dirname, '..', 'preload.js'),
341
+ contextIsolation: true,
342
+ nodeIntegration: false
343
+ }
344
+ };
345
+
346
+ // On macOS, use panel type to prevent focus stealing
347
+ if (process.platform === 'darwin') {
348
+ windowOptions.type = 'panel';
349
+ }
350
+
351
+ const window = new BrowserWindow(windowOptions);
352
+
353
+ window.loadFile(path.join(__dirname, '..', 'index.html'));
354
+
355
+ // Allow window to be dragged across workspaces
356
+ window.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
357
+
358
+ // Store window entry with initial state
359
+ // currentProjectId is mutable to handle single-mode window reuse
360
+ const windowEntry = {
361
+ window,
362
+ state: null, // Initial state, will be set via updateState
363
+ currentProjectId: projectId // Mutable: updated when window is reused in single mode
364
+ };
365
+ this.windows.set(projectId, windowEntry);
366
+
367
+ // Show window without stealing focus once ready
368
+ window.once('ready-to-show', () => {
369
+ // Set always on top based on mode and current state
370
+ const currentState = windowEntry.state ? windowEntry.state.state : null;
371
+ const shouldBeOnTop = this.shouldBeAlwaysOnTop(currentState);
372
+ window.setAlwaysOnTop(shouldBeOnTop, ALWAYS_ON_TOP_LEVEL);
373
+
374
+ window.showInactive();
375
+
376
+ // Send initial state if available
377
+ if (windowEntry.state) {
378
+ window.webContents.send('state-update', windowEntry.state);
379
+ }
380
+
381
+ // Arrange all windows by state and name
382
+ this.arrangeWindowsByName();
383
+ });
384
+
385
+ // Handle window closed
386
+ // Use windowEntry.currentProjectId to get the current project (handles single-mode reuse)
387
+ window.on('closed', () => {
388
+ const currentProjectId = windowEntry.currentProjectId;
389
+
390
+ // Verify this entry still owns the projectId in the Map
391
+ // In single-mode, window may have been reused for a different project
392
+ const entry = this.windows.get(currentProjectId);
393
+ if (entry !== windowEntry) {
394
+ // Window was reused - skip cleanup for this projectId
395
+ return;
396
+ }
397
+
398
+ // Clear snap timer if exists
399
+ const snapTimer = this.snapTimers.get(currentProjectId);
400
+ if (snapTimer) {
401
+ clearTimeout(snapTimer);
402
+ this.snapTimers.delete(currentProjectId);
403
+ }
404
+
405
+ // Remove from windows map
406
+ this.windows.delete(currentProjectId);
407
+
408
+ // Notify callback
409
+ if (this.onWindowClosed) {
410
+ this.onWindowClosed(currentProjectId);
411
+ }
412
+
413
+ // Rearrange remaining windows
414
+ this.rearrangeWindows();
415
+ });
416
+
417
+ // Handle window move for snap to edges
418
+ // Use windowEntry.currentProjectId to get the current project (handles single-mode reuse)
419
+ window.on('move', () => {
420
+ this.handleWindowMove(windowEntry.currentProjectId);
421
+ });
422
+
423
+ return { window, blocked: false, switchedProject: null };
424
+ }
425
+
426
+ /**
427
+ * Arrange all windows by state and project name
428
+ * Right side: active states (thinking, planning, working, notification)
429
+ * Left side: inactive states (start, idle, done, sleep)
430
+ * Within each group: sorted by project name (Z first = rightmost)
431
+ */
432
+ arrangeWindowsByName() {
433
+ // Collect all windows with projectId and state
434
+ const windowsList = [];
435
+ for (const [projectId, entry] of this.windows) {
436
+ if (this.isWindowValid(entry)) {
437
+ const state = entry.state ? entry.state.state : 'idle';
438
+ const isActive = ACTIVE_STATES.includes(state);
439
+ windowsList.push({ projectId, entry, isActive });
440
+ }
441
+ }
442
+
443
+ // Sort: active first (rightmost), then by name descending (Z first)
444
+ windowsList.sort((a, b) => {
445
+ // Active states come first (rightmost)
446
+ if (a.isActive !== b.isActive) {
447
+ return a.isActive ? -1 : 1;
448
+ }
449
+ // Within same group, sort by name descending (Z first = rightmost)
450
+ return b.projectId.localeCompare(a.projectId);
451
+ });
452
+
453
+ // Assign positions (index 0 = rightmost)
454
+ let index = 0;
455
+ for (const { entry } of windowsList) {
456
+ const position = this.calculatePosition(index);
457
+ entry.window.setPosition(position.x, position.y);
458
+ index++;
459
+ }
460
+ }
461
+
462
+ /**
463
+ * Rearrange windows after one closes or new one created
464
+ * Sorts by project name alphabetically (A-Z from right to left)
465
+ */
466
+ rearrangeWindows() {
467
+ this.arrangeWindowsByName();
468
+ }
469
+
470
+ /**
471
+ * Handle window move event with debounced snap to edges
472
+ * @param {string} projectId - Project identifier
473
+ */
474
+ handleWindowMove(projectId) {
475
+ const entry = this.windows.get(projectId);
476
+ if (!entry || !entry.window || entry.window.isDestroyed()) {
477
+ return;
478
+ }
479
+
480
+ // Clear previous timer
481
+ const existingTimer = this.snapTimers.get(projectId);
482
+ if (existingTimer) {
483
+ clearTimeout(existingTimer);
484
+ }
485
+
486
+ // Set new timer - snap after debounce time (drag ended)
487
+ const timerId = setTimeout(() => {
488
+ if (!entry.window || entry.window.isDestroyed()) {
489
+ return;
490
+ }
491
+
492
+ const bounds = entry.window.getBounds();
493
+ const display = screen.getDisplayMatching(bounds);
494
+ const { workArea } = display;
495
+
496
+ let newX = bounds.x;
497
+ let newY = bounds.y;
498
+ let shouldSnap = false;
499
+
500
+ // Check horizontal snap (left or right edge)
501
+ if (Math.abs(bounds.x - workArea.x) < SNAP_THRESHOLD) {
502
+ newX = workArea.x;
503
+ shouldSnap = true;
504
+ } else if (Math.abs((bounds.x + bounds.width) - (workArea.x + workArea.width)) < SNAP_THRESHOLD) {
505
+ newX = workArea.x + workArea.width - bounds.width;
506
+ shouldSnap = true;
507
+ }
508
+
509
+ // Check vertical snap (top or bottom edge)
510
+ if (Math.abs(bounds.y - workArea.y) < SNAP_THRESHOLD) {
511
+ newY = workArea.y;
512
+ shouldSnap = true;
513
+ } else if (Math.abs((bounds.y + bounds.height) - (workArea.y + workArea.height)) < SNAP_THRESHOLD) {
514
+ newY = workArea.y + workArea.height - bounds.height;
515
+ shouldSnap = true;
516
+ }
517
+
518
+ // Apply snap if needed
519
+ if (shouldSnap && (newX !== bounds.x || newY !== bounds.y)) {
520
+ entry.window.setPosition(newX, newY);
521
+ }
522
+ }, SNAP_DEBOUNCE);
523
+
524
+ this.snapTimers.set(projectId, timerId);
525
+ }
526
+
527
+ // ========== Utility Methods ==========
528
+
529
+ /**
530
+ * Check if a window entry is valid (exists and not destroyed)
531
+ * @param {Object} entry - Window entry from the Map
532
+ * @returns {boolean}
533
+ */
534
+ isWindowValid(entry) {
535
+ return entry && entry.window && !entry.window.isDestroyed();
536
+ }
537
+
538
+ /**
539
+ * Get window by project ID
540
+ * @param {string} projectId
541
+ * @returns {BrowserWindow|null}
542
+ */
543
+ getWindow(projectId) {
544
+ const entry = this.windows.get(projectId);
545
+ return entry ? entry.window : null;
546
+ }
547
+
548
+ /**
549
+ * Get state by project ID
550
+ * @param {string} projectId
551
+ * @returns {Object|null}
552
+ */
553
+ getState(projectId) {
554
+ const entry = this.windows.get(projectId);
555
+ return entry ? entry.state : null;
556
+ }
557
+
558
+ /**
559
+ * Update state for a project with change detection
560
+ * Note: Entry object is mutated to preserve event handler closure references.
561
+ * The state property is replaced with a new object for partial immutability.
562
+ * @param {string} projectId
563
+ * @param {Object} newState
564
+ * @returns {{updated: boolean, stateChanged: boolean, infoChanged: boolean}}
565
+ */
566
+ updateState(projectId, newState) {
567
+ const entry = this.windows.get(projectId);
568
+ if (!entry) {
569
+ return { updated: false, stateChanged: false, infoChanged: false };
570
+ }
571
+
572
+ const oldState = entry.state || {};
573
+
574
+ // Check if state changed
575
+ const stateChanged = oldState.state !== newState.state;
576
+
577
+ // Check if info fields changed (tool, model, memory, character)
578
+ const infoChanged = !stateChanged && (
579
+ oldState.tool !== newState.tool ||
580
+ oldState.model !== newState.model ||
581
+ oldState.memory !== newState.memory ||
582
+ oldState.character !== newState.character
583
+ );
584
+
585
+ // No change - skip update
586
+ if (!stateChanged && !infoChanged) {
587
+ return { updated: false, stateChanged: false, infoChanged: false };
588
+ }
589
+
590
+ // Mutate entry's state property (entry object must be preserved for event handler closures)
591
+ entry.state = { ...newState };
592
+ return { updated: true, stateChanged, infoChanged };
593
+ }
594
+
595
+ /**
596
+ * Check if window exists for project
597
+ * @param {string} projectId
598
+ * @returns {boolean}
599
+ */
600
+ hasWindow(projectId) {
601
+ const entry = this.windows.get(projectId);
602
+ return entry !== undefined && entry.window !== null && !entry.window.isDestroyed();
603
+ }
604
+
605
+ /**
606
+ * Send data to window via IPC
607
+ * @param {string} projectId
608
+ * @param {string} channel
609
+ * @param {*} data
610
+ * @returns {boolean}
611
+ */
612
+ sendToWindow(projectId, channel, data) {
613
+ const entry = this.windows.get(projectId);
614
+ if (this.isWindowValid(entry) && !entry.window.webContents.isDestroyed()) {
615
+ entry.window.webContents.send(channel, data);
616
+ return true;
617
+ }
618
+ return false;
619
+ }
620
+
621
+ /**
622
+ * Close window for project
623
+ * @param {string} projectId
624
+ * @returns {boolean}
625
+ */
626
+ closeWindow(projectId) {
627
+ const entry = this.windows.get(projectId);
628
+ if (this.isWindowValid(entry)) {
629
+ entry.window.close();
630
+ return true;
631
+ }
632
+ return false;
633
+ }
634
+
635
+ /**
636
+ * Close all windows
637
+ */
638
+ closeAllWindows() {
639
+ for (const [projectId] of this.windows) {
640
+ this.closeWindow(projectId);
641
+ }
642
+ }
643
+
644
+ /**
645
+ * Cleanup resources on app quit
646
+ * Clears all pending timers
647
+ */
648
+ cleanup() {
649
+ for (const [, timerId] of this.snapTimers) {
650
+ clearTimeout(timerId);
651
+ }
652
+ this.snapTimers.clear();
653
+ }
654
+
655
+ /**
656
+ * Show window for project
657
+ * @param {string} projectId
658
+ * @returns {boolean}
659
+ */
660
+ showWindow(projectId) {
661
+ const entry = this.windows.get(projectId);
662
+ if (this.isWindowValid(entry)) {
663
+ entry.window.showInactive();
664
+ return true;
665
+ }
666
+ return false;
667
+ }
668
+
669
+ /**
670
+ * Show all windows
671
+ * @returns {number} Number of windows shown
672
+ */
673
+ showAllWindows() {
674
+ let count = 0;
675
+ for (const [, entry] of this.windows) {
676
+ if (this.isWindowValid(entry)) {
677
+ entry.window.showInactive();
678
+ count++;
679
+ }
680
+ }
681
+ return count;
682
+ }
683
+
684
+ /**
685
+ * Hide window for project
686
+ * @param {string} projectId
687
+ * @returns {boolean}
688
+ */
689
+ hideWindow(projectId) {
690
+ const entry = this.windows.get(projectId);
691
+ if (this.isWindowValid(entry)) {
692
+ entry.window.hide();
693
+ return true;
694
+ }
695
+ return false;
696
+ }
697
+
698
+ /**
699
+ * Get all project IDs
700
+ * @returns {string[]}
701
+ */
702
+ getProjectIds() {
703
+ return Array.from(this.windows.keys());
704
+ }
705
+
706
+ /**
707
+ * Get number of active windows
708
+ * @returns {number}
709
+ */
710
+ getWindowCount() {
711
+ return this.windows.size;
712
+ }
713
+
714
+ /**
715
+ * Determine if a window should be always on top based on mode and state
716
+ * @param {string|null} state - Current window state
717
+ * @returns {boolean}
718
+ */
719
+ shouldBeAlwaysOnTop(state) {
720
+ switch (this.alwaysOnTopMode) {
721
+ case 'all':
722
+ return true;
723
+ case 'active-only':
724
+ return state && ACTIVE_STATES.includes(state);
725
+ case 'disabled':
726
+ default:
727
+ return false;
728
+ }
729
+ }
730
+
731
+ /**
732
+ * Get always on top mode
733
+ * @returns {'active-only'|'all'|'disabled'}
734
+ */
735
+ getAlwaysOnTopMode() {
736
+ return this.alwaysOnTopMode;
737
+ }
738
+
739
+ /**
740
+ * Get all available always on top modes
741
+ * @returns {Object}
742
+ */
743
+ getAlwaysOnTopModes() {
744
+ return ALWAYS_ON_TOP_MODES;
745
+ }
746
+
747
+ /**
748
+ * Set always on top mode and update all windows
749
+ * @param {'active-only'|'all'|'disabled'} mode
750
+ */
751
+ setAlwaysOnTopMode(mode) {
752
+ if (!ALWAYS_ON_TOP_MODES[mode]) return;
753
+
754
+ this.alwaysOnTopMode = mode;
755
+ this.store.set('alwaysOnTopMode', mode);
756
+
757
+ // Update all windows based on new mode
758
+ for (const [, entry] of this.windows) {
759
+ if (this.isWindowValid(entry)) {
760
+ const state = entry.state ? entry.state.state : null;
761
+ const shouldBeOnTop = this.shouldBeAlwaysOnTop(state);
762
+ entry.window.setAlwaysOnTop(shouldBeOnTop, ALWAYS_ON_TOP_LEVEL);
763
+ }
764
+ }
765
+ }
766
+
767
+ /**
768
+ * Update always on top for a specific window based on state
769
+ * Active states (thinking, planning, working, notification) keep always on top
770
+ * Inactive states immediately disable on top (prevents focus stealing)
771
+ * Respects alwaysOnTopMode setting
772
+ * @param {string} projectId
773
+ * @param {string} state
774
+ */
775
+ updateAlwaysOnTopByState(projectId, state) {
776
+ const entry = this.windows.get(projectId);
777
+ if (!entry || !entry.window || entry.window.isDestroyed()) {
778
+ return;
779
+ }
780
+
781
+ const isActiveState = ACTIVE_STATES.includes(state);
782
+
783
+ if (this.alwaysOnTopMode === 'active-only') {
784
+ if (isActiveState) {
785
+ // Active state: immediately enable on top
786
+ entry.window.setAlwaysOnTop(true, ALWAYS_ON_TOP_LEVEL);
787
+ } else {
788
+ // Inactive states (start, idle, done, sleep): immediately disable on top
789
+ // No grace period to prevent focus stealing
790
+ entry.window.setAlwaysOnTop(false, ALWAYS_ON_TOP_LEVEL);
791
+ }
792
+ } else {
793
+ // 'all' or 'disabled' mode: apply immediately without grace period
794
+ const shouldBeOnTop = this.shouldBeAlwaysOnTop(state);
795
+ entry.window.setAlwaysOnTop(shouldBeOnTop, ALWAYS_ON_TOP_LEVEL);
796
+ }
797
+ }
798
+
799
+ /**
800
+ * Get always on top setting (legacy compatibility)
801
+ * @returns {boolean}
802
+ * @deprecated Use getAlwaysOnTopMode() instead
803
+ */
804
+ getIsAlwaysOnTop() {
805
+ return this.alwaysOnTopMode !== 'disabled';
806
+ }
807
+
808
+ /**
809
+ * Get first window (for backward compatibility)
810
+ * Returns the first (oldest) window from the Map iteration order
811
+ * Note: Map preserves insertion order, so this returns the earliest created window
812
+ * @returns {BrowserWindow|null}
813
+ */
814
+ getFirstWindow() {
815
+ if (this.windows.size === 0) {
816
+ return null;
817
+ }
818
+ // Return the first entry's window (oldest, by Map insertion order)
819
+ const firstEntry = this.windows.values().next().value;
820
+ return firstEntry ? firstEntry.window : null;
821
+ }
822
+
823
+ /**
824
+ * Get states of all windows
825
+ * @returns {Object.<string, Object>} Map of projectId to state
826
+ */
827
+ getStates() {
828
+ const states = {};
829
+ for (const [projectId, entry] of this.windows) {
830
+ states[projectId] = entry.state;
831
+ }
832
+ return states;
833
+ }
834
+
835
+ /**
836
+ * Get all window entries
837
+ * @returns {Object.<string, {window: BrowserWindow, state: Object}>}
838
+ */
839
+ getWindows() {
840
+ const result = {};
841
+ for (const [projectId, entry] of this.windows) {
842
+ result[projectId] = entry;
843
+ }
844
+ return result;
845
+ }
846
+
847
+ /**
848
+ * Show and focus the first available window
849
+ * @returns {boolean} Whether a window was shown
850
+ */
851
+ showFirstWindow() {
852
+ const firstWindow = this.getFirstWindow();
853
+ if (firstWindow && !firstWindow.isDestroyed()) {
854
+ firstWindow.show();
855
+ firstWindow.focus();
856
+ return true;
857
+ }
858
+ return false;
859
+ }
860
+
861
+ /**
862
+ * Get terminal ID for a project
863
+ * @param {string} projectId
864
+ * @returns {string|null}
865
+ */
866
+ getTerminalId(projectId) {
867
+ const entry = this.windows.get(projectId);
868
+ return entry && entry.state ? entry.state.terminalId || null : null;
869
+ }
870
+
871
+ /**
872
+ * Get project ID by webContents
873
+ * @param {Electron.WebContents} webContents
874
+ * @returns {string|null}
875
+ */
876
+ getProjectIdByWebContents(webContents) {
877
+ for (const [projectId, entry] of this.windows) {
878
+ if (entry.window && !entry.window.isDestroyed() &&
879
+ entry.window.webContents === webContents) {
880
+ return projectId;
881
+ }
882
+ }
883
+ return null;
884
+ }
885
+
886
+ /**
887
+ * Get debug info for all windows
888
+ * @returns {Object}
889
+ */
890
+ getDebugInfo() {
891
+ const displays = screen.getAllDisplays();
892
+ const primary = screen.getPrimaryDisplay();
893
+
894
+ const windowsInfo = [];
895
+ for (const [projectId, entry] of this.windows) {
896
+ if (this.isWindowValid(entry)) {
897
+ windowsInfo.push({
898
+ projectId,
899
+ bounds: entry.window.getBounds(),
900
+ state: entry.state ? entry.state.state : null
901
+ });
902
+ }
903
+ }
904
+
905
+ return {
906
+ primaryDisplay: {
907
+ bounds: primary.bounds,
908
+ workArea: primary.workArea,
909
+ workAreaSize: primary.workAreaSize,
910
+ scaleFactor: primary.scaleFactor
911
+ },
912
+ allDisplays: displays.map(d => ({
913
+ id: d.id,
914
+ bounds: d.bounds,
915
+ workArea: d.workArea,
916
+ scaleFactor: d.scaleFactor
917
+ })),
918
+ windows: windowsInfo,
919
+ windowCount: this.windows.size,
920
+ maxWindows: MAX_WINDOWS,
921
+ alwaysOnTopMode: this.alwaysOnTopMode,
922
+ platform: process.platform
923
+ };
924
+ }
925
+ }
926
+
927
+ module.exports = { MultiWindowManager };