@linxin666/dsh-client-ui-task-board 0.2.0 → 0.2.2

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.
Files changed (37) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +1 -1
  3. package/README.zh.md +1 -1
  4. package/lib/client.js +349 -73
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +161 -31
  7. package/lib/types/client/PluginSettingsCard.d.ts +22 -0
  8. package/lib/types/client/TaskBoardSettingsCard.d.ts +2 -2
  9. package/lib/types/client/host-api.d.ts +3 -3
  10. package/lib/types/client/sidebar-entry-core.d.ts +60 -0
  11. package/lib/types/client/sidebar-entry.d.ts +8 -10
  12. package/lib/types/core/controller.d.ts +9 -2
  13. package/lib/types/core/schedule.d.ts +7 -0
  14. package/lib/types/host-ledger.d.ts +10 -0
  15. package/lib/types/host-routes.d.ts +3 -1
  16. package/lib/types/host-runner.d.ts +12 -1
  17. package/lib/types/host-service.d.ts +5 -1
  18. package/lib/types/protocol.d.ts +6 -0
  19. package/package.json +1 -2
  20. package/src/client/PluginSettingsCard.tsx +219 -18
  21. package/src/client/TaskBoardSettingsCard.tsx +10 -10
  22. package/src/client/board/TaskBoard.tsx +5 -5
  23. package/src/client/board/TaskCard.tsx +1 -0
  24. package/src/client/board/TaskDetail.tsx +1 -1
  25. package/src/client/board-mount.tsx +1 -0
  26. package/src/client/host-api.ts +12 -3
  27. package/src/client/settings-card.module.css +90 -1
  28. package/src/client/sidebar-entry-core.ts +208 -0
  29. package/src/client/sidebar-entry.ts +24 -144
  30. package/src/core/controller.ts +20 -3
  31. package/src/core/execution.ts +1 -2
  32. package/src/core/schedule.ts +57 -10
  33. package/src/host-ledger.ts +67 -6
  34. package/src/host-routes.ts +11 -3
  35. package/src/host-runner.ts +24 -6
  36. package/src/host-service.ts +24 -7
  37. package/src/protocol.ts +7 -0
package/README.i18n.yaml CHANGED
@@ -2,5 +2,5 @@
2
2
  # side as of the last confirmed-consistent state. Both languages carry equal authority;
3
3
  # after editing either side, bring the other along and re-record with:
4
4
  # node scripts/verify-docs.mjs --write <dir>
5
- README.md: 9d2e0eea7c24c01a777ac5ae52e73821a7c05ed3
6
- README.zh.md: 4b217aba13a767f605ac2179ac901c9945631e35
5
+ README.md: e9bcccc740d89fdd20a5142efa96dfe79ec842ab
6
+ README.zh.md: bd1b15821bda1577565044c8f9a8e34c1aadfb82
package/README.md CHANGED
@@ -34,7 +34,7 @@ A hot-pluggable DeepSeek Harness (DSH) Web GUI plugin with a Host-authoritative
34
34
  Install the aggregate package or this package alone, then restart `dsh web`:
35
35
 
36
36
  ```sh
37
- dsh plugin --profile web add @linxin666/dsh-client-ui-task-board
37
+ dsh plugin --profile web add @linxin666/dsh-client-ui-task-board@latest
38
38
  ```
39
39
 
40
40
  For local development:
package/README.zh.md CHANGED
@@ -34,7 +34,7 @@
34
34
  安装聚合包或单独安装本包,然后重启 `dsh web`:
35
35
 
36
36
  ```sh
37
- dsh plugin --profile web add @linxin666/dsh-client-ui-task-board
37
+ dsh plugin --profile web add @linxin666/dsh-client-ui-task-board@latest
38
38
  ```
39
39
 
40
40
  本地开发安装:
