bosun 0.36.2 → 0.36.4

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 (57) hide show
  1. package/agent-prompts.mjs +95 -0
  2. package/analyze-agent-work-helpers.mjs +308 -0
  3. package/analyze-agent-work.mjs +926 -0
  4. package/autofix.mjs +2 -0
  5. package/bosun.schema.json +101 -3
  6. package/codex-shell.mjs +85 -10
  7. package/desktop/main.mjs +871 -48
  8. package/desktop/preload.mjs +54 -1
  9. package/desktop-shortcut.mjs +90 -11
  10. package/git-editor-fix.mjs +273 -0
  11. package/mcp-registry.mjs +579 -0
  12. package/meeting-workflow-service.mjs +631 -0
  13. package/monitor.mjs +18 -103
  14. package/package.json +21 -2
  15. package/primary-agent.mjs +32 -12
  16. package/session-tracker.mjs +68 -0
  17. package/setup-web-server.mjs +20 -10
  18. package/setup.mjs +376 -83
  19. package/startup-service.mjs +51 -6
  20. package/stream-resilience.mjs +17 -7
  21. package/ui/app.js +164 -4
  22. package/ui/components/agent-selector.js +145 -1
  23. package/ui/components/chat-view.js +161 -15
  24. package/ui/components/session-list.js +2 -2
  25. package/ui/components/shared.js +188 -15
  26. package/ui/modules/icons.js +13 -0
  27. package/ui/modules/utils.js +44 -0
  28. package/ui/modules/voice-client-sdk.js +733 -0
  29. package/ui/modules/voice-overlay.js +128 -15
  30. package/ui/modules/voice.js +15 -6
  31. package/ui/setup.html +281 -81
  32. package/ui/styles/components.css +99 -3
  33. package/ui/styles/sessions.css +122 -14
  34. package/ui/styles.css +14 -0
  35. package/ui/tabs/agents.js +1 -1
  36. package/ui/tabs/chat.js +123 -14
  37. package/ui/tabs/control.js +16 -22
  38. package/ui/tabs/dashboard.js +85 -8
  39. package/ui/tabs/library.js +113 -17
  40. package/ui/tabs/settings.js +116 -2
  41. package/ui/tabs/tasks.js +388 -39
  42. package/ui/tabs/telemetry.js +0 -1
  43. package/ui/tabs/workflows.js +4 -0
  44. package/ui-server.mjs +400 -22
  45. package/update-check.mjs +41 -13
  46. package/voice-action-dispatcher.mjs +844 -0
  47. package/voice-agents-sdk.mjs +664 -0
  48. package/voice-auth-manager.mjs +164 -0
  49. package/voice-relay.mjs +1194 -0
  50. package/voice-tools.mjs +914 -0
  51. package/workflow-templates/agents.mjs +6 -2
  52. package/workflow-templates/github.mjs +154 -12
  53. package/workflow-templates.mjs +3 -0
  54. package/github-reconciler.mjs +0 -506
  55. package/merge-strategy.mjs +0 -1210
  56. package/pr-cleanup-daemon.mjs +0 -992
  57. package/workspace-reaper.mjs +0 -405
