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.
@@ -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);
@@ -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">: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
  /**
@@ -38,8 +38,8 @@ function injectVoiceStyles() {
38
38
  display: inline-flex;
39
39
  align-items: center;
40
40
  justify-content: center;
41
- width: 32px;
42
- height: 32px;
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
- font-size: 15px;
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: 26px;
78
- height: 26px;
79
- font-size: 13px;
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;
@@ -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: sticky;
3266
- top: 0;
3267
- z-index: 18;
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 {