bosun 0.36.2 → 0.36.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.
- package/analyze-agent-work-helpers.mjs +308 -0
- package/analyze-agent-work.mjs +926 -0
- package/autofix.mjs +2 -0
- package/codex-shell.mjs +85 -10
- 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 +13 -2
- package/primary-agent.mjs +32 -12
- package/session-tracker.mjs +68 -0
- package/stream-resilience.mjs +17 -7
- package/ui/app.js +19 -4
- package/ui/components/chat-view.js +108 -5
- package/ui/components/session-list.js +1 -1
- 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.js +15 -6
- package/ui/styles/components.css +99 -3
- package/ui/styles/sessions.css +84 -12
- package/ui/tabs/chat.js +5 -1
- 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 +193 -19
- package/update-check.mjs +41 -13
- package/voice-relay.mjs +816 -0
- package/voice-tools.mjs +679 -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
|
@@ -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);
|
|
@@ -495,6 +542,8 @@ export function ChatView({ sessionId, readOnly = false, embedded = false }) {
|
|
|
495
542
|
result: false,
|
|
496
543
|
error: false,
|
|
497
544
|
});
|
|
545
|
+
const [editingMsgRef, setEditingMsgRef] = useState(null);
|
|
546
|
+
const [editingText, setEditingText] = useState("");
|
|
498
547
|
const messagesRef = useRef(null);
|
|
499
548
|
const inputRef = useRef(null);
|
|
500
549
|
const fileInputRef = useRef(null);
|
|
@@ -795,6 +844,8 @@ export function ChatView({ sessionId, readOnly = false, embedded = false }) {
|
|
|
795
844
|
useEffect(() => {
|
|
796
845
|
setPendingAttachments([]);
|
|
797
846
|
setUploadingAttachments(false);
|
|
847
|
+
setEditingMsgRef(null);
|
|
848
|
+
setEditingText("");
|
|
798
849
|
}, [sessionId]);
|
|
799
850
|
|
|
800
851
|
/* Track scroll position to decide auto-scroll + unread */
|
|
@@ -903,6 +954,58 @@ export function ChatView({ sessionId, readOnly = false, embedded = false }) {
|
|
|
903
954
|
setPendingAttachments((prev) => prev.filter((_, i) => i !== index));
|
|
904
955
|
}, []);
|
|
905
956
|
|
|
957
|
+
const handleStartEdit = useCallback((msg) => {
|
|
958
|
+
setEditingMsgRef(msg || null);
|
|
959
|
+
setEditingText(messageText(msg));
|
|
960
|
+
}, []);
|
|
961
|
+
|
|
962
|
+
const handleCancelEdit = useCallback(() => {
|
|
963
|
+
setEditingMsgRef(null);
|
|
964
|
+
setEditingText("");
|
|
965
|
+
}, []);
|
|
966
|
+
|
|
967
|
+
const handleSaveEdit = useCallback(async () => {
|
|
968
|
+
if (!sessionId || !editingMsgRef) return;
|
|
969
|
+
const next = String(editingText || "").trim();
|
|
970
|
+
if (!next) return;
|
|
971
|
+
|
|
972
|
+
const previousContent = messageText(editingMsgRef);
|
|
973
|
+
if (next === previousContent) {
|
|
974
|
+
setEditingMsgRef(null);
|
|
975
|
+
setEditingText("");
|
|
976
|
+
return;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
const editedAt = new Date().toISOString();
|
|
980
|
+
sessionMessages.value = (sessionMessages.value || []).map((msg) =>
|
|
981
|
+
msg === editingMsgRef
|
|
982
|
+
? { ...msg, content: next, edited: true, editedAt }
|
|
983
|
+
: msg,
|
|
984
|
+
);
|
|
985
|
+
|
|
986
|
+
setEditingMsgRef(null);
|
|
987
|
+
setEditingText("");
|
|
988
|
+
|
|
989
|
+
try {
|
|
990
|
+
await apiFetch(`/api/sessions/${safeSessionId}/message/edit`, {
|
|
991
|
+
method: "POST",
|
|
992
|
+
body: JSON.stringify({
|
|
993
|
+
messageId: editingMsgRef?.id,
|
|
994
|
+
timestamp: editingMsgRef?.timestamp,
|
|
995
|
+
previousContent,
|
|
996
|
+
content: next,
|
|
997
|
+
}),
|
|
998
|
+
});
|
|
999
|
+
const res = await loadSessionMessages(sessionId);
|
|
1000
|
+
setLoadError(res?.ok ? null : res?.error || "unavailable");
|
|
1001
|
+
showToast("Message updated", "success");
|
|
1002
|
+
} catch {
|
|
1003
|
+
const res = await loadSessionMessages(sessionId);
|
|
1004
|
+
setLoadError(res?.ok ? null : res?.error || "unavailable");
|
|
1005
|
+
showToast("Failed to update message", "error");
|
|
1006
|
+
}
|
|
1007
|
+
}, [sessionId, editingMsgRef, editingText]);
|
|
1008
|
+
|
|
906
1009
|
const handleSend = useCallback(async () => {
|
|
907
1010
|
const text = input.trim();
|
|
908
1011
|
if ((!text && pendingAttachments.length === 0) || sending || readOnly || uploadingAttachments) return;
|
|
@@ -1224,7 +1327,7 @@ export function ChatView({ sessionId, readOnly = false, embedded = false }) {
|
|
|
1224
1327
|
<div class="chat-loading">Loading messages…</div>
|
|
1225
1328
|
`}
|
|
1226
1329
|
${!loading && messages.length === 0 && html`
|
|
1227
|
-
<div class="chat-empty-state-inline">
|
|
1330
|
+
<div class="chat-empty-state-inline chat-empty-state-inline--no-box">
|
|
1228
1331
|
<div class="session-empty-icon">${resolveIcon(":server:")}</div>
|
|
1229
1332
|
<div class="session-empty-text">
|
|
1230
1333
|
No messages yet.
|
|
@@ -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
|
/**
|
package/ui/modules/voice.js
CHANGED
|
@@ -38,8 +38,8 @@ function injectVoiceStyles() {
|
|
|
38
38
|
display: inline-flex;
|
|
39
39
|
align-items: center;
|
|
40
40
|
justify-content: center;
|
|
41
|
-
width:
|
|
42
|
-
height:
|
|
41
|
+
width: 34px;
|
|
42
|
+
height: 34px;
|
|
43
43
|
border-radius: 50%;
|
|
44
44
|
border: 1px solid rgba(255,255,255,0.10);
|
|
45
45
|
background: var(--tg-theme-secondary-bg-color, #1e1e2e);
|
|
@@ -49,10 +49,16 @@ function injectVoiceStyles() {
|
|
|
49
49
|
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
|
|
50
50
|
-webkit-tap-highlight-color: transparent;
|
|
51
51
|
padding: 0;
|
|
52
|
-
|
|
52
|
+
box-sizing: border-box;
|
|
53
|
+
font-size: 0;
|
|
53
54
|
line-height: 1;
|
|
54
55
|
user-select: none;
|
|
55
56
|
}
|
|
57
|
+
.mic-btn svg {
|
|
58
|
+
width: 16px;
|
|
59
|
+
height: 16px;
|
|
60
|
+
display: block;
|
|
61
|
+
}
|
|
56
62
|
.mic-btn:hover:not(:disabled) {
|
|
57
63
|
background: rgba(255,255,255,0.06);
|
|
58
64
|
color: var(--tg-theme-text-color, #fff);
|
|
@@ -74,9 +80,12 @@ function injectVoiceStyles() {
|
|
|
74
80
|
50% { box-shadow: 0 0 0 6px rgba(239,68,68,0.06); }
|
|
75
81
|
}
|
|
76
82
|
.mic-btn-sm {
|
|
77
|
-
width:
|
|
78
|
-
height:
|
|
79
|
-
|
|
83
|
+
width: 24px;
|
|
84
|
+
height: 24px;
|
|
85
|
+
}
|
|
86
|
+
.mic-btn-sm svg {
|
|
87
|
+
width: 12px;
|
|
88
|
+
height: 12px;
|
|
80
89
|
}
|
|
81
90
|
.mic-btn-inline {
|
|
82
91
|
position: absolute;
|
package/ui/styles/components.css
CHANGED
|
@@ -1,5 +1,70 @@
|
|
|
1
1
|
/* ─── Component Styles — iOS-style Clean Design ─── */
|
|
2
2
|
|
|
3
|
+
/* ─── Telemetry Tab ─── */
|
|
4
|
+
.telemetry-tab {
|
|
5
|
+
display: flex;
|
|
6
|
+
flex-direction: column;
|
|
7
|
+
gap: 12px;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
.telemetry-grid {
|
|
11
|
+
display: grid;
|
|
12
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
13
|
+
gap: 12px;
|
|
14
|
+
align-items: stretch;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.telemetry-grid > .card {
|
|
18
|
+
margin-bottom: 0;
|
|
19
|
+
min-height: 240px;
|
|
20
|
+
display: flex;
|
|
21
|
+
flex-direction: column;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.telemetry-grid > .card .empty-state {
|
|
25
|
+
flex: 1;
|
|
26
|
+
display: flex;
|
|
27
|
+
align-items: center;
|
|
28
|
+
justify-content: center;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.telemetry-list {
|
|
32
|
+
list-style: none;
|
|
33
|
+
margin: 0;
|
|
34
|
+
padding: 0;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.telemetry-list li {
|
|
38
|
+
display: flex;
|
|
39
|
+
align-items: center;
|
|
40
|
+
justify-content: space-between;
|
|
41
|
+
gap: 10px;
|
|
42
|
+
padding: 8px 0;
|
|
43
|
+
border-bottom: 1px solid var(--border);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.telemetry-list li:last-child {
|
|
47
|
+
border-bottom: 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.telemetry-label {
|
|
51
|
+
min-width: 0;
|
|
52
|
+
overflow: hidden;
|
|
53
|
+
text-overflow: ellipsis;
|
|
54
|
+
white-space: nowrap;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.telemetry-count {
|
|
58
|
+
font-weight: 600;
|
|
59
|
+
color: var(--text-primary);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
@media (max-width: 960px) {
|
|
63
|
+
.telemetry-grid {
|
|
64
|
+
grid-template-columns: 1fr;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
3
68
|
/* ─── Cards ─── */
|
|
4
69
|
.card {
|
|
5
70
|
background: var(--bg-card);
|
|
@@ -3262,9 +3327,9 @@ select.input {
|
|
|
3262
3327
|
|
|
3263
3328
|
/* ─── Control Unit (sticky) ─── */
|
|
3264
3329
|
.control-unit-card {
|
|
3265
|
-
position:
|
|
3266
|
-
top:
|
|
3267
|
-
z-index:
|
|
3330
|
+
position: relative;
|
|
3331
|
+
top: auto;
|
|
3332
|
+
z-index: auto;
|
|
3268
3333
|
background: var(--bg-card);
|
|
3269
3334
|
}
|
|
3270
3335
|
|
|
@@ -3496,6 +3561,31 @@ select.input {
|
|
|
3496
3561
|
color: #fff;
|
|
3497
3562
|
}
|
|
3498
3563
|
|
|
3564
|
+
/* ─── Shared Save/Discard Bar ─── */
|
|
3565
|
+
.ve-save-discard-bar {
|
|
3566
|
+
display: flex;
|
|
3567
|
+
align-items: center;
|
|
3568
|
+
justify-content: space-between;
|
|
3569
|
+
gap: 10px;
|
|
3570
|
+
margin-top: 12px;
|
|
3571
|
+
padding: 10px 12px;
|
|
3572
|
+
border: 1px solid var(--border);
|
|
3573
|
+
border-radius: var(--radius-lg);
|
|
3574
|
+
background: var(--bg-card);
|
|
3575
|
+
}
|
|
3576
|
+
|
|
3577
|
+
.ve-save-discard-message {
|
|
3578
|
+
font-size: 12px;
|
|
3579
|
+
color: var(--text-secondary);
|
|
3580
|
+
}
|
|
3581
|
+
|
|
3582
|
+
.ve-save-discard-actions {
|
|
3583
|
+
display: flex;
|
|
3584
|
+
gap: 8px;
|
|
3585
|
+
align-items: center;
|
|
3586
|
+
flex-wrap: wrap;
|
|
3587
|
+
}
|
|
3588
|
+
|
|
3499
3589
|
/* ─── Toggle disabled ─── */
|
|
3500
3590
|
.toggle-wrap.disabled {
|
|
3501
3591
|
opacity: 0.4;
|
|
@@ -3690,6 +3780,12 @@ select.input {
|
|
|
3690
3780
|
display: flex;
|
|
3691
3781
|
flex-direction: column;
|
|
3692
3782
|
gap: 16px;
|
|
3783
|
+
min-height: 0;
|
|
3784
|
+
}
|
|
3785
|
+
|
|
3786
|
+
.control-main .card,
|
|
3787
|
+
.control-side .card {
|
|
3788
|
+
overflow: visible;
|
|
3693
3789
|
}
|
|
3694
3790
|
|
|
3695
3791
|
.control-hero {
|