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.
@@ -103,8 +103,14 @@
103
103
  left: 0;
104
104
  bottom: calc(var(--nav-height) + var(--safe-bottom));
105
105
  width: min(88vw, 360px);
106
- transform: translateX(-100%);
107
- transition: transform 0.28s cubic-bezier(0.32, 0.72, 0, 1);
106
+ transform: translateX(calc(-100% - 24px));
107
+ opacity: 0;
108
+ visibility: hidden;
109
+ pointer-events: none;
110
+ transition:
111
+ transform 0.28s cubic-bezier(0.32, 0.72, 0, 1),
112
+ opacity 0.2s ease,
113
+ visibility 0s linear 0.28s;
108
114
  will-change: transform;
109
115
  z-index: 160;
110
116
  box-shadow: 12px 0 40px rgba(0, 0, 0, 0.35);
@@ -113,6 +119,10 @@
113
119
 
114
120
  .session-split.drawer-open .session-pane {
115
121
  transform: translateX(0);
122
+ opacity: 1;
123
+ visibility: visible;
124
+ pointer-events: auto;
125
+ transition-delay: 0s, 0s, 0s;
116
126
  }
117
127
 
118
128
  .session-split>.session-detail {
@@ -869,14 +879,56 @@
869
879
  flex-direction: column;
870
880
  align-items: center;
871
881
  justify-content: center;
872
- padding: var(--space-lg);
873
- gap: var(--space-sm);
874
- border: 1px dashed var(--border);
875
- border-radius: var(--radius-md);
876
- background: rgba(8, 12, 20, 0.35);
882
+ width: min(720px, 100%);
883
+ margin: 0 auto;
884
+ padding: clamp(18px, 2.8vw, 30px) clamp(18px, 3.2vw, 34px);
885
+ gap: 10px;
886
+ border: 1px dashed rgba(148, 163, 184, 0.32);
887
+ border-radius: 14px;
888
+ background: linear-gradient(180deg, rgba(17, 23, 34, 0.55), rgba(8, 12, 20, 0.35));
889
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
877
890
  text-align: center;
878
891
  }
879
892
 