@@ -47,7 +47,7 @@ const AUTO_ACTION_LABELS = {
47
47
 
48
48
  const SCROLL_BOTTOM_TOLERANCE_PX = 6;
49
49
  const SCROLL_BOTTOM_RATIO = 0.995;
50
- const CHAT_PAGE_SIZE = 20;
50
+ const CHAT_PAGE_SIZE = 50;
51
51
  const SCROLL_TOP_TRIGGER_PX = 24;
52
52
  const SCROLL_TOP_REARM_PX = 80;
53
53
 
@@ -354,7 +354,17 @@ function AttachmentList({ attachments }) {
354
354
  }
355
355
 
356
356
  /* ─── Memoized ChatBubble — only re-renders if msg identity changes ─── */
357
- const ChatBubble = memo(function ChatBubble({ msg, isFinalModelResponse = false }) {
357
+ const ChatBubble = memo(function ChatBubble({
358
+ msg,
359
+ isFinalModelResponse = false,
360
+ canEdit = false,
361
+ isEditing = false,
362
+ editingText = "",
363
+ onEditStart = null,
364
+ onEditInput = null,
365
+ onEditSave = null,
366
+ onEditCancel = null,
367
+ }) {
358
368
  const isTool = msg.type === "tool_call" || msg.type === "tool_result";
359
369
  const isError = msg.type === "error" || msg.type === "stream_error";
360
370
  const contentText = messageText(msg);
@@ -389,16 +399,53 @@ const ChatBubble = memo(function ChatBubble({ msg, isFinalModelResponse = false
389
399
  ? html`<div class="chat-bubble-label chat-bubble-label-final">MODEL RESPONSE</div>`
390
400
  : null}
391
401
  <div class="chat-bubble-content">
392
- <${MessageContent} text=${contentText} />
393
- <${AttachmentList} attachments=${msg.attachments} />
402
+ ${isEditing
403
+ ? html`
404
+ <div class="chat-edit-block">
405
+ <textarea
406
+ class="chat-edit-textarea"
407
+ value=${editingText}
408
+ onInput=${(e) => onEditInput?.(e.target.value)}
409
+ rows="3"
410
+ />
411
+ <div class="chat-edit-actions">
412
+ <button class="btn btn-ghost btn-xs" onClick=${onEditCancel}>Cancel</button>
413
+ <button
414
+ class="btn btn-primary btn-xs"
415
+ disabled=${!String(editingText || "").trim()}
416
+ onClick=${onEditSave}
417
+ >
418
+ Save
419
+ </button>
420
+ </div>
421
+ </div>
422
+ `
423
+ : html`
424
+ <${MessageContent} text=${contentText} />
425
+ <${AttachmentList} attachments=${msg.attachments} />
426
+ `}
394
427
  </div>
395
428
  <div class="chat-bubble-time">
396
429
  ${msg.timestamp ? formatRelative(msg.timestamp) : ""}
430
+ ${msg.edited ? " · edited" : ""}
431
+ ${role === "user" && canEdit && !isEditing
432
+ ? html`
433
+ <button class="chat-edit-btn" onClick=${() => onEditStart?.(msg)}>
434
+ Edit
435
+ </button>
436
+ `
437
+ : null}
397
438
  </div>
398
439
  `}
399
440
  </div>
400
441
  `;
401
- }, (prev, next) => prev.msg === next.msg && prev.isFinalModelResponse === next.isFinalModelResponse);
442
+ }, (prev, next) =>
443
+ prev.msg === next.msg &&
444
+ prev.isFinalModelResponse === next.isFinalModelResponse &&
445
+ prev.canEdit === next.canEdit &&
446
+ prev.isEditing === next.isEditing &&
447
+ prev.editingText === next.editingText,
448
+ );
402
449
 
403
450
  const TraceEvent = memo(function TraceEvent({ msg }) {
404
451
  const info = describeTraceMessage(msg);
@@ -439,14 +486,37 @@ const TraceEvent = memo(function TraceEvent({ msg }) {
439
486
  }, (prev, next) => prev.msg === next.msg);
440
487
 
441
488
  /* ─── ThinkingGroup — collapses consecutive trace events into one row ─── */
442
- const ThinkingGroup = memo(function ThinkingGroup({ msgs }) {
489
+ const ThinkingGroup = memo(function ThinkingGroup({ msgs, isLatest = false, isAgentActive = false }) {
443
490
  const hasErrors = msgs.some((m) => m.type === "error" || m.type === "stream_error");
444
- const [expanded, setExpanded] = useState(hasErrors);
491
+ // Track whether user has manually toggled this group
492
+ const userToggledRef = useRef(false);
493
+ const [expanded, setExpanded] = useState(() => hasErrors || (isLatest && isAgentActive));
445
494
 
495
+ // Auto-close when this group is no longer the latest active group
496
+ useEffect(() => {
497
+ if (userToggledRef.current) return;
498
+ if (isLatest && isAgentActive) {
499
+ setExpanded(true);
500
+ } else if (!isLatest) {
501
+ setExpanded(false);
502
+ }
503
+ }, [isLatest, isAgentActive]);
504
+
505
+ // Always expand on errors
446
506
  useEffect(() => {
447
507
  if (hasErrors) setExpanded(true);
448
508
  }, [msgs.length, hasErrors]);
449
509
 
510
+ // Reset user-toggle when group identity changes
511
+ useEffect(() => {
512
+ userToggledRef.current = false;
513
+ }, [msgs]);
514
+
515
+ const handleToggle = useCallback(() => {
516
+ userToggledRef.current = true;
517
+ setExpanded((p) => !p);
518
+ }, []);
519
+
450
520
  const toolCount = msgs.filter((m) => m.type === "tool_call").length;
451
521
  const stepCount = msgs.filter((m) => {
452
522
  const t = (m.type || "").toLowerCase();
@@ -459,20 +529,28 @@ const ThinkingGroup = memo(function ThinkingGroup({ msgs }) {
459
529
  const label = parts.join(", ") || `${msgs.length} step${msgs.length !== 1 ? "s" : ""}`;
460
530
 
461
531
  return html`
462
- <div class="thinking-group ${expanded ? "expanded" : ""} ${hasErrors ? "has-errors" : ""}">
463
- <button class="thinking-group-head" type="button" onClick=${() => setExpanded((p) => !p)}>
464
- <span class="thinking-group-badge">${iconText(":cpu: Thinking")}</span>
532
+ <div class="thinking-group ${expanded ? "expanded" : ""} ${hasErrors ? "has-errors" : ""} ${isLatest && isAgentActive ? "thinking-group-active" : ""}">
533
+ <button class="thinking-group-head" type="button" onClick=${handleToggle}>
534
+ <span class="thinking-group-badge">
535
+ ${isLatest && isAgentActive
536
+ ? iconText(":cpu: Working…")
537
+ : iconText(":cpu: Thinking")}
538
+ </span>
465
539
  <span class="thinking-group-label">${label}</span>
466
540
  <span class="thinking-group-chevron">${expanded ? "▾" : "▸"}</span>
467
541
  </button>
468
- ${expanded && html`
542
+ <div class="thinking-group-body-wrap ${expanded ? "expanded" : ""}">
469
543
  <div class="thinking-group-body">
470
544
  ${msgs.map((m, idx) => html`<${TraceEvent} key=${m.id || m.timestamp || idx} msg=${m} />`)}
471
545
  </div>
472
- `}
546
+ </div>
473
547
  </div>
474
548
  `;
475
- }, (prev, next) => prev.msgs === next.msgs);
549
+ }, (prev, next) =>
550
+ prev.msgs === next.msgs &&
551
+ prev.isLatest === next.isLatest &&
552
+ prev.isAgentActive === next.isAgentActive,
553
+ );
476
554
 
477
555
  /* ─── Chat View component ─── */
478
556
 
@@ -495,6 +573,8 @@ export function ChatView({ sessionId, readOnly = false, embedded = false }) {
495
573
  result: false,
496
574
  error: false,
497
575
  });
576
+ const [editingMsgRef, setEditingMsgRef] = useState(null);
577
+ const [editingText, setEditingText] = useState("");
498
578
  const messagesRef = useRef(null);
499
579
  const inputRef = useRef(null);
500
580
  const fileInputRef = useRef(null);
@@ -684,6 +764,13 @@ export function ChatView({ sessionId, readOnly = false, embedded = false }) {
684
764
  i++;
685
765
  }
686
766
  }
767
+ // Mark the last thinking-group as "latest" for auto-expand/collapse
768
+ for (let j = items.length - 1; j >= 0; j--) {
769
+ if (items[j].kind === "thinking-group") {
770
+ items[j].isLatest = true;
771
+ break;
772
+ }
773
+ }
687
774
  return items;
688
775
  }, [visibleMessages]);
689
776
 
@@ -795,6 +882,8 @@ export function ChatView({ sessionId, readOnly = false, embedded = false }) {
795
882
  useEffect(() => {
796
883
  setPendingAttachments([]);
797
884
  setUploadingAttachments(false);
885
+ setEditingMsgRef(null);
886
+ setEditingText("");
798
887
  }, [sessionId]);
799
888
 
800
889
  /* Track scroll position to decide auto-scroll + unread */
@@ -903,6 +992,58 @@ export function ChatView({ sessionId, readOnly = false, embedded = false }) {
903
992
  setPendingAttachments((prev) => prev.filter((_, i) => i !== index));
904
993
  }, []);
905
994
 
995
+ const handleStartEdit = useCallback((msg) => {
996
+ setEditingMsgRef(msg || null);
997
+ setEditingText(messageText(msg));
998
+ }, []);
999
+
1000
+ const handleCancelEdit = useCallback(() => {
1001
+ setEditingMsgRef(null);
1002
+ setEditingText("");
1003
+ }, []);
1004
+
1005
+ const handleSaveEdit = useCallback(async () => {
1006
+ if (!sessionId || !editingMsgRef) return;
1007
+ const next = String(editingText || "").trim();
1008
+ if (!next) return;
1009
+
1010
+ const previousContent = messageText(editingMsgRef);
1011
+ if (next === previousContent) {
1012
+ setEditingMsgRef(null);
1013
+ setEditingText("");
1014
+ return;
1015
+ }
1016
+
1017
+ const editedAt = new Date().toISOString();
1018
+ sessionMessages.value = (sessionMessages.value || []).map((msg) =>
1019
+ msg === editingMsgRef
1020
+ ? { ...msg, content: next, edited: true, editedAt }
1021
+ : msg,
1022
+ );
1023
+
1024
+ setEditingMsgRef(null);
1025
+ setEditingText("");
1026
+
1027
+ try {
1028
+ await apiFetch(`/api/sessions/${safeSessionId}/message/edit`, {
1029
+ method: "POST",
1030
+ body: JSON.stringify({
1031
+ messageId: editingMsgRef?.id,
1032
+ timestamp: editingMsgRef?.timestamp,
1033
+ previousContent,
1034
+ content: next,
1035
+ }),
1036
+ });
1037
+ const res = await loadSessionMessages(sessionId);
1038
+ setLoadError(res?.ok ? null : res?.error || "unavailable");
1039
+ showToast("Message updated", "success");
1040
+ } catch {
1041
+ const res = await loadSessionMessages(sessionId);
1042
+ setLoadError(res?.ok ? null : res?.error || "unavailable");
1043
+ showToast("Failed to update message", "error");
1044
+ }
1045
+ }, [sessionId, editingMsgRef, editingText]);
1046
+
906
1047
  const handleSend = useCallback(async () => {
907
1048
  const text = input.trim();
908
1049
  if ((!text && pendingAttachments.length === 0) || sending || readOnly || uploadingAttachments) return;
@@ -1224,7 +1365,7 @@ export function ChatView({ sessionId, readOnly = false, embedded = false }) {
1224
1365
  <div class="chat-loading">Loading messages…</div>
1225
1366
  `}
1226
1367
  ${!loading && messages.length === 0 && html`
1227
- <div class="chat-empty-state-inline">
1368
+ <div class="chat-empty-state-inline chat-empty-state-inline--no-box">
1228
1369
  <div class="session-empty-icon">${resolveIcon(":server:")}</div>
1229
1370
  <div class="session-empty-text">
1230
1371
  No messages yet.
@@ -1247,7 +1388,12 @@ export function ChatView({ sessionId, readOnly = false, embedded = false }) {
1247
1388
  </div>
1248
1389
  `}
1249
1390
  ${renderItems.map((item) => item.kind === "thinking-group"
1250
- ? html`<${ThinkingGroup} key=${item.key} msgs=${item.msgs} />`
1391
+ ? html`<${ThinkingGroup}
1392
+ key=${item.key}
1393
+ msgs=${item.msgs}
1394
+ isLatest=${!!item.isLatest}
1395
+ isAgentActive=${statusState !== "idle" && statusState !== "paused"}
1396
+ />`
1251
1397
  : html`<${ChatBubble}
1252
1398
  key=${item.key}
1253
1399
  msg=${item.msg}
@@ -20,7 +20,7 @@ export const sessionsError = signal(null);
20
20
  /** Pagination metadata from the last loadSessionMessages call */
21
21
  export const sessionPagination = signal(null);
22
22
 
23
- const DEFAULT_SESSION_PAGE_SIZE = 20;
23
+ const DEFAULT_SESSION_PAGE_SIZE = 50;
24
24
  const MAX_SESSION_PAGE_SIZE = 200;
25
25
 
26
26
  let _wsListenerReady = false;
@@ -549,7 +549,7 @@ function SwipeableSessionItem({
549
549
  onClick=${handleResume}
550
550
  title="Unarchive"
551
551
  >
552
- <span class="session-action-icon">:workflow:</span>
552
+ <span class="session-action-icon">${resolveIcon(":workflow:")}</span>
553
553
  <span class="session-action-label">Restore</span>
554
554
  </button>
555
555
  `
@@ -16,7 +16,11 @@ import htm from "htm";
16
16
  const html = htm.bind(h);
17
17
 
18
18
  import { ICONS } from "../modules/icons.js";
19
- import { toasts, showToast, shouldShowToast } from "../modules/state.js";
19
+ import {
20
+ toasts,
21
+ showToast,
22
+ shouldShowToast,
23
+ } from "../modules/state.js";
20
24
  import {
21
25
  haptic,
22
26
  showBackButton,
@@ -127,18 +131,65 @@ export function SkeletonCard({ height = "80px", className = "" }) {
127
131
 
128
132
  /**
129
133
  * Bottom-sheet modal with drag handle, title, swipe-to-dismiss, and TG BackButton integration.
130
- * @param {{title?: string, open?: boolean, onClose: () => void, children?: any, contentClassName?: string}} props
134
+ * @param {{
135
+ * title?: string,
136
+ * open?: boolean,
137
+ * onClose: () => void,
138
+ * children?: any,
139
+ * contentClassName?: string,
140
+ * footer?: any,
141
+ * unsavedChanges?: number,
142
+ * onSaveBeforeClose?: (() => Promise<boolean|{closed?: boolean}|void>)|null,
143
+ * onDiscardBeforeClose?: (() => Promise<boolean|{closed?: boolean}|void>)|null,
144
+ * activeOperationLabel?: string,
145
+ * closeGuard?: boolean
146
+ * }} props
131
147
  */
132
- export function Modal({ title, open = true, onClose, children, contentClassName = "", footer }) {
148
+ export function Modal({
149
+ title,
150
+ open = true,
151
+ onClose,
152
+ children,
153
+ contentClassName = "",
154
+ footer,
155
+ unsavedChanges = 0,
156
+ onSaveBeforeClose = null,
157
+ onDiscardBeforeClose = null,
158
+ activeOperationLabel = "",
159
+ closeGuard = true,
160
+ }) {
133
161
  const [visible, setVisible] = useState(false);
134
162
  const contentRef = useRef(null);
135
163
  const dragState = useRef({ startY: 0, startRect: 0, dragging: false });
136
164
  const [dragY, setDragY] = useState(0);
165
+ const [closePromptOpen, setClosePromptOpen] = useState(false);
166
+ const [closePromptSaving, setClosePromptSaving] = useState(false);
167
+ const scopedUnsavedCount = Number.isFinite(Number(unsavedChanges))
168
+ ? Math.max(0, Number(unsavedChanges))
169
+ : 0;
170
+ const hasScopedUnsaved = scopedUnsavedCount > 0;
171
+ const hasUnsaved = hasScopedUnsaved;
172
+ const operationLabel = String(activeOperationLabel || "").trim();
173
+
174
+ const requestClose = useCallback(() => {
175
+ if (!onClose) return;
176
+ if (!closeGuard || (!hasUnsaved && !operationLabel)) {
177
+ onClose();
178
+ return;
179
+ }
180
+ setClosePromptOpen(true);
181
+ }, [closeGuard, hasUnsaved, onClose, operationLabel]);
137
182
 
138
183
  useEffect(() => {
139
184
  requestAnimationFrame(() => setVisible(open));
140
185
  }, [open]);
141
186
 
187
+ useEffect(() => {
188
+ if (open) return;
189
+ setClosePromptOpen(false);
190
+ setClosePromptSaving(false);
191
+ }, [open]);
192
+
142
193
  useEffect(() => {
143
194
  if (!open) return;
144
195
  document.body.classList.add("modal-open");
@@ -148,23 +199,33 @@ export function Modal({ title, open = true, onClose, children, contentClassName
148
199
  // Escape key to close (desktop support)
149
200
  useEffect(() => {
150
201
  if (!open) return;
151
- const handler = (e) => { if (e.key === 'Escape' && onClose) onClose(); };
202
+ const handler = (e) => {
203
+ if (e.key !== "Escape") return;
204
+ if (closePromptOpen) {
205
+ setClosePromptOpen(false);
206
+ return;
207
+ }
208
+ requestClose();
209
+ };
152
210
  document.addEventListener('keydown', handler);
153
211
  return () => document.removeEventListener('keydown', handler);
154
- }, [open, onClose]);
212
+ }, [closePromptOpen, open, requestClose]);
155
213
 
156
214
  // BackButton integration
157
215
  useEffect(() => {
158
216
  const handler = () => {
159
- onClose();
160
- hideBackButton();
217
+ if (closePromptOpen) {
218
+ setClosePromptOpen(false);
219
+ return;
220
+ }
221
+ requestClose();
161
222
  };
162
223
  showBackButton(handler);
163
224
 
164
225
  return () => {
165
226
  hideBackButton();
166
227
  };
167
- }, [onClose]);
228
+ }, [closePromptOpen, requestClose]);
168
229
 
169
230
  // Prevent body scroll while dragging
170
231
  useEffect(() => {
@@ -234,10 +295,10 @@ export function Modal({ title, open = true, onClose, children, contentClassName
234
295
  if (el) el.style.transition = "";
235
296
  if (dragY > 150) {
236
297
  haptic("light");
237
- onClose();
298
+ requestClose();
238
299
  }
239
300
  setDragY(0);
240
- }, [dragY, onClose]);
301
+ }, [dragY, requestClose]);
241
302
 
242
303
  const handlePointerDown = useCallback((e) => {
243
304
  if (e.pointerType === "touch") return;
@@ -282,10 +343,10 @@ export function Modal({ title, open = true, onClose, children, contentClassName
282
343
  }
283
344
  if (dragY > 150) {
284
345
  haptic("light");
285
- onClose();
346
+ requestClose();
286
347
  }
287
348
  setDragY(0);
288
- }, [dragY, onClose]);
349
+ }, [dragY, requestClose]);
289
350
 
290
351
  const handlePointerCancel = useCallback((e) => {
291
352
  if (!dragState.current.dragging) return;
@@ -310,6 +371,71 @@ export function Modal({ title, open = true, onClose, children, contentClassName
310
371
 
311
372
  if (!open) return null;
312
373
 
374
+ const guardTitle = hasUnsaved
375
+ ? "You have unsaved changes"
376
+ : "Action in progress";
377
+ const guardUnsavedLine = hasUnsaved
378
+ ? hasScopedUnsaved
379
+ ? `You have unsaved changes (${scopedUnsavedCount}).`
380
+ : "You have unsaved changes."
381
+ : "";
382
+ const guardActivityLine = operationLabel
383
+ ? `Active operation: ${operationLabel}.`
384
+ : "";
385
+ const guardHintLine = operationLabel
386
+ ? "Closing now may ignore pending updates."
387
+ : "Choose whether to save before closing.";
388
+
389
+ const handleDiscardAndClose = async () => {
390
+ if (closePromptSaving) return;
391
+ try {
392
+ if (typeof onDiscardBeforeClose === "function") {
393
+ const result = await onDiscardBeforeClose();
394
+ if (result === false) return;
395
+ if (result && typeof result === "object" && result.closed) {
396
+ setClosePromptOpen(false);
397
+ return;
398
+ }
399
+ }
400
+ setClosePromptOpen(false);
401
+ onClose?.();
402
+ } catch (err) {
403
+ showToast(
404
+ err?.message || "Could not discard changes before closing.",
405
+ "error",
406
+ );
407
+ }
408
+ };
409
+
410
+ const handleSaveAndClose = async () => {
411
+ if (closePromptSaving) return;
412
+ if (typeof onSaveBeforeClose !== "function") {
413
+ showToast(
414
+ "Save before close is not available for this form.",
415
+ "warning",
416
+ );
417
+ return;
418
+ }
419
+ setClosePromptSaving(true);
420
+ try {
421
+ const result = await onSaveBeforeClose();
422
+ if (result === false) return;
423
+ if (result && typeof result === "object" && result.closed) {
424
+ setClosePromptOpen(false);
425
+ return;
426
+ }
427
+ setClosePromptOpen(false);
428
+ onClose?.();
429
+ } catch (err) {
430
+ showToast(
431
+ err?.message || "Save failed. Resolve errors before closing.",
432
+ "error",
433
+ );
434
+ } finally {
435
+ setClosePromptSaving(false);
436
+ }
437
+ };
438
+
313
439
  const dragStyle = dragY > 0
314
440
  ? `transform: translateY(${dragY}px); opacity: ${Math.max(0.2, 1 - dragY / 400)}`
315
441
  : "";
@@ -318,7 +444,7 @@ export function Modal({ title, open = true, onClose, children, contentClassName
318
444
  <div
319
445
  class="modal-overlay ${visible ? "modal-overlay-visible" : ""}"
320
446
  onClick=${(e) => {
321
- if (e.target === e.currentTarget) onClose();
447
+ if (e.target === e.currentTarget) requestClose();
322
448
  }}
323
449
  >
324
450
  <div
@@ -338,7 +464,7 @@ export function Modal({ title, open = true, onClose, children, contentClassName
338
464
  <div class="modal-header">
339
465
  <div class="modal-handle"></div>
340
466
  ${title ? html`<div class="modal-title">${title}</div>` : null}
341
- <button class="modal-close-btn" onClick=${onClose} aria-label="Close">
467
+ <button class="modal-close-btn" onClick=${requestClose} aria-label="Close">
342
468
  ${ICONS.close}
343
469
  </button>
344
470
  </div>
@@ -349,7 +475,54 @@ export function Modal({ title, open = true, onClose, children, contentClassName
349
475
  </div>
350
476
  </div>
351
477
  `;
352
- return createPortal(content, document.body);
478
+ const guard = closePromptOpen
479
+ ? html`
480
+ <div
481
+ class="modal-overlay modal-overlay-visible"
482
+ onClick=${() => {
483
+ if (closePromptSaving) return;
484
+ setClosePromptOpen(false);
485
+ }}
486
+ >
487
+ <div class="confirm-dialog" onClick=${(e) => e.stopPropagation()}>
488
+ <div class="confirm-dialog-title">${guardTitle}</div>
489
+ <div class="confirm-dialog-message">
490
+ ${guardUnsavedLine ? html`<div>${guardUnsavedLine}</div>` : null}
491
+ ${guardActivityLine ? html`<div>${guardActivityLine}</div>` : null}
492
+ <div>${guardHintLine}</div>
493
+ </div>
494
+ <div class="confirm-dialog-actions">
495
+ <button
496
+ class="btn btn-secondary"
497
+ onClick=${() => setClosePromptOpen(false)}
498
+ disabled=${closePromptSaving}
499
+ >
500
+ Cancel
501
+ </button>
502
+ <button
503
+ class="btn btn-secondary"
504
+ onClick=${handleDiscardAndClose}
505
+ disabled=${closePromptSaving}
506
+ >
507
+ ${hasUnsaved ? "Discard & Close" : "Close Anyway"}
508
+ </button>
509
+ ${hasUnsaved
510
+ ? html`
511
+ <button
512
+ class="btn btn-primary"
513
+ onClick=${handleSaveAndClose}
514
+ disabled=${closePromptSaving || typeof onSaveBeforeClose !== "function"}
515
+ >
516
+ ${closePromptSaving ? "Saving…" : "Save & Close"}
517
+ </button>
518
+ `
519
+ : null}
520
+ </div>
521
+ </div>
522
+ </div>
523
+ `
524
+ : null;
525
+ return createPortal(html`${content}${guard}`, document.body);
353
526
  }
354
527
 
355
528
  /* ═══════════════════════════════════════════════
@@ -626,6 +626,19 @@ export const ICONS = {
626
626
  <line x1="8" y1="23" x2="16" y2="23" />
627
627
  </svg>`,
628
628
 
629
+ headphones: html`<svg
630
+ viewBox="0 0 24 24"
631
+ fill="none"
632
+ stroke="currentColor"
633
+ stroke-width="2"
634
+ stroke-linecap="round"
635
+ stroke-linejoin="round"
636
+ >
637
+ <path d="M4 14v-2a8 8 0 0 1 16 0v2" />
638
+ <path d="M4 14a3 3 0 0 0 3 3h1v-6H7a3 3 0 0 0-3 3z" />
639
+ <path d="M20 14a3 3 0 0 1-3 3h-1v-6h1a3 3 0 0 1 3 3z" />
640
+ </svg>`,
641
+
629
642
  palette: html`<svg
630
643
  viewBox="0 0 24 24"
631
644
  fill="none"
@@ -210,6 +210,50 @@ export function classNames(...args) {
210
210
  return classes.join(" ");
211
211
  }
212
212
 
213
+ /**
214
+ * Build a stable serialized representation for change detection.
215
+ * Object keys are sorted to avoid order-related false positives.
216
+ * @param {*} value
217
+ * @returns {string}
218
+ */
219
+ export function stableSerialize(value) {
220
+ if (value === undefined) return "undefined";
221
+ if (value === null) return "null";
222
+ if (typeof value === "number" && !Number.isFinite(value)) {
223
+ return `"${String(value)}"`;
224
+ }
225
+ if (Array.isArray(value)) {
226
+ return `[${value.map((item) => stableSerialize(item)).join(",")}]`;
227
+ }
228
+ if (typeof value === "object") {
229
+ const keys = Object.keys(value).sort();
230
+ return `{${keys
231
+ .map((key) => `${JSON.stringify(key)}:${stableSerialize(value[key])}`)
232
+ .join(",")}}`;
233
+ }
234
+ return JSON.stringify(value);
235
+ }
236
+
237
+ /**
238
+ * Count changed top-level fields between two plain objects.
239
+ * Values are compared using stable serialization.
240
+ * @param {Record<string, any>} initial
241
+ * @param {Record<string, any>} current
242
+ * @returns {number}
243
+ */
244
+ export function countChangedFields(initial = {}, current = {}) {
245
+ const base = initial && typeof initial === "object" ? initial : {};
246
+ const next = current && typeof current === "object" ? current : {};
247
+ const keys = new Set([...Object.keys(base), ...Object.keys(next)]);
248
+ let changed = 0;
249
+ for (const key of keys) {
250
+ if (stableSerialize(base[key]) !== stableSerialize(next[key])) {
251
+ changed += 1;
252
+ }
253
+ }
254
+ return changed;
255
+ }
256
+
213
257
  /* ─── Data Export Utilities ─── */
214
258
 
215
259
  /**