package/lib/client.js CHANGED
@@ -260,19 +260,65 @@ window.__ModuleLoader__.load({
260
260
  * can never match (for example `0 0 30 2 *`). The five-year horizon includes
261
261
  * a full leap cycle, so a valid February 29 schedule remains reachable from
262
262
  * every non-leap year.
263
+ *
264
+ * Walks candidate year/month/day/hour/minute values straight from the parsed
265
+ * field sets instead of scanning every minute: a sparse expression such as
266
+ * `0 0 29 2 *` used to iterate ~1.5M wall-clock minutes before reaching the
267
+ * next leap day. Wall-clock field construction + the final `matches` re-check
268
+ * preserve the old minute scan's DST semantics exactly (nonexistent spring
269
+ * minutes normalize forward and the repeated fall-back hour is never visited).
263
270
  */
264
271
  function nextRunAtMs(expr, fromMs) {
265
272
  const schedule = parseCron(expr);
266
273
  if (schedule === null) return void 0;
267
274
  if (!hasPossibleCalendarDay(schedule)) return void 0;
268
275
  const from = new Date(fromMs);
269
- const scan = new Date(from.getFullYear(), from.getMonth(), from.getDate(), from.getHours(), from.getMinutes() + 1, 0, 0);
270
276
  const limitMs = fromMs + 5 * 366 * 24 * 60 * 60 * 1e3;
271
- while (scan.getTime() <= limitMs) {
272
- if (matches(schedule, scan)) return scan.getTime();
273
- scan.setMinutes(scan.getMinutes() + 1);
277
+ const sortedMinutes = [...schedule.minutes].sort((a, b) => a - b);
278
+ const sortedHours = [...schedule.hours].sort((a, b) => a - b);
279
+ const sortedMonths = [...schedule.months].sort((a, b) => a - b);
280
+ let year = from.getFullYear();
281
+ let month = from.getMonth() + 1;
282
+ let day = from.getDate();
283
+ let hour = from.getHours();
284
+ let minute = from.getMinutes() + 1;
285
+ while (new Date(year, month - 1, 1, 0, 0, 0, 0).getTime() <= limitMs) {
286
+ for (const candidateMonth of sortedMonths) {
287
+ if (candidateMonth < month) continue;
288
+ const daysInMonth = new Date(year, candidateMonth, 0).getDate();
289
+ const dayStart = candidateMonth === month ? day : 1;
290
+ for (let candidateDay = dayStart; candidateDay <= daysInMonth; candidateDay += 1) {
291
+ if (!dayCandidate(schedule, new Date(year, candidateMonth - 1, candidateDay, 0, 0, 0, 0))) continue;
292
+ const hourStart = candidateMonth === month && candidateDay === day ? hour : 0;
293
+ for (const candidateHour of sortedHours) {
294
+ if (candidateHour < hourStart) continue;
295
+ const minuteStart = candidateMonth === month && candidateDay === day && candidateHour === hour ? minute : 0;
296
+ for (const candidateMinute of sortedMinutes) {
297
+ if (candidateMinute < minuteStart) continue;
298
+ const candidate = new Date(year, candidateMonth - 1, candidateDay, candidateHour, candidateMinute, 0, 0);
299
+ const time = candidate.getTime();
300
+ if (time <= fromMs) continue;
301
+ if (time > limitMs) return void 0;
302
+ if (matches(schedule, candidate)) return time;
303
+ }
304
+ }
305
+ }
306
+ }
307
+ year += 1;
308
+ month = 1;
309
+ day = 1;
310
+ hour = 0;
311
+ minute = 0;
274
312
  }
275
313
  }
314
+ /** Day/weekday OR gate shared by {@link matches} and the candidate scan. */
315
+ function dayCandidate(schedule, date) {
316
+ const dayMatches = schedule.days.has(date.getDate());
317
+ const weekdayMatches = schedule.weekdays.has(date.getDay());
318
+ if (schedule.dayWildcard) return weekdayMatches;
319
+ if (schedule.weekdayWildcard) return dayMatches;
320
+ return dayMatches || weekdayMatches;
321
+ }
276
322
  /** Reject impossible month/day pairs without spending the multi-year scan. */
277
323
  function hasPossibleCalendarDay(schedule) {
278
324
  if (schedule.dayWildcard || !schedule.weekdayWildcard) return true;
@@ -331,11 +377,7 @@ window.__ModuleLoader__.load({
331
377
  if (!schedule.minutes.has(date.getMinutes())) return false;
332
378
  if (!schedule.hours.has(date.getHours())) return false;
333
379
  if (!schedule.months.has(date.getMonth() + 1)) return false;
334
- const dayMatches = schedule.days.has(date.getDate());
335
- const weekdayMatches = schedule.weekdays.has(date.getDay());
336
- if (schedule.dayWildcard) return weekdayMatches;
337
- if (schedule.weekdayWildcard) return dayMatches;
338
- return dayMatches || weekdayMatches;
380
+ return dayCandidate(schedule, date);
339
381
  }
340
382
  function isDigits(value) {
341
383
  return /^\d+$/.test(value);
@@ -945,8 +987,8 @@ window.__ModuleLoader__.load({
945
987
  this.acceptRemote(await transport.bootstrap(this.tasks));
946
988
  if (!this.remoteSubscribed) {
947
989
  this.remoteSubscribed = true;
948
- this.disposers.push(transport.subscribe(() => {
949
- this.refreshRemote();
990
+ this.disposers.push(transport.subscribe((event) => {
991
+ this.onRemoteEvent(event);
950
992
  }));
951
993
  }
952
994
  return true;
@@ -956,6 +998,24 @@ window.__ModuleLoader__.load({
956
998
  return false;
957
999
  }
958
1000
  }
1001
+ /**
1002
+ * SSE frames carry revision/scheduler/power. When the revision matches the
1003
+ * one already applied, apply the frame's scheduler/power in place and skip
1004
+ * the full /state fetch; otherwise the 5 s heartbeat would re-clone and
1005
+ * re-serialize the whole ledger per tab even while nothing changes.
1006
+ */
1007
+ onRemoteEvent(event) {
1008
+ if (event !== void 0 && this.hostState !== void 0 && event.revision === this.hostState.revision && typeof event.scheduler === "object" && event.scheduler !== null && typeof event.power === "object" && event.power !== null) {
1009
+ this.hostState = {
1010
+ revision: event.revision,
1011
+ scheduler: event.scheduler,
1012
+ power: event.power
1013
+ };
1014
+ this.notify();
1015
+ return;
1016
+ }
1017
+ this.refreshRemote();
1018
+ }
959
1019
  async refreshRemote(preserveError) {
960
1020
  const transport = this.deps.transport;
961
1021
  if (transport === void 0) return true;
@@ -1850,6 +1910,7 @@ window.__ModuleLoader__.load({
1850
1910
  type: "button",
1851
1911
  className: board_module_css_default.card,
1852
1912
  "data-status": archived ? "archived" : task.status,
1913
+ "data-dsh-part": "card",
1853
1914
  "data-pending": pending || void 0,
1854
1915
  onClick,
1855
1916
  title: task.description !== "" ? task.description : task.title,
@@ -2467,6 +2528,7 @@ window.__ModuleLoader__.load({
2467
2528
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2468
2529
  className: board_module_css_default.board,
2469
2530
  "data-dsh-taskboard-board": "",
2531
+ "data-dsh-plugin": "task-board",
2470
2532
  children: [
2471
2533
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("header", {
2472
2534
  className: board_module_css_default.boardHeader,
@@ -2542,6 +2604,7 @@ window.__ModuleLoader__.load({
2542
2604
  children: archiveView ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
2543
2605
  className: board_module_css_default.column,
2544
2606
  "data-status": "archived",
2607
+ "data-dsh-part": "column",
2545
2608
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("header", {
2546
2609
  className: board_module_css_default.columnHeader,
2547
2610
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
@@ -2568,6 +2631,7 @@ window.__ModuleLoader__.load({
2568
2631
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
2569
2632
  className: board_module_css_default.column,
2570
2633
  "data-status": column.status,
2634
+ "data-dsh-part": "column",
2571
2635
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("header", {
2572
2636
  className: board_module_css_default.columnHeader,
2573
2637
  children: [
@@ -2655,6 +2719,7 @@ window.__ModuleLoader__.load({
2655
2719
  if (column === void 0) return;
2656
2720
  container = document.createElement("div");
2657
2721
  container.dataset.dshTaskboardView = "";
2722
+ container.dataset.dshPlugin = "task-board";
2658
2723
  container.className = board_module_css_default.boardView;
2659
2724
  column.appendChild(container);
2660
2725
  root = (0, react_dom_client.createRoot)(container);
@@ -2702,9 +2767,7 @@ window.__ModuleLoader__.load({
2702
2767
  };
2703
2768
  }
2704
2769
  //#endregion
2705
- //#region src/client/sidebar-entry.ts
2706
- /** Inline icon (matches the shell's 16px nav-icon look). */
2707
- const ICON = `<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="2.5" width="12" height="11" rx="1.5"/><path d="M2 6.5h12M6.5 6.5v7"/></svg>`;
2770
+ //#region src/client/sidebar-entry-core.ts
2708
2771
  /** Find the sidebar shell root element, or undefined while not yet mounted. */
2709
2772
  function sidebarRoot() {
2710
2773
  const column = document.querySelector("[data-pane=\"sidebar\"], [class*=\"sidebarCol\"]");
@@ -2718,27 +2781,30 @@ window.__ModuleLoader__.load({
2718
2781
  for (const child of root.children) if (child.tagName === "BUTTON") return child;
2719
2782
  }
2720
2783
  /** Build the entry row (a detached button; insert once the shell is up). */
2721
- function createEntry(controller) {
2784
+ function createEntry(options) {
2722
2785
  const entry = document.createElement("button");
2723
2786
  entry.type = "button";
2724
- entry.dataset.dshTaskboardEntry = "";
2725
- entry.className = board_module_css_default.entry;
2726
- entry.setAttribute("aria-label", t("entry.label"));
2727
- entry.innerHTML = `<span class="${board_module_css_default.entryIcon}">${ICON}</span><span class="${board_module_css_default.entryLabel}">${t("entry.label")}</span>`;
2728
- entry.addEventListener("click", () => {
2729
- controller.toggleBoard();
2730
- });
2787
+ entry.setAttribute(options.rowAttribute, "");
2788
+ if (options.plugin !== void 0) {
2789
+ entry.setAttribute("data-dsh-plugin", options.plugin);
2790
+ entry.setAttribute("data-dsh-part", "sidebar-entry");
2791
+ }
2792
+ entry.className = options.css["entry"] ?? "";
2793
+ entry.setAttribute("aria-label", options.label());
2794
+ if (options.tooltip !== void 0) entry.setAttribute("title", options.tooltip());
2795
+ entry.innerHTML = "<span class=\"" + (options.css["entryIcon"] ?? "") + "\">" + options.icon + "</span><span class=\"" + (options.css["entryLabel"] ?? "") + "\">" + options.label() + "</span>";
2796
+ entry.addEventListener("click", options.onToggle);
2731
2797
  return entry;
2732
2798
  }
2733
2799
  /** Re-insert the entry after the New Session row (before the browser region). */
2734
- function placeEntry(root, entry) {
2800
+ function placeEntry(root, entry, options) {
2735
2801
  const button = newSessionButton(root);
2736
2802
  if (button === void 0) return false;
2737
2803
  if (entry.parentElement !== root) {
2738
2804
  const row = button.closest("[class*=\"logoRow\"]");
2739
2805
  const base = row !== null && row.parentElement === root ? row : button;
2740
- const family = Array.from(root.children).filter((el) => el instanceof HTMLElement && el.matches("[data-dsh-taskboard-entry], [data-dsh-ssh-entry]"));
2741
- const anchor = family.length > 0 ? family[0] : base.nextElementSibling;
2806
+ const family = Array.from(root.children).filter((el) => el instanceof HTMLElement && el.matches(options.familySelectors.join(", ")));
2807
+ const anchor = options.position === "before" ? family.length > 0 ? family[0] : base.nextElementSibling : family.length > 0 ? family[family.length - 1].nextElementSibling : base.nextElementSibling;
2742
2808
  root.insertBefore(entry, anchor);
2743
2809
  }
2744
2810
  return true;
@@ -2746,12 +2812,12 @@ window.__ModuleLoader__.load({
2746
2812
  /**
2747
2813
  * Mount the sidebar entry, waiting for the shell to render and self-healing
2748
2814
  * on later React re-renders.
2749
- * @param controller - the board controller the entry toggles.
2815
+ * @param options - the row's attribute/icon/copy/action/ordering configuration.
2750
2816
  * @returns disposer removing the entry and its observers.
2751
2817
  */
2752
- function mountSidebarEntry(controller) {
2753
- if (typeof document !== "undefined" && document.querySelector("[data-dsh-taskboard-entry]") !== null) return () => {};
2754
- const entry = createEntry(controller);
2818
+ function mountSidebarEntry$1(options) {
2819
+ if (typeof document !== "undefined" && document.querySelector(options.rowSelector) !== null) return () => {};
2820
+ const entry = createEntry(options);
2755
2821
  let root;
2756
2822
  let placed = false;
2757
2823
  const tryPlace = () => {
@@ -2768,7 +2834,7 @@ window.__ModuleLoader__.load({
2768
2834
  }
2769
2835
  root ??= sidebarRoot();
2770
2836
  if (root === void 0) return;
2771
- placed = placeEntry(root, entry);
2837
+ placed = placeEntry(root, entry, options);
2772
2838
  if (placed) rootObserver.observe(root, {
2773
2839
  childList: true,
2774
2840
  subtree: true
@@ -2787,25 +2853,59 @@ window.__ModuleLoader__.load({
2787
2853
  tryPlace();
2788
2854
  return;
2789
2855
  }
2790
- if (!root.contains(entry)) placed = placeEntry(root, entry);
2856
+ if (!root.contains(entry)) placed = placeEntry(root, entry, options);
2791
2857
  });
2792
- const syncActive = () => {
2793
- if (controller.getSnapshot().boardOpen) entry.dataset.active = "true";
2794
- else delete entry.dataset.active;
2795
- };
2796
- const unsubscribe = controller.subscribe(syncActive);
2797
- syncActive();
2858
+ const unsubscribeActive = options.active === void 0 ? void 0 : (() => {
2859
+ const syncActive = () => {
2860
+ if (options.active.isOpen()) entry.dataset.active = "true";
2861
+ else delete entry.dataset.active;
2862
+ };
2863
+ const unsubscribe = options.active.subscribe(syncActive);
2864
+ syncActive();
2865
+ return unsubscribe;
2866
+ })();
2798
2867
  tryPlace();
2799
2868
  return () => {
2800
2869
  waitObserver.disconnect();
2801
2870
  rootObserver.disconnect();
2802
- unsubscribe();
2871
+ unsubscribeActive?.();
2803
2872
  entry.remove();
2804
2873
  };
2805
2874
  }
2806
2875
  //#endregion
2876
+ //#region src/client/sidebar-entry.ts
2877
+ /** Stable data attribute identifying the injected entry row. */
2878
+ const ENTRY_SELECTOR = "[data-dsh-taskboard-entry]";
2879
+ /** Inline icon (matches the shell's 16px nav-icon look). */
2880
+ const ICON = "<svg viewBox=\"0 0 16 16\" width=\"14\" height=\"14\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.3\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><rect x=\"2\" y=\"2.5\" width=\"12\" height=\"11\" rx=\"1.5\"/><path d=\"M2 6.5h12M6.5 6.5v7\"/></svg>";
2881
+ /**
2882
+ * Mount the sidebar entry, waiting for the shell to render and self-healing
2883
+ * on later React re-renders.
2884
+ * @param controller - the board controller the entry toggles.
2885
+ * @returns disposer removing the entry and its observers.
2886
+ */
2887
+ function mountSidebarEntry(controller) {
2888
+ return mountSidebarEntry$1({
2889
+ rowAttribute: "data-dsh-taskboard-entry",
2890
+ rowSelector: ENTRY_SELECTOR,
2891
+ plugin: "task-board",
2892
+ icon: ICON,
2893
+ css: board_module_css_default,
2894
+ label: () => t("entry.label"),
2895
+ onToggle: () => {
2896
+ controller.toggleBoard();
2897
+ },
2898
+ position: "before",
2899
+ familySelectors: ["[data-dsh-taskboard-entry]", "[data-dsh-ssh-entry]"],
2900
+ active: {
2901
+ subscribe: (listener) => controller.subscribe(listener),
2902
+ isOpen: () => controller.getSnapshot().boardOpen
2903
+ }
2904
+ });
2905
+ }
2906
+ //#endregion
2807
2907
  //#region \0dsh-css:packages/dsh-task-board/src/client/settings-card.module.css.mjs
2808
- const css = ".Jh0q7G_card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;list-style:none;transition:border-color .16s,background .16s}.Jh0q7G_card:hover{border-color:var(--dsw-alias-label-dimmed)}.Jh0q7G_cardOpen{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-label-dimmed)}.Jh0q7G_header{appearance:none;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:0 0;border:0;border-radius:12px;align-items:center;gap:12px;padding:14px 16px;display:flex}.Jh0q7G_header:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}.Jh0q7G_headerStatic{border-radius:12px;align-items:center;gap:12px;width:100%;padding:14px 16px;display:flex}.Jh0q7G_headText{flex-direction:column;flex:1;gap:4px;min-width:0;display:flex}.Jh0q7G_name{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}.Jh0q7G_description{color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:1.5}.Jh0q7G_pending{white-space:nowrap;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);border-radius:999px;flex:none;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px}.Jh0q7G_chevron{color:var(--dsw-alias-label-tertiary);flex:none;transition:transform .16s}.Jh0q7G_chevronOpen{transform:rotate(180deg)}.Jh0q7G_body{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:8px}.Jh0q7G_readOnly{color:var(--dsw-alias-label-tertiary);margin:12px 0 0;font-size:12px;line-height:1.5}.Jh0q7G_notExposed{color:var(--dsw-alias-state-warn-primary);margin:12px 0 0;font-size:12px;line-height:1.5}.Jh0q7G_footer{border-top:1px solid var(--dsw-alias-border-l2);justify-content:flex-end;align-items:center;gap:8px;padding:12px 0 4px;display:flex}.Jh0q7G_failed{min-width:0;color:var(--dsw-alias-label-error);text-overflow:ellipsis;white-space:nowrap;flex:1;margin:0;font-size:12px;line-height:1.5;overflow:hidden}.Jh0q7G_discard,.Jh0q7G_save{appearance:none;font:inherit;cursor:pointer;border:1px solid #0000;border-radius:8px;padding:5px 14px;font-size:13px;line-height:1.5}.Jh0q7G_discard{border-color:var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);background:0 0}.Jh0q7G_discard:hover:not(:disabled){color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-dimmed)}.Jh0q7G_save{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3)}.Jh0q7G_discard:disabled,.Jh0q7G_save:disabled{opacity:.4;cursor:default}.Jh0q7G_discard:focus-visible,.Jh0q7G_save:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}.Jh0q7G_field{flex-direction:column;gap:6px;padding:12px 0;display:flex}.Jh0q7G_field+.Jh0q7G_field{border-top:1px solid var(--dsw-alias-border-l2)}.Jh0q7G_head{align-items:center;gap:8px;display:flex}.Jh0q7G_label{min-width:0;color:var(--dsw-alias-label-primary);flex:1;font-size:13px;font-weight:500;line-height:1.5}.Jh0q7G_badges{align-items:center;gap:8px;display:inline-flex}.Jh0q7G_badge{white-space:nowrap;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);border-radius:999px;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px}.Jh0q7G_reset{font:inherit;color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:none;padding:0;font-size:12px;line-height:1.5}.Jh0q7G_reset:hover:not(:disabled){color:var(--dsw-alias-label-primary)}.Jh0q7G_reset:disabled{cursor:default}.Jh0q7G_reset:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px;outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px}.Jh0q7G_input,.Jh0q7G_select{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);height:34px;font:inherit;color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px;line-height:1.5}.Jh0q7G_input:focus-visible,.Jh0q7G_select:focus-visible{border-color:var(--dsw-alias-brand-primary);outline:none}.Jh0q7G_input:disabled,.Jh0q7G_select:disabled{color:var(--dsw-alias-label-tertiary);cursor:default}.Jh0q7G_inputInvalid{border:1px solid var(--dsw-alias-label-error);background:var(--dsw-alias-bg-layer-3);height:34px;font:inherit;color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px;line-height:1.5}.Jh0q7G_inputInvalid:focus-visible{outline:2px solid var(--dsw-alias-label-error);outline-offset:1px;border-color:var(--dsw-alias-label-error)}.Jh0q7G_invalid{color:var(--dsw-alias-label-error);margin:0;font-size:12px;line-height:1.5}.Jh0q7G_hint{color:var(--dsw-alias-label-tertiary);margin:0;font-size:12px;line-height:1.5}@media (prefers-reduced-motion:reduce){.Jh0q7G_card,.Jh0q7G_header,.Jh0q7G_chevron,.Jh0q7G_chevronOpen,.Jh0q7G_discard,.Jh0q7G_save{transition:none}}";
2908
+ const css = ".Jh0q7G_card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;list-style:none;transition:border-color .16s,background .16s}.Jh0q7G_card:hover{border-color:var(--dsw-alias-label-dimmed)}.Jh0q7G_cardOpen{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-label-dimmed)}.Jh0q7G_header{appearance:none;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:0 0;border:0;border-radius:12px;align-items:center;gap:12px;padding:14px 16px;display:flex}.Jh0q7G_header:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}.Jh0q7G_headerStatic{border-radius:12px;align-items:center;gap:12px;width:100%;padding:14px 16px;display:flex}.Jh0q7G_headText{flex-direction:column;flex:1;gap:4px;min-width:0;display:flex}.Jh0q7G_name{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}.Jh0q7G_description{color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:1.5}.Jh0q7G_pending{white-space:nowrap;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);border-radius:999px;flex:none;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px}.Jh0q7G_chevron{color:var(--dsw-alias-label-tertiary);flex:none;transition:transform .16s}.Jh0q7G_chevronOpen{transform:rotate(180deg)}.Jh0q7G_body{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:8px}.Jh0q7G_readOnly{color:var(--dsw-alias-label-tertiary);margin:12px 0 0;font-size:12px;line-height:1.5}.Jh0q7G_notExposed{color:var(--dsw-alias-state-warn-primary);margin:12px 0 0;font-size:12px;line-height:1.5}.Jh0q7G_footer{border-top:1px solid var(--dsw-alias-border-l2);justify-content:flex-end;align-items:center;gap:8px;padding:12px 0 4px;display:flex}.Jh0q7G_failed{min-width:0;color:var(--dsw-alias-label-error);text-overflow:ellipsis;white-space:nowrap;flex:1;margin:0;font-size:12px;line-height:1.5;overflow:hidden}.Jh0q7G_discard,.Jh0q7G_save{appearance:none;font:inherit;cursor:pointer;border:1px solid #0000;border-radius:8px;padding:5px 14px;font-size:13px;line-height:1.5}.Jh0q7G_discard{border-color:var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);background:0 0}.Jh0q7G_discard:hover:not(:disabled){color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-dimmed)}.Jh0q7G_save{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3)}.Jh0q7G_discard:disabled,.Jh0q7G_save:disabled{opacity:.4;cursor:default}.Jh0q7G_discard:focus-visible,.Jh0q7G_save:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}.Jh0q7G_field{flex-direction:column;gap:6px;padding:12px 0;display:flex}.Jh0q7G_field+.Jh0q7G_field{border-top:1px solid var(--dsw-alias-border-l2)}.Jh0q7G_head{align-items:center;gap:8px;display:flex}.Jh0q7G_label{min-width:0;color:var(--dsw-alias-label-primary);flex:1;font-size:13px;font-weight:500;line-height:1.5}.Jh0q7G_badges{align-items:center;gap:8px;display:inline-flex}.Jh0q7G_badge{white-space:nowrap;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);border-radius:999px;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px}.Jh0q7G_reset{font:inherit;color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:none;padding:0;font-size:12px;line-height:1.5}.Jh0q7G_reset:hover:not(:disabled){color:var(--dsw-alias-label-primary)}.Jh0q7G_reset:disabled{cursor:default}.Jh0q7G_reset:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px;outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px}.Jh0q7G_input,.Jh0q7G_select{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);height:34px;font:inherit;color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px;line-height:1.5}.Jh0q7G_input:focus-visible,.Jh0q7G_select:focus-visible{border-color:var(--dsw-alias-brand-primary);outline:none}.Jh0q7G_input:disabled,.Jh0q7G_select:disabled{color:var(--dsw-alias-label-tertiary);cursor:default}.Jh0q7G_inputInvalid{border:1px solid var(--dsw-alias-label-error);background:var(--dsw-alias-bg-layer-3);height:34px;font:inherit;color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px;line-height:1.5}.Jh0q7G_inputInvalid:focus-visible{outline:2px solid var(--dsw-alias-label-error);outline-offset:1px;border-color:var(--dsw-alias-label-error)}.Jh0q7G_selectWrap{position:relative}.Jh0q7G_selectButton{appearance:none;text-align:left;cursor:pointer;justify-content:space-between;align-items:center;gap:8px;width:100%;display:flex}.Jh0q7G_selectLabel{text-overflow:ellipsis;white-space:nowrap;min-width:0;overflow:hidden}.Jh0q7G_selectChevron{color:var(--dsw-alias-label-tertiary);flex:none;transition:transform .16s}.Jh0q7G_selectChevronOpen{transform:rotate(180deg)}.Jh0q7G_selectPopup{z-index:40;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);max-height:240px;box-shadow:0 8px 24px var(--dsw-alias-bg-mask-2);opacity:0;border-radius:8px;flex-direction:column;padding:4px;transition:opacity .1s,transform .1s;display:flex;position:absolute;top:calc(100% + 4px);left:0;right:0;overflow-y:auto;transform:translateY(-4px)}.Jh0q7G_selectPopupOpen{opacity:1;transform:none}.Jh0q7G_selectPopupClose{opacity:0;pointer-events:none;transform:translateY(-4px)}.Jh0q7G_selectOption{color:var(--dsw-alias-label-primary);cursor:pointer;white-space:nowrap;text-overflow:ellipsis;border-radius:6px;padding:6px 10px;font-size:13px;line-height:1.5;overflow:hidden}.Jh0q7G_selectOption:hover,.Jh0q7G_selectOptionActive{background:var(--dsw-alias-interactive-bg-hover)}.Jh0q7G_selectOptionSelected{color:var(--dsw-alias-brand-primary);background:color-mix(in srgb, var(--dsw-alias-brand-primary-new-colorprimary-new-color) 10%, transparent);font-weight:500}.Jh0q7G_invalid{color:var(--dsw-alias-label-error);margin:0;font-size:12px;line-height:1.5}.Jh0q7G_hint{color:var(--dsw-alias-label-tertiary);margin:0;font-size:12px;line-height:1.5}@media (prefers-reduced-motion:reduce){.Jh0q7G_card,.Jh0q7G_header,.Jh0q7G_chevron,.Jh0q7G_chevronOpen,.Jh0q7G_discard,.Jh0q7G_save,.Jh0q7G_selectChevron,.Jh0q7G_selectChevronOpen,.Jh0q7G_selectPopup{transition:none}}";
2809
2909
  const tagId = "@linxin666/dsh-client-ui-task-board/settings-card.module.css";
2810
2910
  if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
2811
2911
  const tag = document.createElement("style");
@@ -2842,7 +2942,18 @@ window.__ModuleLoader__.load({
2842
2942
  "readOnly": "Jh0q7G_readOnly",
2843
2943
  "reset": "Jh0q7G_reset",
2844
2944
  "save": "Jh0q7G_save",
2845
- "select": "Jh0q7G_select"
2945
+ "select": "Jh0q7G_select",
2946
+ "selectButton": "Jh0q7G_selectButton",
2947
+ "selectChevron": "Jh0q7G_selectChevron",
2948
+ "selectChevronOpen": "Jh0q7G_selectChevronOpen",
2949
+ "selectLabel": "Jh0q7G_selectLabel",
2950
+ "selectOption": "Jh0q7G_selectOption",
2951
+ "selectOptionActive": "Jh0q7G_selectOptionActive",
2952
+ "selectOptionSelected": "Jh0q7G_selectOptionSelected",
2953
+ "selectPopup": "Jh0q7G_selectPopup",
2954
+ "selectPopupClose": "Jh0q7G_selectPopupClose",
2955
+ "selectPopupOpen": "Jh0q7G_selectPopupOpen",
2956
+ "selectWrap": "Jh0q7G_selectWrap"
2846
2957
  };
2847
2958
  //#endregion
2848
2959
  //#region src/client/PluginSettingsCard.tsx
@@ -2976,6 +3087,175 @@ window.__ModuleLoader__.load({
2976
3087
  }) : null]
2977
3088
  });
2978
3089
  }
3090
+ const NON_SKIN_BODY_MARKERS = /* @__PURE__ */ new Set(["dshSkinCenter", "dshSidebarCollapsed"]);
3091
+ function isSkinActive() {
3092
+ return Object.keys(document.body.dataset).some((key) => key.startsWith("dsh") && !NON_SKIN_BODY_MARKERS.has(key));
3093
+ }
3094
+ const SELECT_CLOSE_MS = 100;
3095
+ /**
3096
+ * The shared dual-mode select control. While an appearance skin is active it
3097
+ * renders the legacy native `<select>` untouched, so element-level skin
3098
+ * selectors keep working; under the default appearance it renders a
3099
+ * self-drawn `role="listbox"` popup whose open/close is transition-animated.
3100
+ * Staged cards reach it through BooleanField/ChoiceField; immediate-apply
3101
+ * editors (the side-card prefs) bind it directly through onEdit.
3102
+ * 双模式下拉框:皮肤激活时用原生 select,默认外观用自绘动画弹层。
3103
+ */
3104
+ function SelectField(props) {
3105
+ const { id, options, value } = props;
3106
+ const [open, setOpen] = (0, react.useState)(false);
3107
+ const [closing, setClosing] = (0, react.useState)(false);
3108
+ const [phase, setPhase] = (0, react.useState)("initial");
3109
+ const [activeIndex, setActiveIndex] = (0, react.useState)(0);
3110
+ const closeTimer = (0, react.useRef)(void 0);
3111
+ const wrapRef = (0, react.useRef)(null);
3112
+ const popupRef = (0, react.useRef)(null);
3113
+ const currentIndex = () => {
3114
+ const index = options.findIndex((option) => option.value === value);
3115
+ return index >= 0 ? index : 0;
3116
+ };
3117
+ const close = (0, react.useCallback)(() => {
3118
+ if (closeTimer.current !== void 0) clearTimeout(closeTimer.current);
3119
+ setClosing(true);
3120
+ closeTimer.current = setTimeout(() => {
3121
+ setClosing(false);
3122
+ setOpen(false);
3123
+ }, SELECT_CLOSE_MS);
3124
+ }, []);
3125
+ const openPopup = () => {
3126
+ if (closeTimer.current !== void 0) clearTimeout(closeTimer.current);
3127
+ setActiveIndex(currentIndex());
3128
+ setPhase("initial");
3129
+ setClosing(false);
3130
+ setOpen(true);
3131
+ };
3132
+ const commit = (index) => {
3133
+ const option = options[index];
3134
+ if (option) props.onEdit(option.value);
3135
+ close();
3136
+ };
3137
+ const onTriggerClick = () => {
3138
+ if (props.disabled) return;
3139
+ if (open && !closing) close();
3140
+ else openPopup();
3141
+ };
3142
+ const onKeyDown = (event) => {
3143
+ if (props.disabled) return;
3144
+ const count = options.length;
3145
+ switch (event.key) {
3146
+ case "ArrowDown":
3147
+ case "ArrowUp":
3148
+ case "Enter":
3149
+ case " ":
3150
+ event.preventDefault();
3151
+ if (!open) openPopup();
3152
+ else if (!closing) if (event.key === "ArrowDown") setActiveIndex((index) => (index + 1) % count);
3153
+ else if (event.key === "ArrowUp") setActiveIndex((index) => (index - 1 + count) % count);
3154
+ else commit(activeIndex);
3155
+ break;
3156
+ case "Escape":
3157
+ if (open) {
3158
+ event.preventDefault();
3159
+ event.stopPropagation();
3160
+ close();
3161
+ }
3162
+ break;
3163
+ case "Tab":
3164
+ if (open) close();
3165
+ break;
3166
+ }
3167
+ };
3168
+ (0, react.useEffect)(() => () => {
3169
+ if (closeTimer.current !== void 0) clearTimeout(closeTimer.current);
3170
+ }, []);
3171
+ (0, react.useLayoutEffect)(() => {
3172
+ if (open && !closing && phase === "initial") {
3173
+ popupRef.current?.offsetHeight;
3174
+ setPhase("open");
3175
+ }
3176
+ }, [
3177
+ open,
3178
+ closing,
3179
+ phase
3180
+ ]);
3181
+ (0, react.useEffect)(() => {
3182
+ if (!open) return;
3183
+ const onPointerDown = (event) => {
3184
+ const target = event.target;
3185
+ if (target instanceof Node && !wrapRef.current?.contains(target)) close();
3186
+ };
3187
+ document.addEventListener("pointerdown", onPointerDown);
3188
+ return () => document.removeEventListener("pointerdown", onPointerDown);
3189
+ }, [open, close]);
3190
+ (0, react.useEffect)(() => {
3191
+ if (props.disabled && open) close();
3192
+ }, [
3193
+ props.disabled,
3194
+ open,
3195
+ close
3196
+ ]);
3197
+ if (isSkinActive()) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("select", {
3198
+ id,
3199
+ className: settings_card_module_css_default.select,
3200
+ value,
3201
+ disabled: props.disabled,
3202
+ onChange: (event) => {
3203
+ props.onEdit(event.target.value);
3204
+ },
3205
+ children: options.map((option) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
3206
+ value: option.value,
3207
+ children: option.label
3208
+ }, option.value))
3209
+ });
3210
+ const label = options.find((option) => option.value === value)?.label ?? "";
3211
+ const popupClass = closing ? `${settings_card_module_css_default.selectPopup} ${settings_card_module_css_default.selectPopupClose}` : phase === "open" ? `${settings_card_module_css_default.selectPopup} ${settings_card_module_css_default.selectPopupOpen}` : settings_card_module_css_default.selectPopup;
3212
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3213
+ className: settings_card_module_css_default.selectWrap,
3214
+ ref: wrapRef,
3215
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
3216
+ type: "button",
3217
+ id,
3218
+ className: `${settings_card_module_css_default.select} ${settings_card_module_css_default.selectButton}`,
3219
+ disabled: props.disabled,
3220
+ "aria-haspopup": "listbox",
3221
+ "aria-expanded": open,
3222
+ "aria-activedescendant": open ? `${id}-o${activeIndex}` : void 0,
3223
+ "aria-invalid": props.invalid || void 0,
3224
+ onClick: onTriggerClick,
3225
+ onKeyDown,
3226
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3227
+ className: settings_card_module_css_default.selectLabel,
3228
+ children: label
3229
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
3230
+ width: "14",
3231
+ height: "14",
3232
+ viewBox: "0 0 14 14",
3233
+ fill: "none",
3234
+ xmlns: "http://www.w3.org/2000/svg",
3235
+ className: open ? `${settings_card_module_css_default.selectChevron} ${settings_card_module_css_default.selectChevronOpen}` : settings_card_module_css_default.selectChevron,
3236
+ "aria-hidden": "true",
3237
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
3238
+ d: "M11.8486 5.5L11.4238 5.92383L8.69727 8.65137C8.44157 8.90706 8.21562 9.13382 8.01172 9.29785C7.79912 9.46883 7.55595 9.61756 7.25 9.66602C7.08435 9.69222 6.91565 9.69222 6.75 9.66602C6.44405 9.61756 6.20088 9.46883 5.98828 9.29785C5.78438 9.13382 5.55843 8.90706 5.30273 8.65137L2.57617 5.92383L2.15137 5.5L3 4.65137L3.42383 5.07617L6.15137 7.80273C6.42595 8.07732 6.59876 8.24849 6.74023 8.3623C6.87291 8.46904 6.92272 8.47813 6.9375 8.48047C6.97895 8.48703 7.02105 8.48703 7.0625 8.48047C7.07728 8.47813 7.12709 8.46904 7.25977 8.3623C7.40124 8.24849 7.57405 8.07732 7.84863 7.80273L10.5762 5.07617L11 4.65137L11.8486 5.5Z",
3239
+ fill: "currentColor"
3240
+ })
3241
+ })]
3242
+ }), open ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3243
+ className: popupClass,
3244
+ role: "listbox",
3245
+ ref: popupRef,
3246
+ children: options.map((option, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3247
+ id: `${id}-o${index}`,
3248
+ role: "option",
3249
+ "aria-selected": option.value === value,
3250
+ className: `${settings_card_module_css_default.selectOption}${option.value === value ? ` ${settings_card_module_css_default.selectOptionSelected}` : ""}${index === activeIndex && !closing ? ` ${settings_card_module_css_default.selectOptionActive}` : ""}`,
3251
+ onClick: () => {
3252
+ commit(index);
3253
+ },
3254
+ children: option.label
3255
+ }, option.value))
3256
+ }) : null]
3257
+ });
3258
+ }
2979
3259
  /** A staged boolean field: 继承 / 开 / 关. */
2980
3260
  function BooleanField(props) {
2981
3261
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
@@ -3001,28 +3281,26 @@ window.__ModuleLoader__.load({
3001
3281
  })]
3002
3282
  }) : null]
3003
3283
  }),
3004
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
3284
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SelectField, {
3005
3285
  id: props.id,
3006
- className: settings_card_module_css_default.select,
3007
- value: props.text,
3008
- disabled: props.disabled,
3009
- onChange: (event) => {
3010
- props.onEdit(event.target.value);
3011
- },
3012
- children: [
3013
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
3286
+ options: [
3287
+ {
3014
3288
  value: "",
3015
- children: props.inheritLabel
3016
- }),
3017
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
3289
+ label: props.inheritLabel
3290
+ },
3291
+ {
3018
3292
  value: "true",
3019
- children: props.onLabel
3020
- }),
3021
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
3293
+ label: props.onLabel
3294
+ },
3295
+ {
3022
3296
  value: "false",
3023
- children: props.offLabel
3024
- })
3025
- ]
3297
+ label: props.offLabel
3298
+ }
3299
+ ],
3300
+ value: props.text,
3301
+ disabled: props.disabled,
3302
+ invalid: props.invalid,
3303
+ onEdit: props.onEdit
3026
3304
  }),
3027
3305
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
3028
3306
  className: settings_card_module_css_default.hint,
@@ -3339,24 +3617,16 @@ window.__ModuleLoader__.load({
3339
3617
  const [power, setPower] = (0, react.useState)();
3340
3618
  (0, react.useEffect)(() => {
3341
3619
  let live = true;
3342
- const refresh = async () => {
3620
+ const events = new EventSource("/api/task-board/events");
3621
+ events.onmessage = (message) => {
3343
3622
  try {
3344
- const response = await fetch("/api/task-board/state", {
3345
- cache: "no-store",
3346
- credentials: "same-origin"
3347
- });
3348
- if (!response.ok) return;
3349
- const snapshot = await response.json();
3350
- if (live) setPower(snapshot.power);
3623
+ const frame = JSON.parse(message.data);
3624
+ if (frame.power !== void 0 && live) setPower(frame.power);
3351
3625
  } catch {}
3352
3626
  };
3353
- refresh();
3354
- const timer = setInterval(() => {
3355
- refresh();
3356
- }, 5e3);
3357
3627
  return () => {
3358
3628
  live = false;
3359
- clearInterval(timer);
3629
+ events.close();
3360
3630
  };
3361
3631
  }, []);
3362
3632
  const fieldProps = {
@@ -3515,8 +3785,14 @@ window.__ModuleLoader__.load({
3515
3785
  }
3516
3786
  subscribe(listener) {
3517
3787
  const events = new EventSource(`${TASK_BOARD_API_PREFIX}/events`);
3518
- events.onmessage = () => {
3519
- listener();
3788
+ events.onmessage = (message) => {
3789
+ try {
3790
+ const parsed = JSON.parse(message.data);
3791
+ if (parsed === null || typeof parsed !== "object" || typeof parsed.revision !== "number") throw new Error("invalid event frame");
3792
+ listener(parsed);
3793
+ } catch {
3794
+ listener();
3795
+ }
3520
3796
  };
3521
3797
  const onVisible = () => {
3522
3798
  if (document.visibilityState === "visible") listener();