893
+ .chat-empty-state-inline.chat-empty-state-inline--no-box {
894
+ width: auto;
895
+ max-width: 100%;
896
+ border: none !important;
897
+ border-radius: 0 !important;
898
+ background: transparent !important;
899
+ box-shadow: none !important;
900
+ padding: clamp(18px, 2.8vw, 30px) var(--space-lg);
901
+ margin-top: auto;
902
+ margin-bottom: auto;
903
+ transform: translateY(-18px);
904
+ }
905
+
906
+ .chat-empty-state-inline .session-empty-icon {
907
+ font-size: 24px;
908
+ opacity: 0.72;
909
+ color: var(--text-hint);
910
+ }
911
+
912
+ .chat-empty-state-inline .session-empty-text {
913
+ font-size: 15px;
914
+ font-weight: 600;
915
+ color: var(--text-primary);
916
+ line-height: 1.35;
917
+ }
918
+
919
+ .chat-empty-state-inline .session-empty-subtext {
920
+ margin-top: 6px;
921
+ font-size: 12px;
922
+ color: var(--text-secondary);
923
+ line-height: 1.45;
924
+ }
925
+
926
+ @media (max-width: 768px) {
927
+ .chat-empty-state-inline--no-box {
928
+ transform: translateY(-10px);
929
+ }
930
+ }
931
+
880
932
  .chat-loading {
881
933
  text-align: center;
882
934
  color: var(--text-hint);
@@ -1977,15 +2029,15 @@ ul.md-list li::before {
1977
2029
 
1978
2030
  .chat-input-wrapper {
1979
2031
  display: flex;
1980
- align-items: flex-end;
1981
- gap: 8px;
2032
+ align-items: center;
2033
+ gap: 10px;
1982
2034
  background: var(--bg-card);
1983
2035
  border: 1px solid var(--border);
1984
2036
  border-radius: 24px;
1985
2037
  padding: 8px 8px 8px 16px;
1986
2038
  transition: border-color 0.15s, box-shadow 0.15s;
1987
2039
  max-width: min(760px, 100%);
1988
- margin: 0 auto;
2040
+ margin: 8px auto 0;
1989
2041
  }
1990
2042
 
1991
2043
  .chat-input-wrapper:focus-within {
@@ -1995,6 +2047,7 @@ ul.md-list li::before {
1995
2047
 
1996
2048
  .chat-textarea {
1997
2049
  flex: 1;
2050
+ align-self: center;
1998
2051
  background: transparent;
1999
2052
  border: none;
2000
2053
  outline: none;
@@ -2005,10 +2058,25 @@ ul.md-list li::before {
2005
2058
  resize: none;
2006
2059
  min-height: 24px;
2007
2060
  max-height: 120px;
2008
- padding: 4px 0;
2061
+ padding: 0;
2009
2062
  scrollbar-width: thin;
2010
2063
  }
2011
2064
 
2065
+ .chat-input-wrapper .mic-btn,
2066
+ .chat-input-wrapper .chat-send-btn {
2067
+ align-self: center;
2068
+ }
2069
+
2070
+ .chat-input-wrapper .mic-btn {
2071
+ width: 34px;
2072
+ height: 34px;
2073
+ margin-right: 2px;
2074
+ }
2075
+
2076
+ .chat-input-wrapper .chat-send-btn {
2077
+ margin-left: 2px;
2078
+ }
2079
+
2012
2080
  .chat-textarea::placeholder {
2013
2081
  color: var(--text-muted, var(--text-hint));
2014
2082
  }
@@ -2044,7 +2112,7 @@ ul.md-list li::before {
2044
2112
  .chat-input-hint {
2045
2113
  display: flex;
2046
2114
  justify-content: space-between;
2047
- padding: 4px 4px 0;
2115
+ padding: 6px 8px 0;
2048
2116
  font-size: 11px;
2049
2117
  color: var(--text-hint);
2050
2118
  max-width: min(760px, 100%);
@@ -2506,6 +2574,10 @@ ul.md-list li::before {
2506
2574
  }
2507
2575
 
2508
2576
  /* ─── Blend chat input seamlessly with bottom navbar ─── */
2577
+ .app-shell[data-tab="chat"] .main-content {
2578
+ background: var(--glass-bg);
2579
+ }
2580
+
2509
2581
  .app-shell[data-tab="chat"] .chat-input-area {
2510
2582
  border-top: none;
2511
2583
  background: var(--glass-bg);
package/ui/tabs/chat.js CHANGED
@@ -523,7 +523,11 @@ export function ChatTab() {
523
523
  // Forward to agent SDK
524
524
  const resp = await apiFetch("/api/agents/sdk-command", {
525
525
  method: "POST",
526
- body: JSON.stringify({ command: cmdBase, args: cmdArgs }),
526
+ body: JSON.stringify({
527
+ command: cmdBase,
528
+ args: cmdArgs,
529
+ sessionId: sessionId || undefined,
530
+ }),
527
531
  });
528
532
  const resultText = resp?.result || resp?.data || `:check: SDK command executed: ${cmdBase}`;
529
533
  if (sessionId) {
@@ -865,29 +865,11 @@ export function ControlTab() {
865
865
 
866
866
  <${Card} className="routing-card">
867
867
  <${Collapsible} title="Routing" defaultOpen=${!isCompact}>
868
- <div class="meta-text mb-sm">Quick runtime overrides for executor routing and region. Persistent defaults live in Settings.</div>
868
+ <div class="meta-text mb-sm">Runtime region override only. SDK and kanban backend changes now live in Settings to avoid accidental disruptive switches.</div>
869
869
  <div class="card-subtitle">SDK</div>
870
- <${SegmentedControl}
871
- options=${[
872
- { value: "codex", label: "Codex" },
873
- { value: "copilot", label: "Copilot" },
874
- { value: "claude", label: "Claude" },
875
- { value: "auto", label: "Auto" },
876
- ]}
877
- value=${config?.sdk || "auto"}
878
- onChange=${(v) => updateConfig("sdk", v)}
879
- />
880
- <div class="card-subtitle mt-sm">Kanban</div>
881
- <${SegmentedControl}
882
- options=${[
883
- { value: "internal", label: "Internal" },
884
- { value: "vk", label: "VK" },
885
- { value: "github", label: "GitHub" },
886
- { value: "jira", label: "Jira" },
887
- ]}
888
- value=${config?.kanbanBackend || "internal"}
889
- onChange=${(v) => updateConfig("kanban", v)}
890
- />
870
+ <div class="meta-text mb-sm">Current: ${String(config?.sdk || "auto").toUpperCase()}</div>
871
+ <div class="card-subtitle">Kanban</div>
872
+ <div class="meta-text mb-sm">Current: ${config?.kanbanBackend || "internal"}</div>
891
873
  ${regions.length > 1 && html`
892
874
  <div class="card-subtitle mt-sm">Region</div>
893
875
  <${SegmentedControl}
@@ -896,6 +878,18 @@ export function ControlTab() {
896
878
  onChange=${(v) => updateConfig("region", v)}
897
879
  />
898
880
  `}
881
+ ${regions.length <= 1 && html`
882
+ <div class="card-subtitle mt-sm">Region</div>
883
+ <div class="meta-text mb-sm">Current: ${regions[0] || "auto"}</div>
884
+ `}
885
+ <button
886
+ class="btn btn-ghost btn-sm mt-sm"
887
+ onClick=${() => {
888
+ import("../modules/router.js").then(({ navigateTo }) => navigateTo("settings"));
889
+ }}
890
+ >
891
+ Open Settings
892
+ </button>
899
893
  <//>
900
894
  <//>
901
895
 
@@ -7,6 +7,7 @@ import {
7
7
  useEffect,
8
8
  useCallback,
9
9
  useRef,
10
+ useMemo,
10
11
  } from "preact/hooks";
11
12
  import htm from "htm";
12
13
 
@@ -27,10 +28,17 @@ import {
27
28
  scheduleRefresh,
28
29
  getTrend,
29
30
  getDashboardHistory,
31
+ setPendingChange,
32
+ clearPendingChange,
30
33
  } from "../modules/state.js";
31
34
  import { navigateTo } from "../modules/router.js";
32
35
  import { ICONS } from "../modules/icons.js";
33
- import { cloneValue, formatRelative, truncate } from "../modules/utils.js";
36
+ import {
37
+ cloneValue,
38
+ formatRelative,
39
+ truncate,
40
+ countChangedFields,
41
+ } from "../modules/utils.js";
34
42
  import { iconText, resolveIcon } from "../modules/icon-utils.js";
35
43
  import {
36
44
  Card,
@@ -38,6 +46,7 @@ import {
38
46
  SkeletonCard,
39
47
  Modal,
40
48
  EmptyState,
49
+ SaveDiscardBar,
41
50
  } from "../components/shared.js";
42
51
  import { DonutChart, ProgressBar, MiniSparkline } from "../components/charts.js";
43
52
  import {
@@ -139,11 +148,44 @@ export function CreateTaskModal({ onClose }) {
139
148
  const [description, setDescription] = useState("");
140
149
  const [priority, setPriority] = useState("medium");
141
150
  const [submitting, setSubmitting] = useState(false);
151
+ const initialSnapshotRef = useRef({
152
+ title: "",
153
+ description: "",
154
+ priority: "medium",
155
+ });
156
+ const pendingKey = "modal:create-task-dashboard";
157
+
158
+ const currentSnapshot = useMemo(
159
+ () => ({
160
+ title: title || "",
161
+ description: description || "",
162
+ priority: priority || "medium",
163
+ }),
164
+ [description, priority, title],
165
+ );
166
+ const changeCount = useMemo(
167
+ () => countChangedFields(initialSnapshotRef.current, currentSnapshot),
168
+ [currentSnapshot],
169
+ );
170
+ const hasUnsaved = changeCount > 0;
171
+
172
+ useEffect(() => {
173
+ setPendingChange(pendingKey, hasUnsaved);
174
+ return () => clearPendingChange(pendingKey);
175
+ }, [hasUnsaved]);
142
176
 
143
- const handleSubmit = useCallback(async () => {
177
+ const resetToInitial = useCallback(() => {
178
+ const base = initialSnapshotRef.current || {};
179
+ setTitle(base.title || "");
180
+ setDescription(base.description || "");
181
+ setPriority(base.priority || "medium");
182
+ showToast("Changes discarded", "info");
183
+ }, []);
184
+
185
+ const handleSubmit = useCallback(async ({ closeAfterSave = true } = {}) => {
144
186
  if (!title.trim()) {
145
187
  showToast("Title is required", "error");
146
- return;
188
+ return false;
147
189
  }
148
190
  setSubmitting(true);
149
191
  haptic("medium");
@@ -157,12 +199,22 @@ export function CreateTaskModal({ onClose }) {
157
199
  }),
158
200
  });
159
201
  showToast("Task created", "success");
160
- onClose();
202
+ initialSnapshotRef.current = {
203
+ title: title.trim(),
204
+ description: description.trim(),
205
+ priority,
206
+ };
207
+ if (closeAfterSave) {
208
+ onClose?.();
209
+ }
161
210
  await refreshTab("dashboard");
211
+ return closeAfterSave ? { closed: true } : true;
162
212
  } catch {
163
213
  /* toast shown by apiFetch */
214
+ return false;
215
+ } finally {
216
+ setSubmitting(false);
164
217
  }
165
- setSubmitting(false);
166
218
  }, [title, description, priority, onClose]);
167
219
 
168
220
  /* Telegram MainButton integration */
@@ -171,7 +223,9 @@ export function CreateTaskModal({ onClose }) {
171
223
  if (tg?.MainButton) {
172
224
  tg.MainButton.setText("Create Task");
173
225
  tg.MainButton.show();
174
- const handler = () => handleSubmit();
226
+ const handler = () => {
227
+ void handleSubmit({ closeAfterSave: true });
228
+ };
175
229
  tg.MainButton.onClick(handler);
176
230
  return () => {
177
231
  tg.MainButton.hide();
@@ -181,7 +235,17 @@ export function CreateTaskModal({ onClose }) {
181
235
  }, [handleSubmit]);
182
236
 
183
237
  return html`
184
- <${Modal} title="Create Task" onClose=${onClose}>
238
+ <${Modal}
239
+ title="Create Task"
240
+ onClose=${onClose}
241
+ unsavedChanges=${changeCount}
242
+ onSaveBeforeClose=${() => handleSubmit({ closeAfterSave: true })}
243
+ onDiscardBeforeClose=${() => {
244
+ resetToInitial();
245
+ return true;
246
+ }}
247
+ activeOperationLabel=${submitting ? "Task creation is in progress" : ""}
248
+ >
185
249
  <div class="flex-col gap-md">
186
250
  <input
187
251
  class="input"
@@ -212,11 +276,24 @@ export function CreateTaskModal({ onClose }) {
212
276
  />
213
277
  <button
214
278
  class="btn btn-primary"
215
- onClick=${handleSubmit}
279
+ onClick=${() => {
280
+ void handleSubmit({ closeAfterSave: true });
281
+ }}
216
282
  disabled=${submitting}
217
283
  >
218
284
  ${submitting ? "Creating…" : "Create Task"}
219
285
  </button>
286
+ <${SaveDiscardBar}
287
+ dirty=${hasUnsaved}
288
+ message=${`You have unsaved changes (${changeCount})`}
289
+ saveLabel="Create Task"
290
+ discardLabel="Discard"
291
+ onSave=${() => {
292
+ void handleSubmit({ closeAfterSave: false });
293
+ }}
294
+ onDiscard=${resetToInitial}
295
+ saving=${submitting}
296
+ />
220
297
  </div>
221
298
  <//>
222
299
  `;
@@ -11,11 +11,25 @@ const html = htm.bind(h);
11
11
 
12
12
  import { haptic } from "../modules/telegram.js";
13
13
  import { apiFetch } from "../modules/api.js";
14
- import { showToast, refreshTab } from "../modules/state.js";
14
+ import {
15
+ showToast,
16
+ refreshTab,
17
+ setPendingChange,
18
+ clearPendingChange,
19
+ } from "../modules/state.js";
15
20
  import { ICONS } from "../modules/icons.js";
16
21
  import { iconText, resolveIcon } from "../modules/icon-utils.js";
17
- import { formatRelative } from "../modules/utils.js";
18
- import { Card, Badge, EmptyState, Modal, ConfirmDialog, Spinner, ListItem } from "../components/shared.js";
22
+ import { formatRelative, countChangedFields } from "../modules/utils.js";
23
+ import {
24
+ Card,
25
+ Badge,
26
+ EmptyState,
27
+ Modal,
28
+ ConfirmDialog,
29
+ Spinner,
30
+ ListItem,
31
+ SaveDiscardBar,
32
+ } from "../components/shared.js";
19
33
  import { SearchInput, SegmentedControl, Toggle } from "../components/forms.js";
20
34
 
21
35
  /* ═══════════════════════════════════════════════════════════════
@@ -41,14 +55,17 @@ const LIBRARY_STYLES = `
41
55
 
42
56
  .library-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr)); gap: 12px; }
43
57
  .library-card { background: var(--bg-card, #1a1a2e); border: 1px solid var(--border, #333);
44
- border-radius: 12px; padding: 16px; cursor: pointer; transition: all 0.15s; position: relative; }
58
+ border-radius: 12px; padding: 16px; padding-right: 96px; cursor: pointer; transition: all 0.15s; position: relative; }
45
59
  .library-card:hover { border-color: var(--accent, #58a6ff); transform: translateY(-1px);
46
60
  box-shadow: 0 4px 12px rgba(0,0,0,0.3); }
47
61
 
48
62
  .library-card-header { display: flex; align-items: flex-start; gap: 10px; margin-bottom: 8px; }
49
63
  .library-card-icon { font-size: 1.4em; flex-shrink: 0; width: 32px; text-align: center; }
50
64
  .library-card-icon svg { width: 20px; height: 20px; vertical-align: middle; }
51
- .library-card-title { font-weight: 600; font-size: 0.95em; color: var(--text-primary, #eee); }
65
+ .library-card-header > div { min-width: 0; flex: 1; }
66
+ .library-card-title { font-weight: 600; font-size: 0.95em; color: var(--text-primary, #eee);
67
+ overflow-wrap: anywhere; word-break: break-word;
68
+ display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
52
69
  .library-card-desc { font-size: 0.82em; color: var(--text-secondary, #aaa); margin-bottom: 8px;
53
70
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
54
71
  .library-card-meta { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
@@ -123,7 +140,7 @@ const LIBRARY_STYLES = `
123
140
  .library-toolbar { gap: 6px; }
124
141
  .library-toolbar .search-wrap { flex: 1 1 100%; min-width: 0; }
125
142
  .library-grid { grid-template-columns: 1fr; }
126
- .library-card { padding: 12px; }
143
+ .library-card { padding: 12px; padding-right: 84px; }
127
144
  .library-card-scope { margin-left: 0; }
128
145
  .library-actions { flex-wrap: wrap; justify-content: stretch; }
129
146
  .library-actions button { flex: 1 1 140px; padding: 10px 12px; }
@@ -307,7 +324,7 @@ function LibraryCard({ entry, onSelect }) {
307
324
 
308
325
  function EntryEditor({ entry, onClose, onSaved, onDeleted }) {
309
326
  const isNew = !entry?.id;
310
- const [form, setForm] = useState({
327
+ const initialFormSnapshot = {
311
328
  id: entry?.id || "",
312
329
  type: entry?.type || "prompt",
313
330
  name: entry?.name || "",
@@ -315,10 +332,31 @@ function EntryEditor({ entry, onClose, onSaved, onDeleted }) {
315
332
  tags: (entry?.tags || []).join(", "),
316
333
  scope: entry?.scope || "global",
317
334
  content: "",
318
- });
335
+ };
336
+ const [form, setForm] = useState(initialFormSnapshot);
337
+ const [baseline, setBaseline] = useState(initialFormSnapshot);
319
338
  const [loading, setLoading] = useState(false);
320
339
  const [loadingContent, setLoadingContent] = useState(!isNew && !!entry?.id);
321
340
  const [confirmDelete, setConfirmDelete] = useState(false);
341
+ const pendingKey = useMemo(
342
+ () => `modal:library-entry:${entry?.id || "new"}`,
343
+ [entry?.id],
344
+ );
345
+
346
+ useEffect(() => {
347
+ const next = {
348
+ id: entry?.id || "",
349
+ type: entry?.type || "prompt",
350
+ name: entry?.name || "",
351
+ description: entry?.description || "",
352
+ tags: (entry?.tags || []).join(", "),
353
+ scope: entry?.scope || "global",
354
+ content: "",
355
+ };
356
+ setForm(next);
357
+ setBaseline(next);
358
+ setLoadingContent(!isNew && !!entry?.id);
359
+ }, [entry?.id]);
322
360
 
323
361
  // Load content for existing entries
324
362
  useEffect(() => {
@@ -330,7 +368,11 @@ function EntryEditor({ entry, onClose, onSaved, onDeleted }) {
330
368
  if (cancelled) return;
331
369
  let contentStr = detail?.content ?? "";
332
370
  if (typeof contentStr === "object") contentStr = JSON.stringify(contentStr, null, 2);
333
- setForm((f) => ({ ...f, content: contentStr }));
371
+ setForm((f) => {
372
+ const next = { ...f, content: contentStr };
373
+ setBaseline(next);
374
+ return next;
375
+ });
334
376
  } catch { /* ignore */ }
335
377
  setLoadingContent(false);
336
378
  })();
@@ -338,9 +380,27 @@ function EntryEditor({ entry, onClose, onSaved, onDeleted }) {
338
380
  }, [entry?.id]);
339
381
 
340
382
  const updateField = (key) => (e) => setForm((f) => ({ ...f, [key]: e.target.value }));
383
+ const changeCount = useMemo(
384
+ () => countChangedFields(baseline, form),
385
+ [baseline, form],
386
+ );
387
+ const hasUnsaved = changeCount > 0;
341
388
 
342
- const handleSave = useCallback(async () => {
343
- if (!form.name.trim()) { showToast("Name is required", "error"); return; }
389
+ useEffect(() => {
390
+ setPendingChange(pendingKey, hasUnsaved);
391
+ return () => clearPendingChange(pendingKey);
392
+ }, [hasUnsaved, pendingKey]);
393
+
394
+ const resetToBaseline = useCallback(() => {
395
+ setForm(baseline);
396
+ showToast("Changes discarded", "info");
397
+ }, [baseline]);
398
+
399
+ const handleSave = useCallback(async ({ closeAfterSave = true } = {}) => {
400
+ if (!form.name.trim()) {
401
+ showToast("Name is required", "error");
402
+ return false;
403
+ }
344
404
  setLoading(true);
345
405
  try {
346
406
  const tags = form.tags.split(/[,\s]+/).map((t) => t.trim().toLowerCase()).filter(Boolean);
@@ -359,14 +419,23 @@ function EntryEditor({ entry, onClose, onSaved, onDeleted }) {
359
419
  });
360
420
  if (res?.ok) {
361
421
  showToast(`${TYPE_LABELS[form.type] || "Entry"} saved`, "success");
362
- onSaved?.();
422
+ const nextBaseline = { ...form };
423
+ setBaseline(nextBaseline);
424
+ if (closeAfterSave) {
425
+ onSaved?.();
426
+ return { closed: true };
427
+ }
428
+ return true;
363
429
  } else {
364
430
  showToast(res?.error || "Save failed", "error");
431
+ return false;
365
432
  }
366
433
  } catch (err) {
367
434
  showToast(err.message, "error");
435
+ return false;
436
+ } finally {
437
+ setLoading(false);
368
438
  }
369
- setLoading(false);
370
439
  }, [form, onSaved]);
371
440
 
372
441
  const handleDelete = useCallback(async () => {
@@ -402,7 +471,17 @@ function EntryEditor({ entry, onClose, onSaved, onDeleted }) {
402
471
  : "# Skill Title\n\n## Purpose\nDescribe what this skill teaches agents.\n\n## Instructions\n...";
403
472
 
404
473
  return html`
405
- <${Modal} title=${isNew ? "New Resource" : `Edit: ${entry.name}`} onClose=${onClose}>
474
+ <${Modal}
475
+ title=${isNew ? "New Resource" : `Edit: ${entry.name}`}
476
+ onClose=${onClose}
477
+ unsavedChanges=${changeCount}
478
+ onSaveBeforeClose=${() => handleSave({ closeAfterSave: true })}
479
+ onDiscardBeforeClose=${() => {
480
+ resetToBaseline();
481
+ return true;
482
+ }}
483
+ activeOperationLabel=${loading ? "Save/Delete request is still running" : ""}
484
+ >
406
485
  <div class="library-editor">
407
486
  ${isNew && html`
408
487
  <label>
@@ -459,10 +538,27 @@ function EntryEditor({ entry, onClose, onSaved, onDeleted }) {
459
538
  `}
460
539
  <div style="flex:1" />
461
540
  <button class="btn-ghost" onClick=${onClose}>Cancel</button>
462
- <button class="btn-primary" onClick=${handleSave} disabled=${loading}>
541
+ <button
542
+ class="btn-primary"
543
+ onClick=${() => {
544
+ void handleSave({ closeAfterSave: true });
545
+ }}
546
+ disabled=${loading}
547
+ >
463
548
  ${loading ? html`<${Spinner} size=${14} />` : (isNew ? "Create" : "Save")}
464
549
  </button>
465
550
  </div>
551
+ <${SaveDiscardBar}
552
+ dirty=${hasUnsaved}
553
+ message=${`You have unsaved changes (${changeCount})`}
554
+ saveLabel=${isNew ? "Create" : "Save Changes"}
555
+ discardLabel="Discard"
556
+ onSave=${() => {
557
+ void handleSave({ closeAfterSave: false });
558
+ }}
559
+ onDiscard=${resetToBaseline}
560
+ saving=${loading}
561
+ />
466
562
  </div>
467
563
  ${confirmDelete && html`
468
564
  <${ConfirmDialog}
@@ -664,7 +760,7 @@ export function LibraryTab() {
664
760
  ${iconText(":refresh: Rebuild")}
665
761
  </button>
666
762
  <button class="library-type-pill active" onClick=${() => setEditing({})}>
667
- New
763
+ ${iconText("➕ New")}
668
764
  </button>
669
765
  </div>
670
766
 
@@ -704,7 +800,7 @@ export function LibraryTab() {
704
800
  : "Create your first prompt, agent profile, or skill."}
705
801
  action=${searchQuery.value
706
802
  ? { label: "Clear search", onClick: () => { searchQuery.value = ""; loadEntries(); } }
707
- : { label: " New Resource", onClick: () => setEditing({}) }} />
803
+ : { label: " New Resource", onClick: () => setEditing({}) }} />
708
804
  `}
709
805
 
710
806
  ${!loading && displayed.length > 0 && html`