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.
- package/README.md +40 -0
- package/assets/characters/apto.png +0 -0
- package/assets/characters/claw.png +0 -0
- package/assets/characters/clawd.png +0 -0
- package/assets/characters/kiro.png +0 -0
- package/assets/generators/generate-icons.js +86 -0
- package/assets/generators/icon-128.png +0 -0
- package/assets/generators/icon-16.png +0 -0
- package/assets/generators/icon-256.png +0 -0
- package/assets/generators/icon-32.png +0 -0
- package/assets/generators/icon-64.png +0 -0
- package/assets/generators/icon-generator.html +221 -0
- package/assets/icon.icns +0 -0
- package/assets/icon.ico +0 -0
- package/assets/icon.png +0 -0
- package/bin/cli.js +16 -0
- package/index.html +26 -0
- package/main.js +358 -0
- package/modules/http-server.cjs +584 -0
- package/modules/http-utils.cjs +110 -0
- package/modules/multi-window-manager.cjs +927 -0
- package/modules/state-manager.cjs +168 -0
- package/modules/tray-manager.cjs +660 -0
- package/modules/validators.cjs +180 -0
- package/modules/ws-client.cjs +313 -0
- package/package.json +112 -0
- package/preload.js +22 -0
- package/renderer.js +84 -0
- package/shared/config.cjs +64 -0
- package/shared/constants.cjs +8 -0
- package/shared/data/constants.json +86 -0
- package/stats.html +521 -0
- package/styles.css +90 -0
|
@@ -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 };
|