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/ui/tabs/tasks.js CHANGED
@@ -34,6 +34,8 @@ import {
34
34
  scheduleRefresh,
35
35
  loadTasks,
36
36
  updateTaskManualState,
37
+ setPendingChange,
38
+ clearPendingChange,
37
39
  } from "../modules/state.js";
38
40
  import { ICONS } from "../modules/icons.js";
39
41
  import {
@@ -44,6 +46,7 @@ import {
44
46
  debounce,
45
47
  exportAsCSV,
46
48
  exportAsJSON,
49
+ countChangedFields,
47
50
  } from "../modules/utils.js";
48
51
  import {
49
52
  Card,
@@ -53,6 +56,7 @@ import {
53
56
  Modal,
54
57
  EmptyState,
55
58
  ListItem,
59
+ SaveDiscardBar,
56
60
  } from "../components/shared.js";
57
61
  import { SegmentedControl, SearchInput, Toggle } from "../components/forms.js";
58
62
  import { KanbanBoard } from "../components/kanban-board.js";
@@ -255,6 +259,11 @@ function isImageAttachment(att) {
255
259
  return /\.(png|jpe?g|gif|webp|bmp|svg)$/.test(name);
256
260
  }
257
261
 
262
+ function unsavedChangesMessage(changeCount) {
263
+ const count = Math.max(0, Number(changeCount || 0));
264
+ return `You have unsaved changes (${count})`;
265
+ }
266
+
258
267
  export function StartTaskModal({
259
268
  task,
260
269
  defaultSdk = "auto",
@@ -266,14 +275,27 @@ export function StartTaskModal({
266
275
  const [model, setModel] = useState("");
267
276
  const [taskIdInput, setTaskIdInput] = useState(task?.id || "");
268
277
  const [starting, setStarting] = useState(false);
278
+ const initialSnapshotRef = useRef({
279
+ sdk: defaultSdk || "auto",
280
+ model: "",
281
+ taskIdInput: task?.id || "",
282
+ });
283
+ const pendingKey = useMemo(
284
+ () => `modal:start-task:${task?.id || "manual"}`,
285
+ [task?.id],
286
+ );
269
287
 
270
288
  useEffect(() => {
271
- setSdk(defaultSdk || "auto");
272
- }, [defaultSdk]);
273
-
274
- useEffect(() => {
275
- setTaskIdInput(task?.id || "");
276
- }, [task?.id, task?.meta?.codex?.isIgnored, task?.meta?.labels]);
289
+ const next = {
290
+ sdk: defaultSdk || "auto",
291
+ model: "",
292
+ taskIdInput: task?.id || "",
293
+ };
294
+ initialSnapshotRef.current = next;
295
+ setSdk(next.sdk);
296
+ setModel(next.model);
297
+ setTaskIdInput(next.taskIdInput);
298
+ }, [defaultSdk, task?.id, task?.meta?.codex?.isIgnored, task?.meta?.labels]);
277
299
 
278
300
  const canModel = sdk && sdk !== "auto";
279
301
 
@@ -298,12 +320,38 @@ export function StartTaskModal({
298
320
  };
299
321
 
300
322
  const resolvedTaskId = (task?.id || taskIdInput || "").trim();
323
+ const currentSnapshot = useMemo(
324
+ () => ({
325
+ sdk: sdk || "auto",
326
+ model: model || "",
327
+ taskIdInput: taskIdInput || "",
328
+ }),
329
+ [model, sdk, taskIdInput],
330
+ );
331
+ const changeCount = useMemo(
332
+ () => countChangedFields(initialSnapshotRef.current, currentSnapshot),
333
+ [currentSnapshot],
334
+ );
335
+ const hasUnsaved = changeCount > 0;
336
+
337
+ useEffect(() => {
338
+ setPendingChange(pendingKey, hasUnsaved);
339
+ return () => clearPendingChange(pendingKey);
340
+ }, [hasUnsaved, pendingKey]);
341
+
342
+ const resetToInitial = useCallback(() => {
343
+ const base = initialSnapshotRef.current || {};
344
+ setSdk(base.sdk || "auto");
345
+ setModel(base.model || "");
346
+ setTaskIdInput(base.taskIdInput || "");
347
+ showToast("Changes discarded", "info");
348
+ }, []);
301
349
 
302
- const handleStart = async () => {
350
+ const handleStart = async ({ closeAfterStart = true } = {}) => {
303
351
  if (starting) return;
304
352
  if (!resolvedTaskId) {
305
353
  showToast("Task ID is required", "error");
306
- return;
354
+ return false;
307
355
  }
308
356
  setStarting(true);
309
357
  try {
@@ -312,15 +360,37 @@ export function StartTaskModal({
312
360
  sdk: sdk && sdk !== "auto" ? sdk : undefined,
313
361
  model: model.trim() ? model.trim() : undefined,
314
362
  });
315
- onClose();
363
+ initialSnapshotRef.current = {
364
+ sdk: sdk || "auto",
365
+ model: model || "",
366
+ taskIdInput: taskIdInput || "",
367
+ };
368
+ if (closeAfterStart) {
369
+ onClose?.();
370
+ return { closed: true };
371
+ }
372
+ return true;
316
373
  } catch {
317
374
  /* toast via apiFetch */
375
+ return false;
376
+ } finally {
377
+ setStarting(false);
318
378
  }
319
- setStarting(false);
320
379
  };
321
380
 
322
381
  return html`
323
- <${Modal} title="Start Task" onClose=${onClose} contentClassName="modal-content-wide">
382
+ <${Modal}
383
+ title="Start Task"
384
+ onClose=${onClose}
385
+ contentClassName="modal-content-wide"
386
+ unsavedChanges=${changeCount}
387
+ onSaveBeforeClose=${() => handleStart({ closeAfterStart: true })}
388
+ onDiscardBeforeClose=${() => {
389
+ resetToInitial();
390
+ return true;
391
+ }}
392
+ activeOperationLabel=${starting ? "Task dispatch is still running" : ""}
393
+ >
324
394
  ${task?.id || task?.title
325
395
  ? html`
326
396
  <div class="meta-text mb-sm">
@@ -363,13 +433,26 @@ export function StartTaskModal({
363
433
  <div class="modal-form-field modal-form-span">
364
434
  <button
365
435
  class="btn btn-primary"
366
- onClick=${handleStart}
436
+ onClick=${() => {
437
+ void handleStart({ closeAfterStart: true });
438
+ }}
367
439
  disabled=${starting || !resolvedTaskId}
368
440
  >
369
441
  ${starting ? "Starting…" : ":play: Start Task"}
370
442
  </button>
371
443
  </div>
372
444
  </div>
445
+ <${SaveDiscardBar}
446
+ dirty=${hasUnsaved}
447
+ message=${unsavedChangesMessage(changeCount)}
448
+ saveLabel="Start Task"
449
+ discardLabel="Discard"
450
+ onSave=${() => {
451
+ void handleStart({ closeAfterStart: false });
452
+ }}
453
+ onDiscard=${resetToInitial}
454
+ saving=${starting}
455
+ />
373
456
  <//>
374
457
  `;
375
458
  }
@@ -538,6 +621,21 @@ function TriggerTemplatesModal({ onClose }) {
538
621
  const [defaults, setDefaults] = useState({ executor: "auto", model: "auto" });
539
622
  const [templates, setTemplates] = useState([]);
540
623
  const [planner, setPlanner] = useState({});
624
+ const defaultsBaselineRef = useRef({ executor: "auto", model: "auto" });
625
+ const pendingKey = "modal:trigger-templates";
626
+
627
+ const defaultsSnapshot = useMemo(
628
+ () => ({
629
+ executor: String(defaults?.executor || "auto"),
630
+ model: String(defaults?.model || "auto"),
631
+ }),
632
+ [defaults],
633
+ );
634
+ const defaultsDirtyCount = useMemo(
635
+ () => countChangedFields(defaultsBaselineRef.current, defaultsSnapshot),
636
+ [defaultsSnapshot],
637
+ );
638
+ const hasUnsavedDefaults = defaultsDirtyCount > 0;
541
639
 
542
640
  const loadTemplates = useCallback(async () => {
543
641
  setLoading(true);
@@ -546,11 +644,15 @@ function TriggerTemplatesModal({ onClose }) {
546
644
  const res = await apiFetch("/api/triggers/templates", { _silent: true });
547
645
  const data = res?.data || {};
548
646
  setEnabled(data.enabled === true);
549
- setDefaults(
647
+ const normalizedDefaults =
550
648
  data.defaults && typeof data.defaults === "object"
551
649
  ? data.defaults
552
- : { executor: "auto", model: "auto" },
553
- );
650
+ : { executor: "auto", model: "auto" };
651
+ defaultsBaselineRef.current = {
652
+ executor: String(normalizedDefaults.executor || "auto"),
653
+ model: String(normalizedDefaults.model || "auto"),
654
+ };
655
+ setDefaults(normalizedDefaults);
554
656
  setTemplates(Array.isArray(data.templates) ? data.templates : []);
555
657
  setPlanner(data.planner || {});
556
658
  } catch (err) {
@@ -563,6 +665,11 @@ function TriggerTemplatesModal({ onClose }) {
563
665
  loadTemplates();
564
666
  }, []);
565
667
 
668
+ useEffect(() => {
669
+ setPendingChange(pendingKey, hasUnsavedDefaults);
670
+ return () => clearPendingChange(pendingKey);
671
+ }, [hasUnsavedDefaults]);
672
+
566
673
  const persistUpdate = async (payload) => {
567
674
  setSaving(true);
568
675
  setError("");
@@ -573,11 +680,15 @@ function TriggerTemplatesModal({ onClose }) {
573
680
  });
574
681
  const data = res?.data || {};
575
682
  setEnabled(data.enabled === true);
576
- setDefaults(
683
+ const normalizedDefaults =
577
684
  data.defaults && typeof data.defaults === "object"
578
685
  ? data.defaults
579
- : { executor: "auto", model: "auto" },
580
- );
686
+ : { executor: "auto", model: "auto" };
687
+ defaultsBaselineRef.current = {
688
+ executor: String(normalizedDefaults.executor || "auto"),
689
+ model: String(normalizedDefaults.model || "auto"),
690
+ };
691
+ setDefaults(normalizedDefaults);
581
692
  setTemplates(Array.isArray(data.templates) ? data.templates : []);
582
693
  setPlanner(data.planner || {});
583
694
  showToast("Template settings updated", "success");
@@ -599,10 +710,28 @@ function TriggerTemplatesModal({ onClose }) {
599
710
  }
600
711
  };
601
712
 
602
- const handleSaveDefaults = async () => {
603
- await persistUpdate({ defaults });
713
+ const handleSaveDefaults = async ({ closeAfterSave = false } = {}) => {
714
+ try {
715
+ await persistUpdate({ defaults });
716
+ if (closeAfterSave) {
717
+ onClose?.();
718
+ return { closed: true };
719
+ }
720
+ return true;
721
+ } catch {
722
+ return false;
723
+ }
604
724
  };
605
725
 
726
+ const handleDiscardDefaults = useCallback(() => {
727
+ const base = defaultsBaselineRef.current || { executor: "auto", model: "auto" };
728
+ setDefaults({
729
+ executor: base.executor || "auto",
730
+ model: base.model || "auto",
731
+ });
732
+ showToast("Changes discarded", "info");
733
+ }, []);
734
+
606
735
  const handleToggleTemplate = async (template, nextEnabled) => {
607
736
  await persistUpdate({ template: { ...template, enabled: nextEnabled } });
608
737
  };
@@ -616,6 +745,13 @@ function TriggerTemplatesModal({ onClose }) {
616
745
  title="Trigger Templates"
617
746
  onClose=${onClose}
618
747
  contentClassName="modal-content-wide"
748
+ unsavedChanges=${defaultsDirtyCount}
749
+ onSaveBeforeClose=${() => handleSaveDefaults({ closeAfterSave: true })}
750
+ onDiscardBeforeClose=${() => {
751
+ handleDiscardDefaults();
752
+ return true;
753
+ }}
754
+ activeOperationLabel=${saving ? "Template update request is still running" : ""}
619
755
  >
620
756
  <div class="flex-col" style="gap:10px;">
621
757
  <div class="card" style="padding:10px 12px;">
@@ -689,6 +825,17 @@ function TriggerTemplatesModal({ onClose }) {
689
825
  onSaveTemplate=${handleSaveTemplate}
690
826
  />
691
827
  `)}
828
+ <${SaveDiscardBar}
829
+ dirty=${hasUnsavedDefaults}
830
+ message=${unsavedChangesMessage(defaultsDirtyCount)}
831
+ saveLabel="Save Defaults"
832
+ discardLabel="Discard"
833
+ onSave=${() => {
834
+ void handleSaveDefaults({ closeAfterSave: false });
835
+ }}
836
+ onDiscard=${handleDiscardDefaults}
837
+ saving=${saving}
838
+ />
692
839
  </div>
693
840
  <//>
694
841
  `;
@@ -1252,9 +1399,49 @@ export function TaskDetailModal({ task, onClose, onStart }) {
1252
1399
  );
1253
1400
  const [repository, setRepository] = useState(task?.repository || "");
1254
1401
  const attachmentInputRef = useRef(null);
1402
+ const initialSnapshotRef = useRef({
1403
+ title: task?.title || "",
1404
+ description: task?.description || "",
1405
+ baseBranch: getTaskBaseBranch(task),
1406
+ status: task?.status || "todo",
1407
+ priority: task?.priority || "",
1408
+ tagsInput: getTaskTags(task).join(", "),
1409
+ draft: Boolean(task?.draft || task?.status === "draft"),
1410
+ });
1411
+ const pendingKey = useMemo(
1412
+ () => `modal:task-detail:${task?.id || "unknown"}`,
1413
+ [task?.id],
1414
+ );
1255
1415
  const activeWsId = activeWorkspaceId.value || "";
1256
1416
  const canDispatch = Boolean(onStart && task?.id);
1257
1417
 
1418
+ const editableSnapshot = useMemo(
1419
+ () => ({
1420
+ title: title || "",
1421
+ description: description || "",
1422
+ baseBranch: baseBranch || "",
1423
+ status: status || "todo",
1424
+ priority: priority || "",
1425
+ tagsInput: tagsInput || "",
1426
+ draft: Boolean(draft),
1427
+ }),
1428
+ [baseBranch, description, draft, priority, status, tagsInput, title],
1429
+ );
1430
+ const changeCount = useMemo(
1431
+ () => countChangedFields(initialSnapshotRef.current, editableSnapshot),
1432
+ [editableSnapshot],
1433
+ );
1434
+ const hasUnsaved = changeCount > 0;
1435
+ const activeOperationLabel = saving
1436
+ ? "Task save is in progress"
1437
+ : rewriting
1438
+ ? "Improve with AI is still running"
1439
+ : uploadingAttachment
1440
+ ? "Attachment upload is still running"
1441
+ : manualBusy
1442
+ ? "Manual takeover update is in progress"
1443
+ : "";
1444
+
1258
1445
  const workspaceOptions = managedWorkspaces.value || [];
1259
1446
  const selectedWorkspace = useMemo(
1260
1447
  () => workspaceOptions.find((ws) => ws.id === workspaceId) || null,
@@ -1263,19 +1450,35 @@ export function TaskDetailModal({ task, onClose, onStart }) {
1263
1450
  const repositoryOptions = selectedWorkspace?.repos || [];
1264
1451
 
1265
1452
  useEffect(() => {
1266
- setTitle(task?.title || "");
1267
- setDescription(task?.description || "");
1268
- setBaseBranch(getTaskBaseBranch(task));
1269
- setStatus(task?.status || "todo");
1270
- setPriority(task?.priority || "");
1271
- setTagsInput(getTaskTags(task).join(", "));
1453
+ const nextTitle = task?.title || "";
1454
+ const nextDescription = task?.description || "";
1455
+ const nextBaseBranch = getTaskBaseBranch(task);
1456
+ const nextStatus = task?.status || "todo";
1457
+ const nextPriority = task?.priority || "";
1458
+ const nextTags = getTaskTags(task).join(", ");
1459
+ const nextDraft = Boolean(task?.draft || task?.status === "draft");
1460
+ setTitle(nextTitle);
1461
+ setDescription(nextDescription);
1462
+ setBaseBranch(nextBaseBranch);
1463
+ setStatus(nextStatus);
1464
+ setPriority(nextPriority);
1465
+ setTagsInput(nextTags);
1272
1466
  setAttachments(normalizeTaskAttachments(task));
1273
1467
  setComments(normalizeTaskComments(task));
1274
- setDraft(Boolean(task?.draft || task?.status === "draft"));
1468
+ setDraft(nextDraft);
1275
1469
  setManualOverride(isTaskManual(task));
1276
1470
  setManualReason(getManualReason(task));
1277
1471
  setWorkspaceId(task?.workspace || activeWorkspaceId.value || "");
1278
1472
  setRepository(task?.repository || "");
1473
+ initialSnapshotRef.current = {
1474
+ title: nextTitle,
1475
+ description: nextDescription,
1476
+ baseBranch: nextBaseBranch,
1477
+ status: nextStatus,
1478
+ priority: nextPriority,
1479
+ tagsInput: nextTags,
1480
+ draft: nextDraft,
1481
+ };
1279
1482
  }, [task?.id]);
1280
1483
 
1281
1484
  useEffect(() => {
@@ -1300,7 +1503,24 @@ export function TaskDetailModal({ task, onClose, onStart }) {
1300
1503
  }
1301
1504
  }, [workspaceId, repositoryOptions.length]);
1302
1505
 
1303
- const handleSave = async () => {
1506
+ useEffect(() => {
1507
+ setPendingChange(pendingKey, hasUnsaved);
1508
+ return () => clearPendingChange(pendingKey);
1509
+ }, [hasUnsaved, pendingKey]);
1510
+
1511
+ const handleDiscardChanges = useCallback(() => {
1512
+ const base = initialSnapshotRef.current || {};
1513
+ setTitle(base.title || "");
1514
+ setDescription(base.description || "");
1515
+ setBaseBranch(base.baseBranch || "");
1516
+ setStatus(base.status || "todo");
1517
+ setPriority(base.priority || "");
1518
+ setTagsInput(base.tagsInput || "");
1519
+ setDraft(Boolean(base.draft));
1520
+ showToast("Changes discarded", "info");
1521
+ }, []);
1522
+
1523
+ const handleSave = async ({ closeAfterSave = true } = {}) => {
1304
1524
  setSaving(true);
1305
1525
  haptic("medium");
1306
1526
  const prev = cloneValue(tasksData.value);
@@ -1354,11 +1574,26 @@ export function TaskDetailModal({ task, onClose, onStart }) {
1354
1574
  },
1355
1575
  );
1356
1576
  showToast("Task saved", "success");
1357
- onClose();
1577
+ initialSnapshotRef.current = {
1578
+ title,
1579
+ description,
1580
+ baseBranch,
1581
+ status: nextStatus,
1582
+ priority: priority || "",
1583
+ tagsInput,
1584
+ draft: wantsDraft,
1585
+ };
1586
+ if (closeAfterSave) {
1587
+ onClose?.();
1588
+ return { closed: true };
1589
+ }
1590
+ return true;
1358
1591
  } catch {
1359
1592
  /* toast via apiFetch */
1593
+ return false;
1594
+ } finally {
1595
+ setSaving(false);
1360
1596
  }
1361
- setSaving(false);
1362
1597
  };
1363
1598
 
1364
1599
  const handleStatusUpdate = async (newStatus) => {
@@ -1527,7 +1762,18 @@ export function TaskDetailModal({ task, onClose, onStart }) {
1527
1762
  };
1528
1763
 
1529
1764
  return html`
1530
- <${Modal} title=${task?.title || "Task Detail"} onClose=${onClose} contentClassName="modal-content-wide">
1765
+ <${Modal}
1766
+ title=${task?.title || "Task Detail"}
1767
+ onClose=${onClose}
1768
+ contentClassName="modal-content-wide"
1769
+ unsavedChanges=${changeCount}
1770
+ onSaveBeforeClose=${() => handleSave({ closeAfterSave: true })}
1771
+ onDiscardBeforeClose=${() => {
1772
+ handleDiscardChanges();
1773
+ return true;
1774
+ }}
1775
+ activeOperationLabel=${activeOperationLabel}
1776
+ >
1531
1777
  <div class="task-modal-summary">
1532
1778
  <div class="task-modal-id" style="user-select:all">ID: ${task?.id}</div>
1533
1779
  <div class="task-modal-badges">
@@ -1824,6 +2070,19 @@ export function TaskDetailModal({ task, onClose, onStart }) {
1824
2070
  </div>
1825
2071
  `}
1826
2072
 
2073
+ <${SaveDiscardBar}
2074
+ dirty=${hasUnsaved}
2075
+ message=${unsavedChangesMessage(changeCount)}
2076
+ saveLabel="Save Changes"
2077
+ discardLabel="Discard"
2078
+ onSave=${() => {
2079
+ void handleSave({ closeAfterSave: false });
2080
+ }}
2081
+ onDiscard=${handleDiscardChanges}
2082
+ saving=${saving}
2083
+ disabled=${Boolean(activeOperationLabel && !saving)}
2084
+ />
2085
+
1827
2086
  <div class="btn-row modal-form-span">
1828
2087
  ${(task?.status === "error" || task?.status === "cancelled") &&
1829
2088
  html`
@@ -1833,7 +2092,9 @@ export function TaskDetailModal({ task, onClose, onStart }) {
1833
2092
  `}
1834
2093
  <button
1835
2094
  class="btn btn-secondary btn-sm"
1836
- onClick=${handleSave}
2095
+ onClick=${() => {
2096
+ void handleSave({ closeAfterSave: true });
2097
+ }}
1837
2098
  disabled=${saving}
1838
2099
  >
1839
2100
  ${saving ? "Saving…" : iconText(":save: Save")}
@@ -3008,6 +3269,15 @@ function CreateTaskModalInline({ onClose }) {
3008
3269
  const [repository, setRepository] = useState("");
3009
3270
  const [repositories, setRepositories] = useState([]);
3010
3271
  const [showAdvanced, setShowAdvanced] = useState(false);
3272
+ const initialSnapshotRef = useRef({
3273
+ title: "",
3274
+ description: "",
3275
+ baseBranch: "",
3276
+ priority: "medium",
3277
+ tagsInput: "",
3278
+ draft: false,
3279
+ });
3280
+ const pendingKey = "modal:create-task-inline";
3011
3281
 
3012
3282
  const handleRewrite = async () => {
3013
3283
  if (!title.trim() || rewriting) return;
@@ -3063,6 +3333,39 @@ function CreateTaskModalInline({ onClose }) {
3063
3333
  }
3064
3334
  }, [workspaceId, repositoryOptions.length]);
3065
3335
 
3336
+ const unsavedSnapshot = useMemo(
3337
+ () => ({
3338
+ title: title || "",
3339
+ description: description || "",
3340
+ baseBranch: baseBranch || "",
3341
+ priority: priority || "medium",
3342
+ tagsInput: tagsInput || "",
3343
+ draft: Boolean(draft),
3344
+ }),
3345
+ [baseBranch, description, draft, priority, tagsInput, title],
3346
+ );
3347
+ const changeCount = useMemo(
3348
+ () => countChangedFields(initialSnapshotRef.current, unsavedSnapshot),
3349
+ [unsavedSnapshot],
3350
+ );
3351
+ const hasUnsaved = changeCount > 0;
3352
+
3353
+ useEffect(() => {
3354
+ setPendingChange(pendingKey, hasUnsaved);
3355
+ return () => clearPendingChange(pendingKey);
3356
+ }, [hasUnsaved]);
3357
+
3358
+ const resetToInitial = useCallback(() => {
3359
+ const base = initialSnapshotRef.current || {};
3360
+ setTitle(base.title || "");
3361
+ setDescription(base.description || "");
3362
+ setBaseBranch(base.baseBranch || "");
3363
+ setPriority(base.priority || "medium");
3364
+ setTagsInput(base.tagsInput || "");
3365
+ setDraft(Boolean(base.draft));
3366
+ showToast("Changes discarded", "info");
3367
+ }, []);
3368
+
3066
3369
  const toggleRepo = (slug) => {
3067
3370
  setRepositories((prev) =>
3068
3371
  prev.includes(slug) ? prev.filter((s) => s !== slug) : [...prev, slug],
@@ -3077,10 +3380,14 @@ function CreateTaskModalInline({ onClose }) {
3077
3380
  });
3078
3381
  };
3079
3382
 
3080
- const handleSubmit = async () => {
3383
+ const handleSubmit = async ({ closeAfterSave = true } = {}) => {
3384
+ if (rewriting) {
3385
+ showToast("Wait for AI improvement to finish before saving.", "warning");
3386
+ return false;
3387
+ }
3081
3388
  if (!title.trim()) {
3082
3389
  showToast("Title is required", "error");
3083
- return;
3390
+ return false;
3084
3391
  }
3085
3392
  setSubmitting(true);
3086
3393
  haptic("medium");
@@ -3103,12 +3410,25 @@ function CreateTaskModalInline({ onClose }) {
3103
3410
  }),
3104
3411
  });
3105
3412
  showToast("Task created", "success");
3106
- onClose();
3413
+ initialSnapshotRef.current = {
3414
+ title: title.trim(),
3415
+ description: description.trim(),
3416
+ baseBranch: baseBranch.trim(),
3417
+ priority,
3418
+ tagsInput,
3419
+ draft: Boolean(draft),
3420
+ };
3421
+ if (closeAfterSave) {
3422
+ onClose?.();
3423
+ }
3107
3424
  await loadTasks();
3425
+ return closeAfterSave ? { closed: true } : true;
3108
3426
  } catch {
3109
3427
  /* toast */
3428
+ return false;
3429
+ } finally {
3430
+ setSubmitting(false);
3110
3431
  }
3111
- setSubmitting(false);
3112
3432
  };
3113
3433
 
3114
3434
  useEffect(() => {
@@ -3129,8 +3449,10 @@ function CreateTaskModalInline({ onClose }) {
3129
3449
  priority,
3130
3450
  tagsInput,
3131
3451
  draft,
3452
+ rewriting,
3132
3453
  workspaceId,
3133
3454
  repository,
3455
+ repositories,
3134
3456
  ]);
3135
3457
 
3136
3458
  const parsedTags = normalizeTagInput(tagsInput);
@@ -3140,8 +3462,10 @@ function CreateTaskModalInline({ onClose }) {
3140
3462
  <button
3141
3463
  class="btn btn-primary"
3142
3464
  style="width:100%"
3143
- onClick=${handleSubmit}
3144
- disabled=${submitting}
3465
+ onClick=${() => {
3466
+ void handleSubmit({ closeAfterSave: true });
3467
+ }}
3468
+ disabled=${submitting || rewriting}
3145
3469
  >
3146
3470
  ${submitting ? "Creating…" : iconText("✓ Create Task")}
3147
3471
  </button>
@@ -3153,6 +3477,13 @@ function CreateTaskModalInline({ onClose }) {
3153
3477
  onClose=${onClose}
3154
3478
  contentClassName="modal-content-wide"
3155
3479
  footer=${footerContent}
3480
+ unsavedChanges=${changeCount}
3481
+ onSaveBeforeClose=${() => handleSubmit({ closeAfterSave: true })}
3482
+ onDiscardBeforeClose=${() => {
3483
+ resetToInitial();
3484
+ return true;
3485
+ }}
3486
+ activeOperationLabel=${rewriting ? "Improve with AI is still running" : ""}
3156
3487
  >
3157
3488
  <div class="flex-col create-task-form">
3158
3489
 
@@ -3164,7 +3495,12 @@ function CreateTaskModalInline({ onClose }) {
3164
3495
  value=${title}
3165
3496
  autoFocus=${true}
3166
3497
  onInput=${(e) => setTitle(e.target.value)}
3167
- onKeyDown=${(e) => e.key === "Enter" && !e.shiftKey && handleSubmit()}
3498
+ onKeyDown=${(e) => {
3499
+ if (e.key === "Enter" && !e.shiftKey) {
3500
+ e.preventDefault();
3501
+ void handleSubmit({ closeAfterSave: true });
3502
+ }
3503
+ }}
3168
3504
  />
3169
3505
  <${VoiceMicButtonInline}
3170
3506
  onTranscript=${(t) => setTitle((prev) => (prev ? prev + " " + t : t))}
@@ -3300,6 +3636,19 @@ function CreateTaskModalInline({ onClose }) {
3300
3636
  />
3301
3637
  `}
3302
3638
 
3639
+ <${SaveDiscardBar}
3640
+ dirty=${hasUnsaved}
3641
+ message=${unsavedChangesMessage(changeCount)}
3642
+ saveLabel="Create Task"
3643
+ discardLabel="Discard"
3644
+ onSave=${() => {
3645
+ void handleSubmit({ closeAfterSave: false });
3646
+ }}
3647
+ onDiscard=${resetToInitial}
3648
+ saving=${submitting}
3649
+ disabled=${rewriting}
3650
+ />
3651
+
3303
3652
  </div>
3304
3653
  <//>
3305
3654
  `;
@@ -164,4 +164,3 @@ export function TelemetryTab() {
164
164
  </section>
165
165
  `;
166
166
  }
167
-
@@ -2339,6 +2339,10 @@ export function WorkflowsTab() {
2339
2339
  return html`
2340
2340
  <style>
2341
2341
  .wf-btn {
2342
+ display: inline-flex;
2343
+ align-items: center;
2344
+ justify-content: center;
2345
+ gap: 6px;
2342
2346
  padding: 6px 14px;
2343
2347
  border: 1px solid var(--color-border, #2a3040);
2344
2348
  border-radius: 8px;