devglide 0.1.2 → 0.1.3

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,701 @@
1
+ // ── Devglide Unified App Shell ─────────────────────────────────────────────────
2
+ // SPA router with page module loading, sidebar navigation, project selector,
3
+ // voice widget, and mobile drawer support.
4
+
5
+ import {
6
+ dashboardSocket,
7
+ activeProject,
8
+ onProjectChange,
9
+ onProjectListChange,
10
+ getProjectList,
11
+ getActiveProject,
12
+ } from './state.js';
13
+
14
+ // ── App registry ──────────────────────────────────────────────────────────────
15
+
16
+ const APPS = [
17
+ { id: 'kanban', name: 'Kanban', desc: 'Task management', ctx: 'project', icon: '\u25A6' },
18
+ { id: 'log', name: 'Log', desc: 'Browser console capture', ctx: 'project', icon: '\u2261' },
19
+ { id: 'test', name: 'Test', desc: 'LLM-driven UI automation', ctx: 'project', icon: '\u2713' },
20
+ { id: 'shell', name: 'Shell', desc: 'Terminal multiplexer', ctx: 'project', icon: '\u276F' },
21
+ { id: 'coder', name: 'Coder', desc: 'In-browser IDE', ctx: 'project', icon: '\u2039\u203A' },
22
+ { id: 'workflow', name: 'Workflow', desc: 'Task automation', ctx: 'project', icon: '\u2942' },
23
+ { id: 'vocabulary', name: 'Vocabulary', desc: 'Domain terminology', ctx: 'project', icon: '\u2338' },
24
+ { id: 'prompts', name: 'Prompts', desc: 'Reusable prompt library', ctx: 'project', icon: '\u270E' },
25
+ { id: 'documentation', name: 'Documentation', desc: 'Product docs & guides', ctx: 'tool', icon: '\u2630' },
26
+ { id: 'voice', name: 'Voice', desc: 'Speech-to-text', ctx: 'tool', icon: '\u25C9' },
27
+ { id: 'keymap', name: 'Keymap', desc: 'Keyboard shortcuts', ctx: 'tool', icon: '\u2328' },
28
+ ];
29
+
30
+ const DEFAULT_TOOL_APP = 'documentation';
31
+
32
+ // ── State ─────────────────────────────────────────────────────────────────────
33
+
34
+ let _storedId;
35
+ try { _storedId = localStorage.getItem('dashboard:activeApp'); } catch { _storedId = null; }
36
+ let activeId = APPS.some(a => a.id === _storedId) ? _storedId : DEFAULT_TOOL_APP;
37
+
38
+ const pageCache = {};
39
+ let currentApp = null;
40
+ let currentModule = null;
41
+
42
+ // ── Sidebar order ─────────────────────────────────────────────────────────────
43
+
44
+ function getOrderedServices(ctx) {
45
+ const pool = APPS.filter(s => s.ctx === ctx);
46
+ let saved;
47
+ try { saved = JSON.parse(localStorage.getItem('dashboard:menuOrder:' + ctx) ?? 'null'); } catch { saved = null; }
48
+ if (!saved) return [...pool];
49
+ const known = new Map(pool.map(s => [s.id, s]));
50
+ const ordered = saved.filter(id => known.has(id)).map(id => known.get(id));
51
+ pool.forEach(s => { if (!saved.includes(s.id)) ordered.push(s); });
52
+ return ordered;
53
+ }
54
+
55
+ function saveOrder(sectionEl) {
56
+ const ctx = sectionEl.dataset.section;
57
+ const ids = [...sectionEl.querySelectorAll('.service-item')].map(el => el.dataset.id);
58
+ localStorage.setItem('dashboard:menuOrder:' + ctx, JSON.stringify(ids));
59
+ }
60
+
61
+ // ── Sidebar disabled state ───────────────────────────────────────────────────
62
+
63
+ function updateSidebarDisabledState() {
64
+ const hasProject = !!getActiveProject();
65
+ const section = document.querySelector('[data-section="project"]');
66
+ if (!section) return;
67
+
68
+ // Update section label hint
69
+ const label = section.querySelector('.nav-section-label');
70
+ if (label) {
71
+ label.textContent = hasProject ? 'Project' : 'Project (select a project)';
72
+ }
73
+
74
+ section.querySelectorAll('.service-item').forEach(el => {
75
+ if (hasProject) {
76
+ el.classList.remove('disabled');
77
+ el.removeAttribute('disabled');
78
+ el.removeAttribute('aria-disabled');
79
+ el.removeAttribute('title');
80
+ el.draggable = true;
81
+ } else {
82
+ el.classList.add('disabled');
83
+ el.setAttribute('disabled', '');
84
+ el.setAttribute('aria-disabled', 'true');
85
+ el.setAttribute('title', 'Select a project first');
86
+ el.draggable = false;
87
+ }
88
+ });
89
+ }
90
+
91
+ // ── Build sidebar ─────────────────────────────────────────────────────────────
92
+
93
+ const nav = document.getElementById('service-nav');
94
+ let dragSrcId = null;
95
+ let dragSrcCtx = null;
96
+
97
+ function buildSection(ctx, label) {
98
+ const section = document.createElement('div');
99
+ section.className = 'nav-section';
100
+ section.dataset.section = ctx;
101
+
102
+ const header = document.createElement('div');
103
+ header.className = 'nav-section-label';
104
+ header.textContent = label;
105
+ section.appendChild(header);
106
+
107
+ for (const app of getOrderedServices(ctx)) {
108
+ const item = document.createElement('button');
109
+ item.className = 'service-item';
110
+ item.dataset.id = app.id;
111
+ item.draggable = true;
112
+ item.innerHTML = `
113
+ <span class="drag-handle" title="Drag to reorder">\u2807</span>
114
+ <span class="service-icon">${app.icon}</span>
115
+ <span class="service-name">${app.name}</span>
116
+ <span class="service-desc">${app.desc}</span>
117
+ `;
118
+ item.addEventListener('click', () => {
119
+ if (app.ctx === 'project' && !getActiveProject()) return;
120
+ selectApp(app.id);
121
+ });
122
+
123
+ item.addEventListener('dragstart', (e) => {
124
+ dragSrcId = app.id;
125
+ dragSrcCtx = ctx;
126
+ e.dataTransfer.effectAllowed = 'move';
127
+ setTimeout(() => item.classList.add('dragging'), 0);
128
+ });
129
+
130
+ item.addEventListener('dragover', (e) => {
131
+ e.preventDefault();
132
+ if (app.id === dragSrcId || dragSrcCtx !== ctx) return;
133
+ const rect = item.getBoundingClientRect();
134
+ const below = e.clientY >= rect.top + rect.height / 2;
135
+ item.classList.toggle('drag-over-top', !below);
136
+ item.classList.toggle('drag-over-bottom', below);
137
+ });
138
+
139
+ item.addEventListener('dragleave', () => {
140
+ item.classList.remove('drag-over-top', 'drag-over-bottom');
141
+ });
142
+
143
+ item.addEventListener('drop', (e) => {
144
+ e.preventDefault();
145
+ item.classList.remove('drag-over-top', 'drag-over-bottom');
146
+ if (!dragSrcId || dragSrcId === app.id || dragSrcCtx !== ctx) return;
147
+ const draggedEl = section.querySelector(`[data-id="${dragSrcId}"]`);
148
+ if (!draggedEl) return;
149
+ const rect = item.getBoundingClientRect();
150
+ const below = e.clientY >= rect.top + rect.height / 2;
151
+ section.insertBefore(draggedEl, below ? item.nextSibling : item);
152
+ saveOrder(section);
153
+ });
154
+
155
+ item.addEventListener('dragend', () => {
156
+ dragSrcId = null;
157
+ dragSrcCtx = null;
158
+ nav.querySelectorAll('.service-item').forEach(el => {
159
+ el.classList.remove('dragging', 'drag-over-top', 'drag-over-bottom');
160
+ });
161
+ });
162
+
163
+ section.appendChild(item);
164
+ }
165
+
166
+ return section;
167
+ }
168
+
169
+ nav.appendChild(buildSection('project', 'Project'));
170
+ nav.appendChild(buildSection('tool', 'Tools'));
171
+ updateSidebarDisabledState();
172
+
173
+ // ── Mobile drawer ─────────────────────────────────────────────────────────────
174
+
175
+ const sidebar = document.getElementById('sidebar');
176
+ const overlay = document.getElementById('overlay');
177
+ const hamburger = document.getElementById('hamburger');
178
+
179
+ function openDrawer() { sidebar.classList.add('open'); overlay.classList.add('visible'); }
180
+ function closeDrawer() { sidebar.classList.remove('open'); overlay.classList.remove('visible'); }
181
+
182
+ hamburger.addEventListener('click', () =>
183
+ sidebar.classList.contains('open') ? closeDrawer() : openDrawer()
184
+ );
185
+ overlay.addEventListener('click', closeDrawer);
186
+
187
+ // ── App selection / page module mounting ───────────────────────────────────────
188
+
189
+ async function selectApp(id) {
190
+ // Redirect to default tool app if target is project-scoped with no active project
191
+ const targetApp = APPS.find(a => a.id === id);
192
+ if (targetApp?.ctx === 'project' && !getActiveProject()) {
193
+ id = DEFAULT_TOOL_APP;
194
+ }
195
+
196
+ activeId = id;
197
+ localStorage.setItem('dashboard:activeApp', id);
198
+ closeDrawer();
199
+
200
+ // Update sidebar active state
201
+ document.querySelectorAll('.service-item').forEach(el => {
202
+ const isActive = el.dataset.id === id;
203
+ el.classList.toggle('active', isActive);
204
+ if (isActive) el.setAttribute('aria-current', 'page');
205
+ else el.removeAttribute('aria-current');
206
+ });
207
+
208
+ // Update mobile topbar title
209
+ const app = APPS.find(a => a.id === id);
210
+ const mobileTitle = document.getElementById('mobile-title');
211
+ if (mobileTitle) mobileTitle.textContent = app ? app.name : 'Devglide';
212
+
213
+ const container = document.getElementById('app-content');
214
+
215
+ // Unmount current page module
216
+ if (currentModule?.unmount) {
217
+ try { currentModule.unmount(container); } catch (e) { console.warn('[shell] unmount error:', e); }
218
+ }
219
+ container.innerHTML = '';
220
+
221
+ // Load and mount page module
222
+ if (!pageCache[id]) {
223
+ pageCache[id] = await import(`/app/${id}/page.js`);
224
+ }
225
+ currentModule = pageCache[id];
226
+
227
+ await currentModule.mount(container, {
228
+ project: getActiveProject(),
229
+ navigate: selectApp,
230
+ });
231
+
232
+ currentApp = id;
233
+ }
234
+
235
+ // ── Project UI ────────────────────────────────────────────────────────────────
236
+
237
+ function updateProjectUI() {
238
+ const nameEl = document.getElementById('project-name');
239
+ const selectorEl = document.getElementById('project-selector');
240
+ if (nameEl) {
241
+ const project = getActiveProject();
242
+ nameEl.textContent = project ? project.name : 'No project';
243
+ nameEl.title = project ? project.path : '';
244
+ }
245
+ if (selectorEl) {
246
+ selectorEl.classList.toggle('has-project', !!getActiveProject());
247
+ }
248
+ }
249
+
250
+ function buildProjectDropdown() {
251
+ const dropdown = document.getElementById('project-dropdown');
252
+ if (!dropdown) return;
253
+ dropdown.innerHTML = '';
254
+
255
+ const projects = getProjectList();
256
+
257
+ for (const p of projects) {
258
+ const item = document.createElement('button');
259
+ item.className = 'project-item' + (getActiveProject()?.id === p.id ? ' active' : '');
260
+ item.innerHTML = `
261
+ <div class="project-item-row">
262
+ <div class="project-item-info">
263
+ <span class="project-item-name">${p.name}</span>
264
+ <span class="project-item-path">${p.path}</span>
265
+ </div>
266
+ <div class="project-item-actions">
267
+ <button class="project-item-action edit" title="Edit project">\u270E</button>
268
+ <button class="project-item-action delete" title="Delete project">\u2715</button>
269
+ </div>
270
+ </div>`;
271
+
272
+ // Clicking the item activates the project
273
+ item.addEventListener('click', (e) => {
274
+ e.stopPropagation();
275
+ dashboardSocket.emit('project:activate', { id: p.id });
276
+ dropdown.classList.remove('open');
277
+ });
278
+
279
+ // Edit button
280
+ const editBtn = item.querySelector('.project-item-action.edit');
281
+ editBtn.addEventListener('click', (e) => {
282
+ e.stopPropagation();
283
+ dropdown.classList.remove('open');
284
+ openProjectModal('edit', p);
285
+ });
286
+
287
+ // Delete button — inline confirmation
288
+ const deleteBtn = item.querySelector('.project-item-action.delete');
289
+ deleteBtn.addEventListener('click', (e) => {
290
+ e.stopPropagation();
291
+ // Replace item content with confirmation
292
+ const originalHTML = item.innerHTML;
293
+ item.innerHTML = '';
294
+ const confirm = document.createElement('div');
295
+ confirm.className = 'project-delete-confirm';
296
+ confirm.innerHTML = `<span>Delete ${p.name}?</span>`;
297
+
298
+ const confirmBtn = document.createElement('button');
299
+ confirmBtn.className = 'project-modal-btn danger';
300
+ confirmBtn.textContent = 'Confirm';
301
+ confirmBtn.addEventListener('click', (ev) => {
302
+ ev.stopPropagation();
303
+ dashboardSocket.emit('project:remove', { id: p.id }, (res) => {
304
+ if (!res.ok) showModalError(res.error);
305
+ });
306
+ dropdown.classList.remove('open');
307
+ });
308
+
309
+ const cancelBtn = document.createElement('button');
310
+ cancelBtn.className = 'project-modal-btn';
311
+ cancelBtn.textContent = 'Cancel';
312
+ cancelBtn.addEventListener('click', (ev) => {
313
+ ev.stopPropagation();
314
+ item.innerHTML = originalHTML;
315
+ // Re-attach action listeners after restoring HTML
316
+ rebindItemActions(item, p, dropdown);
317
+ });
318
+
319
+ confirm.appendChild(confirmBtn);
320
+ confirm.appendChild(cancelBtn);
321
+ item.appendChild(confirm);
322
+ });
323
+
324
+ dropdown.appendChild(item);
325
+ }
326
+
327
+ const addBtn = document.createElement('button');
328
+ addBtn.className = 'project-item project-add';
329
+ addBtn.textContent = '+ Add Project\u2026';
330
+ addBtn.addEventListener('click', (e) => {
331
+ e.stopPropagation();
332
+ dropdown.classList.remove('open');
333
+ openProjectModal('add');
334
+ });
335
+ dropdown.appendChild(addBtn);
336
+ }
337
+
338
+ /** Re-bind edit/delete listeners after restoring item HTML (e.g. after cancel delete) */
339
+ function rebindItemActions(item, project, dropdown) {
340
+ const editBtn = item.querySelector('.project-item-action.edit');
341
+ const deleteBtn = item.querySelector('.project-item-action.delete');
342
+ if (editBtn) {
343
+ editBtn.addEventListener('click', (e) => {
344
+ e.stopPropagation();
345
+ dropdown.classList.remove('open');
346
+ openProjectModal('edit', project);
347
+ });
348
+ }
349
+ if (deleteBtn) {
350
+ deleteBtn.addEventListener('click', (e) => {
351
+ e.stopPropagation();
352
+ // Trigger inline delete confirmation by simulating the same flow
353
+ const originalHTML = item.innerHTML;
354
+ item.innerHTML = '';
355
+ const confirm = document.createElement('div');
356
+ confirm.className = 'project-delete-confirm';
357
+ confirm.innerHTML = `<span>Delete ${project.name}?</span>`;
358
+
359
+ const confirmBtn = document.createElement('button');
360
+ confirmBtn.className = 'project-modal-btn danger';
361
+ confirmBtn.textContent = 'Confirm';
362
+ confirmBtn.addEventListener('click', (ev) => {
363
+ ev.stopPropagation();
364
+ dashboardSocket.emit('project:remove', { id: project.id }, (res) => {
365
+ if (!res.ok) showModalError(res.error);
366
+ });
367
+ dropdown.classList.remove('open');
368
+ });
369
+
370
+ const cancelBtn = document.createElement('button');
371
+ cancelBtn.className = 'project-modal-btn';
372
+ cancelBtn.textContent = 'Cancel';
373
+ cancelBtn.addEventListener('click', (ev) => {
374
+ ev.stopPropagation();
375
+ item.innerHTML = originalHTML;
376
+ rebindItemActions(item, project, dropdown);
377
+ });
378
+
379
+ confirm.appendChild(confirmBtn);
380
+ confirm.appendChild(cancelBtn);
381
+ item.appendChild(confirm);
382
+ });
383
+ }
384
+ }
385
+
386
+ // ── Project modal ─────────────────────────────────────────────────────────────
387
+
388
+ function openProjectModal(mode, project) {
389
+ // Remove any existing modal
390
+ closeProjectModal();
391
+
392
+ const modalOverlay = document.createElement('div');
393
+ modalOverlay.className = 'project-modal-overlay';
394
+
395
+ const modal = document.createElement('div');
396
+ modal.className = 'project-modal';
397
+
398
+ const title = mode === 'edit' ? 'Edit Project' : 'Add Project';
399
+ const submitLabel = mode === 'edit' ? 'Save' : 'Add';
400
+
401
+ modal.innerHTML = `
402
+ <div class="project-modal-title">${title}</div>
403
+ <div class="project-modal-field">
404
+ <label class="project-modal-label" for="pm-name">Name</label>
405
+ <input class="project-modal-input" id="pm-name" type="text" value="${mode === 'edit' && project ? project.name : ''}" autocomplete="off" />
406
+ </div>
407
+ <div class="project-modal-field">
408
+ <label class="project-modal-label" for="pm-path">Path</label>
409
+ <div class="project-modal-path-row">
410
+ <input class="project-modal-input" id="pm-path" type="text" value="${mode === 'edit' && project ? project.path : ''}" placeholder="/absolute/path/to/project" autocomplete="off" />
411
+ <button class="project-modal-btn" id="pm-browse" type="button" title="Browse folders">\u2026</button>
412
+ </div>
413
+ <div class="folder-picker hidden" id="pm-folder-picker">
414
+ <div class="folder-picker-header">
415
+ <button class="folder-picker-up" id="pm-folder-up" title="Go up">\u2191</button>
416
+ <span class="folder-picker-path" id="pm-folder-path"></span>
417
+ </div>
418
+ <div class="folder-picker-list" id="pm-folder-list"></div>
419
+ <div class="folder-picker-actions">
420
+ <button class="project-modal-btn" id="pm-folder-cancel">Cancel</button>
421
+ <button class="project-modal-btn primary" id="pm-folder-select">Select</button>
422
+ </div>
423
+ </div>
424
+ </div>
425
+ <div class="project-modal-error" id="pm-error"></div>
426
+ <div class="project-modal-actions">
427
+ <button class="project-modal-btn" id="pm-cancel">Cancel</button>
428
+ <button class="project-modal-btn primary" id="pm-submit">${submitLabel}</button>
429
+ </div>
430
+ `;
431
+
432
+ modalOverlay.appendChild(modal);
433
+ document.body.appendChild(modalOverlay);
434
+
435
+ const nameInput = document.getElementById('pm-name');
436
+ const pathInput = document.getElementById('pm-path');
437
+ const cancelBtn = document.getElementById('pm-cancel');
438
+ const submitBtn = document.getElementById('pm-submit');
439
+
440
+ // ── Folder picker ──────────────────────────────────────────────────────
441
+ const browseBtn = document.getElementById('pm-browse');
442
+ const folderPicker = document.getElementById('pm-folder-picker');
443
+ const folderPath = document.getElementById('pm-folder-path');
444
+ const folderList = document.getElementById('pm-folder-list');
445
+ const folderUpBtn = document.getElementById('pm-folder-up');
446
+ const folderSelectBtn = document.getElementById('pm-folder-select');
447
+ const folderCancelBtn = document.getElementById('pm-folder-cancel');
448
+ let browseCurrentPath = '';
449
+
450
+ async function loadFolder(dirPath) {
451
+ try {
452
+ const url = '/api/dashboard/browse' + (dirPath ? '?path=' + encodeURIComponent(dirPath) : '');
453
+ const res = await fetch(url);
454
+ const data = await res.json();
455
+ if (!res.ok) { showModalError(data.error || 'Cannot browse'); return; }
456
+
457
+ browseCurrentPath = data.path;
458
+ folderPath.textContent = data.path;
459
+ folderPath.title = data.path;
460
+ folderList.innerHTML = '';
461
+
462
+ for (const name of data.dirs) {
463
+ const item = document.createElement('button');
464
+ item.className = 'folder-picker-item';
465
+ item.textContent = name;
466
+ item.addEventListener('click', () => loadFolder(data.path + '/' + name));
467
+ folderList.appendChild(item);
468
+ }
469
+
470
+ if (data.dirs.length === 0) {
471
+ const empty = document.createElement('div');
472
+ empty.className = 'folder-picker-empty';
473
+ empty.textContent = 'No subdirectories';
474
+ folderList.appendChild(empty);
475
+ }
476
+ } catch {
477
+ showModalError('Failed to load directory');
478
+ }
479
+ }
480
+
481
+ browseBtn.addEventListener('click', () => {
482
+ folderPicker.classList.toggle('hidden');
483
+ if (!folderPicker.classList.contains('hidden')) {
484
+ loadFolder(pathInput.value.trim() || '');
485
+ }
486
+ });
487
+
488
+ folderUpBtn.addEventListener('click', () => {
489
+ const parent = browseCurrentPath.replace(/\/[^/]+$/, '') || '/';
490
+ loadFolder(parent);
491
+ });
492
+
493
+ folderSelectBtn.addEventListener('click', () => {
494
+ pathInput.value = browseCurrentPath;
495
+ folderPicker.classList.add('hidden');
496
+ // Auto-fill name from folder basename if empty
497
+ if (!nameInput.value.trim()) {
498
+ const basename = browseCurrentPath.split('/').filter(Boolean).pop();
499
+ if (basename) nameInput.value = basename;
500
+ }
501
+ });
502
+
503
+ folderCancelBtn.addEventListener('click', () => {
504
+ folderPicker.classList.add('hidden');
505
+ });
506
+
507
+ // Auto-focus name input
508
+ requestAnimationFrame(() => nameInput.focus());
509
+
510
+ // Submit handler
511
+ function handleSubmit() {
512
+ const name = nameInput.value.trim();
513
+ const path = pathInput.value.trim();
514
+
515
+ // Clear previous errors
516
+ nameInput.classList.remove('error');
517
+ pathInput.classList.remove('error');
518
+
519
+ if (!name) {
520
+ nameInput.classList.add('error');
521
+ showModalError('Project name is required.');
522
+ nameInput.focus();
523
+ return;
524
+ }
525
+ if (!path) {
526
+ pathInput.classList.add('error');
527
+ showModalError('Absolute path is required.');
528
+ pathInput.focus();
529
+ return;
530
+ }
531
+
532
+ if (mode === 'add') {
533
+ dashboardSocket.emit('project:add', { name, path }, (res) => {
534
+ if (res.ok) {
535
+ closeProjectModal();
536
+ } else {
537
+ showModalError(res.error || 'Failed to add project.');
538
+ }
539
+ });
540
+ } else if (mode === 'edit' && project) {
541
+ dashboardSocket.emit('project:update', { id: project.id, name, path }, (res) => {
542
+ if (res.ok) {
543
+ closeProjectModal();
544
+ } else {
545
+ showModalError(res.error || 'Failed to update project.');
546
+ }
547
+ });
548
+ }
549
+ }
550
+
551
+ submitBtn.addEventListener('click', handleSubmit);
552
+ cancelBtn.addEventListener('click', closeProjectModal);
553
+
554
+ // Backdrop click closes modal
555
+ modalOverlay.addEventListener('click', (e) => {
556
+ if (e.target === modalOverlay) closeProjectModal();
557
+ });
558
+
559
+ // Escape key closes modal
560
+ function onKeyDown(e) {
561
+ if (e.key === 'Escape') {
562
+ closeProjectModal();
563
+ } else if (e.key === 'Enter') {
564
+ handleSubmit();
565
+ }
566
+ }
567
+ document.addEventListener('keydown', onKeyDown);
568
+
569
+ // Store cleanup reference so closeProjectModal can remove listener
570
+ modalOverlay._keydownHandler = onKeyDown;
571
+ }
572
+
573
+ function closeProjectModal() {
574
+ const existing = document.querySelector('.project-modal-overlay');
575
+ if (existing) {
576
+ if (existing._keydownHandler) {
577
+ document.removeEventListener('keydown', existing._keydownHandler);
578
+ }
579
+ existing.remove();
580
+ }
581
+ }
582
+
583
+ function showModalError(msg) {
584
+ const errorEl = document.getElementById('pm-error');
585
+ if (errorEl) {
586
+ errorEl.textContent = msg;
587
+ }
588
+ }
589
+
590
+ // ── Project selector toggle ───────────────────────────────────────────────────
591
+
592
+ const projectSelector = document.getElementById('project-selector');
593
+ const projectDropdown = document.getElementById('project-dropdown');
594
+
595
+ if (projectSelector && projectDropdown) {
596
+ projectSelector.addEventListener('click', (e) => {
597
+ e.stopPropagation();
598
+ buildProjectDropdown();
599
+ projectDropdown.classList.toggle('open');
600
+ });
601
+
602
+ document.addEventListener('click', () => {
603
+ projectDropdown.classList.remove('open');
604
+ });
605
+ }
606
+
607
+ // ── Project change propagation ────────────────────────────────────────────────
608
+
609
+ let _initialLoad = true;
610
+
611
+ onProjectChange((project) => {
612
+ updateProjectUI();
613
+ updateSidebarDisabledState();
614
+
615
+ if (_initialLoad) {
616
+ // First project:active from socket — now we know the project state.
617
+ // Defer initial selectApp to here so the guard has accurate data.
618
+ _initialLoad = false;
619
+ selectApp(activeId);
620
+ return;
621
+ }
622
+
623
+ if (!project) {
624
+ // No project — redirect to default tool app if currently on a project-scoped app
625
+ const cur = APPS.find(a => a.id === activeId);
626
+ if (cur?.ctx === 'project') {
627
+ selectApp(DEFAULT_TOOL_APP);
628
+ return;
629
+ }
630
+ }
631
+
632
+ if (currentModule?.onProjectChange) {
633
+ try { currentModule.onProjectChange(project); } catch (e) { console.warn('[shell] onProjectChange error:', e); }
634
+ }
635
+ });
636
+
637
+ // Register project list updates
638
+ onProjectListChange(() => {
639
+ // Re-build dropdown if it's open
640
+ const dropdown = document.getElementById('project-dropdown');
641
+ if (dropdown?.classList.contains('open')) buildProjectDropdown();
642
+ });
643
+
644
+ // ── Voice widget ──────────────────────────────────────────────────────────────
645
+
646
+ let voiceWidget = null;
647
+
648
+ let voiceErrorTimer = null;
649
+ function showVoiceError(message) {
650
+ const popup = document.getElementById('voice-error-popup');
651
+ if (!popup) return;
652
+ popup.textContent = message;
653
+ popup.classList.remove('hidden');
654
+ popup.getBoundingClientRect();
655
+ popup.classList.add('visible');
656
+ clearTimeout(voiceErrorTimer);
657
+ voiceErrorTimer = setTimeout(() => {
658
+ popup.classList.remove('visible');
659
+ popup.addEventListener('transitionend', () => popup.classList.add('hidden'), { once: true });
660
+ }, 4000);
661
+ }
662
+
663
+ if (typeof VoiceWidget !== 'undefined') {
664
+ voiceWidget = VoiceWidget.create({
665
+ voiceUrl: window.location.origin,
666
+ onResult(text) {
667
+ document.dispatchEvent(new CustomEvent('voice:result', { detail: { text } }));
668
+ },
669
+ onError(err) {
670
+ const msg = err.message || 'Voice error';
671
+ const isNoMic = /microphone|mic|NotFound|DevicesNotFound/i.test(msg);
672
+ showVoiceError(isNoMic ? 'No microphone found — connect a mic and try again.' : msg);
673
+ },
674
+ });
675
+
676
+ const voiceMountEl = document.getElementById('voice-widget-mount');
677
+ if (voiceMountEl) voiceWidget.mount(voiceMountEl);
678
+
679
+ // Handle Ctrl+Alt+Shift hold-to-speak when the shell has focus
680
+ let voiceKeyActive = false;
681
+ document.addEventListener('keydown', (e) => {
682
+ const isVoiceHotkey = typeof KeymapRegistry !== 'undefined'
683
+ ? KeymapRegistry.matchesAction(e, 'voice:hold-to-speak')
684
+ : (e.ctrlKey && e.altKey && e.shiftKey && (e.key === 'Control' || e.key === 'Alt' || e.key === 'Shift'));
685
+ if (isVoiceHotkey && !voiceKeyActive) {
686
+ e.preventDefault();
687
+ voiceKeyActive = true;
688
+ voiceWidget.startRecording();
689
+ }
690
+ });
691
+ document.addEventListener('keyup', (e) => {
692
+ if (voiceKeyActive && (e.key === 'Shift' || e.key === 'Alt' || e.key === 'Control')) {
693
+ voiceKeyActive = false;
694
+ voiceWidget.stopRecording();
695
+ }
696
+ });
697
+ }
698
+
699
+ // ── Init ──────────────────────────────────────────────────────────────────────
700
+ // Initial selectApp is deferred to the first onProjectChange callback
701
+ // so the project guard has accurate data from the socket.