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.
- package/agent-prompts.mjs +95 -0
- package/analyze-agent-work-helpers.mjs +308 -0
- package/analyze-agent-work.mjs +926 -0
- package/autofix.mjs +2 -0
- package/bosun.schema.json +101 -3
- package/codex-shell.mjs +85 -10
- package/desktop/main.mjs +871 -48
- package/desktop/preload.mjs +54 -1
- package/desktop-shortcut.mjs +90 -11
- package/git-editor-fix.mjs +273 -0
- package/mcp-registry.mjs +579 -0
- package/meeting-workflow-service.mjs +631 -0
- package/monitor.mjs +18 -103
- package/package.json +21 -2
- package/primary-agent.mjs +32 -12
- package/session-tracker.mjs +68 -0
- package/setup-web-server.mjs +20 -10
- package/setup.mjs +376 -83
- package/startup-service.mjs +51 -6
- package/stream-resilience.mjs +17 -7
- package/ui/app.js +164 -4
- package/ui/components/agent-selector.js +145 -1
- package/ui/components/chat-view.js +161 -15
- package/ui/components/session-list.js +2 -2
- package/ui/components/shared.js +188 -15
- package/ui/modules/icons.js +13 -0
- package/ui/modules/utils.js +44 -0
- package/ui/modules/voice-client-sdk.js +733 -0
- package/ui/modules/voice-overlay.js +128 -15
- package/ui/modules/voice.js +15 -6
- package/ui/setup.html +281 -81
- package/ui/styles/components.css +99 -3
- package/ui/styles/sessions.css +122 -14
- package/ui/styles.css +14 -0
- package/ui/tabs/agents.js +1 -1
- package/ui/tabs/chat.js +123 -14
- package/ui/tabs/control.js +16 -22
- package/ui/tabs/dashboard.js +85 -8
- package/ui/tabs/library.js +113 -17
- package/ui/tabs/settings.js +116 -2
- package/ui/tabs/tasks.js +388 -39
- package/ui/tabs/telemetry.js +0 -1
- package/ui/tabs/workflows.js +4 -0
- package/ui-server.mjs +400 -22
- package/update-check.mjs +41 -13
- package/voice-action-dispatcher.mjs +844 -0
- package/voice-agents-sdk.mjs +664 -0
- package/voice-auth-manager.mjs +164 -0
- package/voice-relay.mjs +1194 -0
- package/voice-tools.mjs +914 -0
- package/workflow-templates/agents.mjs +6 -2
- package/workflow-templates/github.mjs +154 -12
- package/workflow-templates.mjs +3 -0
- package/github-reconciler.mjs +0 -506
- package/merge-strategy.mjs +0 -1210
- package/pr-cleanup-daemon.mjs +0 -992
- 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 =
|
|
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({
|
|
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
|
-
|
|
393
|
-
|
|
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) =>
|
|
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
|
-
|
|
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=${
|
|
464
|
-
<span class="thinking-group-badge"
|
|
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
|
|
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) =>
|
|
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}
|
|
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 =
|
|
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"
|
|
552
|
+
<span class="session-action-icon">${resolveIcon(":workflow:")}</span>
|
|
553
553
|
<span class="session-action-label">Restore</span>
|
|
554
554
|
</button>
|
|
555
555
|
`
|
package/ui/components/shared.js
CHANGED
|
@@ -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 {
|
|
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 {{
|
|
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({
|
|
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) => {
|
|
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,
|
|
212
|
+
}, [closePromptOpen, open, requestClose]);
|
|
155
213
|
|
|
156
214
|
// BackButton integration
|
|
157
215
|
useEffect(() => {
|
|
158
216
|
const handler = () => {
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
}, [
|
|
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
|
-
|
|
298
|
+
requestClose();
|
|
238
299
|
}
|
|
239
300
|
setDragY(0);
|
|
240
|
-
}, [dragY,
|
|
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
|
-
|
|
346
|
+
requestClose();
|
|
286
347
|
}
|
|
287
348
|
setDragY(0);
|
|
288
|
-
}, [dragY,
|
|
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)
|
|
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=${
|
|
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
|
-
|
|
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
|
/* ═══════════════════════════════════════════════
|
package/ui/modules/icons.js
CHANGED
|
@@ -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"
|
package/ui/modules/utils.js
CHANGED
|
@@ -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
|
/**
|