@linxin666/dsh-client-ui-task-board 0.1.13 → 0.1.15

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/lib/client.js CHANGED
@@ -8,107 +8,6 @@ window.__ModuleLoader__.load({
8
8
  let react = require("react");
9
9
  let react_jsx_runtime = require("react/jsx-runtime");
10
10
  let _deepseek_ai_dsh_client_runtime_client = require("@deepseek-ai/dsh-client-runtime/client");
11
- //#region src/core/schedule.ts
12
- /** Inclusive ranges per field, in cron order. */
13
- const FIELD_RANGES = [
14
- [0, 59],
15
- [0, 23],
16
- [1, 31],
17
- [1, 12],
18
- [0, 7]
19
- ];
20
- /**
21
- * Parse a 5-field cron expression.
22
- * @returns the match sets, or null when the expression is invalid.
23
- */
24
- function parseCron(expr) {
25
- const fields = expr.trim().split(/\s+/);
26
- if (fields.length !== 5) return null;
27
- const sets = [];
28
- for (let index = 0; index < 5; index++) {
29
- const [min, max] = FIELD_RANGES[index];
30
- const set = /* @__PURE__ */ new Set();
31
- if (!parseField(fields[index], min, max, set)) return null;
32
- sets.push(set);
33
- }
34
- const weekdays = /* @__PURE__ */ new Set();
35
- for (const day of sets[4]) weekdays.add(day === 7 ? 0 : day);
36
- return {
37
- minutes: sets[0],
38
- hours: sets[1],
39
- days: sets[2],
40
- months: sets[3],
41
- weekdays,
42
- dayWildcard: fields[2] === "*",
43
- weekdayWildcard: fields[4] === "*"
44
- };
45
- }
46
- /** Whether the expression parses. */
47
- function isValidCron(expr) {
48
- return parseCron(expr) !== null;
49
- }
50
- /**
51
- * Compute the next matching instant after `fromMs` (ms epoch), in local time,
52
- * at minute granularity, strictly greater than `fromMs`. Returns the ms epoch
53
- * of the matching minute's start, or undefined when nothing matches within
54
- * 366 days (e.g. `0 0 30 2 *`).
55
- */
56
- function nextRunAtMs(expr, fromMs) {
57
- const schedule = parseCron(expr);
58
- if (schedule === null) return void 0;
59
- const from = new Date(fromMs);
60
- const scan = new Date(from.getFullYear(), from.getMonth(), from.getDate(), from.getHours(), from.getMinutes() + 1, 0, 0);
61
- const limitMs = fromMs + 366 * 24 * 60 * 60 * 1e3;
62
- while (scan.getTime() <= limitMs) {
63
- if (matches(schedule, scan)) return scan.getTime();
64
- scan.setMinutes(scan.getMinutes() + 1);
65
- }
66
- }
67
- /** Parse one comma-list field into the match set. */
68
- function parseField(field, min, max, out) {
69
- if (field === "*") {
70
- for (let value = min; value <= max; value++) out.add(value);
71
- return true;
72
- }
73
- for (const part of field.split(",")) {
74
- if (part === "") return false;
75
- const [range, stepRaw] = part.split("/");
76
- let low;
77
- let high;
78
- if (range === "*") {
79
- low = min;
80
- high = max;
81
- } else if (range.includes("-")) {
82
- const [a, b] = range.split("-");
83
- if (a === "" || b === "" || !isDigits(a) || !isDigits(b)) return false;
84
- low = Number(a);
85
- high = Number(b);
86
- } else if (isDigits(range)) {
87
- low = Number(range);
88
- high = Number(range);
89
- } else return false;
90
- if (low < min || high > max || low > high) return false;
91
- const step = stepRaw === void 0 ? 1 : isDigits(stepRaw) ? Number(stepRaw) : NaN;
92
- if (!Number.isInteger(step) || step < 1) return false;
93
- for (let value = low; value <= high; value += step) out.add(value);
94
- }
95
- return true;
96
- }
97
- /** Day/weekday OR semantics: a restricted day field alone gates, and vice versa. */
98
- function matches(schedule, date) {
99
- if (!schedule.minutes.has(date.getMinutes())) return false;
100
- if (!schedule.hours.has(date.getHours())) return false;
101
- if (!schedule.months.has(date.getMonth() + 1)) return false;
102
- const dayMatches = schedule.days.has(date.getDate());
103
- const weekdayMatches = schedule.weekdays.has(date.getDay());
104
- if (schedule.dayWildcard) return weekdayMatches;
105
- if (schedule.weekdayWildcard) return dayMatches;
106
- return dayMatches || weekdayMatches;
107
- }
108
- function isDigits(value) {
109
- return /^\d+$/.test(value);
110
- }
111
- //#endregion
112
11
  //#region src/core/tasks.ts
113
12
  /** The five kanban columns, in display order. */
114
13
  const COLUMNS = [
@@ -249,6 +148,221 @@ window.__ModuleLoader__.load({
249
148
  return "running";
250
149
  }
251
150
  //#endregion
151
+ //#region src/core/use-cases/task-create.ts
152
+ /**
153
+ * Create-task use case: mint a new task from user input, rejecting a blank
154
+ * title. Pure ledger transition (no persistence or notify — the controller
155
+ * orchestrates those), so it is unit-testable without any runtime face.
156
+ */
157
+ /**
158
+ * Apply a create against the current ledger. Returns the new task and the
159
+ * appended ledger, or the unchanged ledger when the title is blank.
160
+ * @param tasks - current ledger.
161
+ * @param input - raw user input (title/description/prompt).
162
+ * @param now - clock instant (ms epoch).
163
+ * @param id - minted task id.
164
+ */
165
+ function applyCreateTask(tasks, input, now, id) {
166
+ if (input.title.trim() === "") return {
167
+ task: void 0,
168
+ tasks
169
+ };
170
+ const task = createTask(input, now, id);
171
+ return {
172
+ task,
173
+ tasks: [...tasks, task]
174
+ };
175
+ }
176
+ //#endregion
177
+ //#region src/core/use-cases/task-delete.ts
178
+ /**
179
+ * Apply a delete across the ledger. The selection (a task id) is cleared when
180
+ * it matches the removed task, so the UI never lingers on a vanished detail.
181
+ * @param tasks - current ledger.
182
+ * @param selectedTaskId - the currently selected task id (may be undefined).
183
+ * @param id - the task to remove.
184
+ */
185
+ function applyDeleteTask(tasks, selectedTaskId, id) {
186
+ return {
187
+ tasks: tasks.filter((task) => task.id !== id),
188
+ selectionCleared: selectedTaskId === id
189
+ };
190
+ }
191
+ //#endregion
192
+ //#region src/core/schedule.ts
193
+ /** Inclusive ranges per field, in cron order. */
194
+ const FIELD_RANGES = [
195
+ [0, 59],
196
+ [0, 23],
197
+ [1, 31],
198
+ [1, 12],
199
+ [0, 7]
200
+ ];
201
+ /**
202
+ * Parse a 5-field cron expression.
203
+ * @returns the match sets, or null when the expression is invalid.
204
+ */
205
+ function parseCron(expr) {
206
+ const fields = expr.trim().split(/\s+/);
207
+ if (fields.length !== 5) return null;
208
+ const sets = [];
209
+ for (let index = 0; index < 5; index++) {
210
+ const [min, max] = FIELD_RANGES[index];
211
+ const set = /* @__PURE__ */ new Set();
212
+ if (!parseField(fields[index], min, max, set)) return null;
213
+ sets.push(set);
214
+ }
215
+ const weekdays = /* @__PURE__ */ new Set();
216
+ for (const day of sets[4]) weekdays.add(day === 7 ? 0 : day);
217
+ return {
218
+ minutes: sets[0],
219
+ hours: sets[1],
220
+ days: sets[2],
221
+ months: sets[3],
222
+ weekdays,
223
+ dayWildcard: fields[2] === "*",
224
+ weekdayWildcard: fields[4] === "*"
225
+ };
226
+ }
227
+ /** Whether the expression parses. */
228
+ function isValidCron(expr) {
229
+ return parseCron(expr) !== null;
230
+ }
231
+ /**
232
+ * Compute the next matching instant after `fromMs` (ms epoch), in local time,
233
+ * at minute granularity, strictly greater than `fromMs`. Returns the ms epoch
234
+ * of the matching minute's start, or undefined when nothing matches within
235
+ * 366 days (e.g. `0 0 30 2 *`).
236
+ */
237
+ function nextRunAtMs(expr, fromMs) {
238
+ const schedule = parseCron(expr);
239
+ if (schedule === null) return void 0;
240
+ const from = new Date(fromMs);
241
+ const scan = new Date(from.getFullYear(), from.getMonth(), from.getDate(), from.getHours(), from.getMinutes() + 1, 0, 0);
242
+ const limitMs = fromMs + 366 * 24 * 60 * 60 * 1e3;
243
+ while (scan.getTime() <= limitMs) {
244
+ if (matches(schedule, scan)) return scan.getTime();
245
+ scan.setMinutes(scan.getMinutes() + 1);
246
+ }
247
+ }
248
+ /** Parse one comma-list field into the match set. */
249
+ function parseField(field, min, max, out) {
250
+ if (field === "*") {
251
+ for (let value = min; value <= max; value++) out.add(value);
252
+ return true;
253
+ }
254
+ for (const part of field.split(",")) {
255
+ if (part === "") return false;
256
+ const [range, stepRaw] = part.split("/");
257
+ let low;
258
+ let high;
259
+ if (range === "*") {
260
+ low = min;
261
+ high = max;
262
+ } else if (range.includes("-")) {
263
+ const [a, b] = range.split("-");
264
+ if (a === "" || b === "" || !isDigits(a) || !isDigits(b)) return false;
265
+ low = Number(a);
266
+ high = Number(b);
267
+ } else if (isDigits(range)) {
268
+ low = Number(range);
269
+ high = Number(range);
270
+ } else return false;
271
+ if (low < min || high > max || low > high) return false;
272
+ const step = stepRaw === void 0 ? 1 : isDigits(stepRaw) ? Number(stepRaw) : NaN;
273
+ if (!Number.isInteger(step) || step < 1) return false;
274
+ for (let value = low; value <= high; value += step) out.add(value);
275
+ }
276
+ return true;
277
+ }
278
+ /** Day/weekday OR semantics: a restricted day field alone gates, and vice versa. */
279
+ function matches(schedule, date) {
280
+ if (!schedule.minutes.has(date.getMinutes())) return false;
281
+ if (!schedule.hours.has(date.getHours())) return false;
282
+ if (!schedule.months.has(date.getMonth() + 1)) return false;
283
+ const dayMatches = schedule.days.has(date.getDate());
284
+ const weekdayMatches = schedule.weekdays.has(date.getDay());
285
+ if (schedule.dayWildcard) return weekdayMatches;
286
+ if (schedule.weekdayWildcard) return dayMatches;
287
+ return dayMatches || weekdayMatches;
288
+ }
289
+ function isDigits(value) {
290
+ return /^\d+$/.test(value);
291
+ }
292
+ //#endregion
293
+ //#region src/core/use-cases/task-schedule.ts
294
+ /**
295
+ * Schedule use case: arm/disarm a task's cron rule and roll a rule forward.
296
+ * Pure ledger transitions (no persistence or notify — the controller
297
+ * orchestrates those). Validation and next-run computation live here, sharing
298
+ * the core cron parser (schedule.ts) and the withSchedule transition.
299
+ */
300
+ /**
301
+ * Set a task's schedule rule. A blank or invalid cron is rejected (state
302
+ * untouched); an enabled rule computes the next run instant immediately, a
303
+ * disabled or invalid one carries no next-run instant.
304
+ * @param tasks - current ledger.
305
+ * @param id - the task to schedule.
306
+ * @param patch - rule fields to change (absent fields keep their current value).
307
+ * @param now - clock instant (ms epoch).
308
+ */
309
+ function applySetSchedule(tasks, id, patch, now) {
310
+ const task = tasks.find((candidate) => candidate.id === id);
311
+ if (task === void 0) return {
312
+ tasks,
313
+ applied: false
314
+ };
315
+ const current = task.schedule;
316
+ const cron = (patch.cron ?? current?.cron ?? "").trim();
317
+ if (cron === "" || !isValidCron(cron)) return {
318
+ tasks,
319
+ applied: false
320
+ };
321
+ const enabled = patch.enabled ?? current?.enabled ?? false;
322
+ const nextRunAt = enabled ? nextRunAtMs(cron, now) : void 0;
323
+ return {
324
+ tasks: tasks.map((candidate) => candidate.id === id ? withSchedule(candidate, {
325
+ enabled,
326
+ cron,
327
+ nextRunAt
328
+ }, now) : candidate),
329
+ applied: true
330
+ };
331
+ }
332
+ /**
333
+ * Roll a task's schedule rule forward (scheduler callback): persist the next
334
+ * due instant and the trigger instant. No-op for tasks without a rule (deleted
335
+ * mid-tick, for example).
336
+ * @param tasks - current ledger.
337
+ * @param id - the task to roll forward.
338
+ * @param nextRunAt - next due instant (may be undefined to clear).
339
+ * @param lastTriggeredAt - the trigger instant of this run.
340
+ * @param now - clock instant (ms epoch).
341
+ */
342
+ function applyScheduleNextRun(tasks, id, nextRunAt, lastTriggeredAt, now) {
343
+ return tasks.map((task) => task.id === id && task.schedule !== void 0 ? withSchedule(task, {
344
+ nextRunAt,
345
+ lastTriggeredAt
346
+ }, now) : task);
347
+ }
348
+ //#endregion
349
+ //#region src/core/use-cases/task-update.ts
350
+ /**
351
+ * Apply an update across the ledger. Tasks that do not match the id are left
352
+ * untouched; the matched task receives the patch plus a fresh updatedAt.
353
+ * @param tasks - current ledger.
354
+ * @param id - the task to update.
355
+ * @param patch - editable-field changes.
356
+ * @param now - clock instant (ms epoch).
357
+ */
358
+ function applyUpdateTask(tasks, id, patch, now) {
359
+ return tasks.map((task) => task.id === id ? {
360
+ ...task,
361
+ ...patch,
362
+ updatedAt: now
363
+ } : task);
364
+ }
365
+ //#endregion
252
366
  //#region src/core/controller.ts
253
367
  /** The selected task (resolved from the ledger), or undefined. */
254
368
  function selectedTaskOf(snapshot) {
@@ -347,18 +461,14 @@ window.__ModuleLoader__.load({
347
461
  this.notify();
348
462
  }
349
463
  createTask(input) {
350
- if (input.title.trim() === "") return void 0;
351
- const task = createTask(input, this.now(), this.uuid());
352
- this.tasks = [...this.tasks, task];
464
+ const { task, tasks } = applyCreateTask(this.tasks, input, this.now(), this.uuid());
465
+ if (task === void 0) return void 0;
466
+ this.tasks = [...tasks];
353
467
  this.persistAndNotify();
354
468
  return task;
355
469
  }
356
470
  updateTask(id, patch) {
357
- this.tasks = this.tasks.map((task) => task.id === id ? {
358
- ...task,
359
- ...patch,
360
- updatedAt: this.now()
361
- } : task);
471
+ this.tasks = [...applyUpdateTask(this.tasks, id, patch, this.now())];
362
472
  this.persistAndNotify();
363
473
  }
364
474
  moveTask(id, status) {
@@ -366,32 +476,24 @@ window.__ModuleLoader__.load({
366
476
  this.persistAndNotify();
367
477
  }
368
478
  deleteTask(id) {
369
- this.tasks = this.tasks.filter((task) => task.id !== id);
370
- if (this.selectedTaskId === id) this.selectedTaskId = void 0;
479
+ const { tasks, selectionCleared } = applyDeleteTask(this.tasks, this.selectedTaskId, id);
480
+ this.tasks = [...tasks];
481
+ if (selectionCleared) this.selectedTaskId = void 0;
371
482
  this.persistAndNotify();
372
483
  }
373
484
  /**
374
485
  * Update a task's schedule rule. A blank or invalid cron expression is
375
486
  * rejected (returns false, state untouched). When the rule ends up enabled
376
487
  * the next run instant is computed immediately; a disabled rule carries no
377
- * next-run instant.
488
+ * next-run instant. Delegates the domain transition to the schedule use case.
378
489
  * @param id - the task to schedule.
379
490
  * @param patch - fields to change (absent fields keep their current value).
380
491
  * @returns true when applied, false when rejected (invalid cron / unknown task).
381
492
  */
382
493
  setSchedule(id, patch) {
383
- const task = this.tasks.find((candidate) => candidate.id === id);
384
- if (task === void 0) return false;
385
- const current = task.schedule;
386
- const cron = (patch.cron ?? current?.cron ?? "").trim();
387
- if (cron === "" || !isValidCron(cron)) return false;
388
- const enabled = patch.enabled ?? current?.enabled ?? false;
389
- const nextRunAt = enabled ? nextRunAtMs(cron, this.now()) : void 0;
390
- this.tasks = this.tasks.map((candidate) => candidate.id === id ? withSchedule(candidate, {
391
- enabled,
392
- cron,
393
- nextRunAt
394
- }, this.now()) : candidate);
494
+ const { tasks, applied } = applySetSchedule(this.tasks, id, patch, this.now());
495
+ if (!applied) return false;
496
+ this.tasks = [...tasks];
395
497
  this.persistAndNotify();
396
498
  return true;
397
499
  }
@@ -401,10 +503,8 @@ window.__ModuleLoader__.load({
401
503
  * schedule rule (it was deleted mid-tick, for example).
402
504
  */
403
505
  applyScheduleNextRun(id, nextRunAt, lastTriggeredAt) {
404
- this.tasks = this.tasks.map((task) => task.id === id && task.schedule !== void 0 ? withSchedule(task, {
405
- nextRunAt,
406
- lastTriggeredAt
407
- }, this.now()) : task);
506
+ const next = applyScheduleNextRun(this.tasks, id, nextRunAt, lastTriggeredAt, this.now());
507
+ this.tasks = [...next];
408
508
  this.persistAndNotify();
409
509
  }
410
510
  /**
@@ -736,6 +836,12 @@ window.__ModuleLoader__.load({
736
836
  * a task still running at its due instant is skipped by the controller's
737
837
  * runTask guard and simply waits for the next cron match.
738
838
  *
839
+ * The ticker is controlled: `start` arms a single interval guarded by an
840
+ * idempotence check (a second start while running is a no-op, so wiring can
841
+ * never leak a duplicate timer), and `stop`/`dispose` clear the timer and
842
+ * drop the environment listeners. The board unmount path calls stop/dispose,
843
+ * so no interval or listener outlives the board.
844
+ *
739
845
  * Framework-free: all runtime access flows through the injected deps
740
846
  * (structural faces), so tests drive ticks directly without timers.
741
847
  */
@@ -746,26 +852,54 @@ window.__ModuleLoader__.load({
746
852
  var SchedulerService = class {
747
853
  deps;
748
854
  timer;
855
+ environmentListener;
749
856
  disposed = false;
857
+ started = false;
750
858
  /** @param deps - tasks/clock/trigger/apply faces (see {@link SchedulerDeps}). */
751
859
  constructor(deps) {
752
860
  this.deps = deps;
753
861
  }
754
- /** Start ticking: one immediate check (catch-up after reload) + the interval. */
862
+ /**
863
+ * Start ticking: one immediate check (catch-up after reload) + the interval.
864
+ * Single-instance: a second start while already armed is a no-op, so a
865
+ * re-entrant mount can never stack a duplicate timer.
866
+ */
755
867
  start() {
756
868
  if (this.disposed) return;
869
+ if (this.started) return;
870
+ this.started = true;
757
871
  this.tick();
758
872
  this.timer = setInterval(() => {
759
873
  this.tick();
760
874
  }, this.deps.tickMs ?? 6e4);
761
- this.deps.environment?.addEventListener("visibilitychange", this.onVisibility);
875
+ if (this.deps.environment !== void 0) {
876
+ this.environmentListener = () => {
877
+ this.tick();
878
+ };
879
+ this.deps.environment.addEventListener("visibilitychange", this.environmentListener);
880
+ }
881
+ }
882
+ /**
883
+ * Stop ticking and drop listeners (idempotent). Preferred shutdown verb for
884
+ * callers that treat the scheduler as a controlled ticker; `dispose` is an
885
+ * alias.
886
+ */
887
+ stop() {
888
+ this.dispose();
762
889
  }
763
890
  /** Stop ticking and drop listeners (idempotent). */
764
891
  dispose() {
892
+ if (this.disposed && this.timer === void 0 && this.environmentListener === void 0) return;
765
893
  this.disposed = true;
766
- if (this.timer !== void 0) clearInterval(this.timer);
767
- this.timer = void 0;
768
- this.deps.environment?.removeEventListener("visibilitychange", this.onVisibility);
894
+ this.started = false;
895
+ if (this.timer !== void 0) {
896
+ clearInterval(this.timer);
897
+ this.timer = void 0;
898
+ }
899
+ if (this.environmentListener !== void 0 && this.deps.environment !== void 0) {
900
+ this.deps.environment.removeEventListener("visibilitychange", this.environmentListener);
901
+ this.environmentListener = void 0;
902
+ }
769
903
  }
770
904
  /**
771
905
  * Check every enabled schedule and trigger the due ones. Idempotent per
@@ -791,9 +925,6 @@ window.__ModuleLoader__.load({
791
925
  if (await this.deps.runTask(task.id)) this.deps.applySchedule(task.id, next, now);
792
926
  }
793
927
  }
794
- onVisibility = () => {
795
- this.tick();
796
- };
797
928
  };
798
929
  //#endregion
799
930
  //#region src/core/store.ts
@@ -953,6 +1084,23 @@ window.__ModuleLoader__.load({
953
1084
  }
954
1085
  };
955
1086
  //#endregion
1087
+ //#region src/client/apply-guard.ts
1088
+ /** Claims the plugin apply slot. Returns true when this call won the slot. */
1089
+ function claimTaskboardApply() {
1090
+ if (globalThis.__dshTaskboardApplied === true) return false;
1091
+ globalThis.__dshTaskboardApplied = true;
1092
+ return true;
1093
+ }
1094
+ /**
1095
+ * Releases the claim. Called from the client fiber cleanup so that a
1096
+ * hot-reloaded bundle (the loader unloads the old plugin fiber and invokes
1097
+ * the rebuilt one in the same page) can claim again instead of being
1098
+ * silently dropped.
1099
+ */
1100
+ function releaseTaskboardApply() {
1101
+ globalThis.__dshTaskboardApplied = void 0;
1102
+ }
1103
+ //#endregion
956
1104
  //#region src/client/locales.ts
957
1105
  /**
958
1106
  * Task-board copy: zh-first dictionaries with an English fallback, selected
@@ -1335,6 +1483,15 @@ window.__ModuleLoader__.load({
1335
1483
  }
1336
1484
  //#endregion
1337
1485
  //#region src/client/board/TaskCard.tsx
1486
+ /**
1487
+ * Task card: the board's column item. Clicking opens the task detail — it
1488
+ * never executes anything directly (detail holds the Run button).
1489
+ *
1490
+ * Memoized: the card re-renders only when its own task record changes, so a
1491
+ * status/filter update on one card (or scrolling) never re-renders every
1492
+ * card on the board. The per-card onClick is built with a stable task reference
1493
+ * by the board, so the memo boundary is effective.
1494
+ */
1338
1495
  /** Compact relative/absolute time label. */
1339
1496
  function formatTime(ms) {
1340
1497
  const date = new Date(ms);
@@ -1345,7 +1502,7 @@ window.__ModuleLoader__.load({
1345
1502
  return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
1346
1503
  }
1347
1504
  /** One card in a column. */
1348
- function TaskCard({ task, onClick }) {
1505
+ function TaskCardInner({ task, onClick }) {
1349
1506
  const latest = task.executions[task.executions.length - 1];
1350
1507
  const runs = task.executions.length;
1351
1508
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
@@ -1406,6 +1563,8 @@ window.__ModuleLoader__.load({
1406
1563
  ]
1407
1564
  });
1408
1565
  }
1566
+ /** Memoized card: re-renders only when the card's own task record changes. */
1567
+ const TaskCard = (0, react.memo)(TaskCardInner);
1409
1568
  //#endregion
1410
1569
  //#region src/client/board/ConfirmDialog.tsx
1411
1570
  /**
@@ -1809,6 +1968,20 @@ window.__ModuleLoader__.load({
1809
1968
  const needle = filter.trim().toLowerCase();
1810
1969
  return task.title.toLowerCase().includes(needle) || task.description.toLowerCase().includes(needle);
1811
1970
  }
1971
+ /**
1972
+ * Memoized per-card adapter: with a stable `onOpen` from the board and an
1973
+ * immutable task record (only the changed card gets a new object ref), a card
1974
+ * re-renders only when its own task changes — not when a sibling card status,
1975
+ * the filter, or the selection moves.
1976
+ */
1977
+ const MemoTaskCard = (0, react.memo)(function MemoTaskCard({ task, onOpen }) {
1978
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TaskCard, {
1979
+ task,
1980
+ onClick: (0, react.useCallback)(() => {
1981
+ onOpen(task.id);
1982
+ }, [task.id, onOpen])
1983
+ });
1984
+ });
1812
1985
  /** Board component; subscribes to the controller snapshot. */
1813
1986
  function TaskBoard({ controller }) {
1814
1987
  const [snapshot, setSnapshot] = (0, react.useState)(controller.getSnapshot());
@@ -1817,6 +1990,9 @@ window.__ModuleLoader__.load({
1817
1990
  const [showNew, setShowNew] = (0, react.useState)(false);
1818
1991
  const selected = selectedTaskOf(snapshot);
1819
1992
  const visible = snapshot.tasks.filter((task) => matchesFilter(task, filter));
1993
+ const openTask = (0, react.useCallback)((id) => {
1994
+ controller.openTask(id);
1995
+ }, [controller]);
1820
1996
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1821
1997
  className: board_module_css_default.board,
1822
1998
  "data-dsh-taskboard-board": "",
@@ -1882,11 +2058,9 @@ window.__ModuleLoader__.load({
1882
2058
  ]
1883
2059
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1884
2060
  className: board_module_css_default.cards,
1885
- children: [tasks.map((task) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TaskCard, {
2061
+ children: [tasks.map((task) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MemoTaskCard, {
1886
2062
  task,
1887
- onClick: () => {
1888
- controller.openTask(task.id);
1889
- }
2063
+ onOpen: openTask
1890
2064
  }, task.id)), tasks.length === 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1891
2065
  className: board_module_css_default.columnEmpty,
1892
2066
  children: t("board.empty")
@@ -2045,6 +2219,7 @@ window.__ModuleLoader__.load({
2045
2219
  * @returns disposer removing the entry and its observers.
2046
2220
  */
2047
2221
  function mountSidebarEntry(controller) {
2222
+ if (typeof document !== "undefined" && document.querySelector("[data-dsh-taskboard-entry]") !== null) return () => {};
2048
2223
  const entry = createEntry(controller);
2049
2224
  let root;
2050
2225
  let placed = false;
@@ -2099,7 +2274,7 @@ window.__ModuleLoader__.load({
2099
2274
  }
2100
2275
  //#endregion
2101
2276
  //#region \0dsh-css:packages/dsh-task-board/src/client/settings-card.module.css.mjs
2102
- 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_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);flex:1;margin:0;font-size:12px;line-height:1.5}.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}.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}}";
2277
+ 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_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);flex:1;margin:0;font-size:12px;line-height:1.5}.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}}";
2103
2278
  const tagId = "@linxin666/dsh-client-ui-task-board/settings-card.module.css";
2104
2279
  if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
2105
2280
  const tag = document.createElement("style");
@@ -2140,12 +2315,12 @@ window.__ModuleLoader__.load({
2140
2315
  //#endregion
2141
2316
  //#region src/client/PluginSettingsCard.tsx
2142
2317
  /**
2143
- * Shared chrome for the plugin settings card: a disclosure header naming the
2144
- * plugin and what its settings govern, the controls inside, and the save that
2145
- * writes them. Renders nothing while the namespace is unavailable — a
2318
+ * Family-shared chrome for plugin settings cards: a disclosure header naming
2319
+ * the plugin and what its settings govern, the controls inside, and the save
2320
+ * that writes them. Renders nothing while the namespace is unavailable — a
2146
2321
  * deployment that does not compose the owning plugin should show no trace of
2147
- * it. Mirrors the official ui-plugin-config PluginCard in a self-contained
2148
- * slice (this package must not depend on a sibling UI package).
2322
+ * it. Inlined into each consumer's client bundle; mirrors the official
2323
+ * ui-plugin-config PluginCard in a self-contained slice.
2149
2324
  */
2150
2325
  /**
2151
2326
  * Render one plugin settings card.
@@ -2157,6 +2332,7 @@ window.__ModuleLoader__.load({
2157
2332
  const { state } = props;
2158
2333
  if (!state.available) return null;
2159
2334
  const title = props.t(props.titleKey);
2335
+ const description = props.t(props.descriptionKey);
2160
2336
  const blocked = !state.dirty || state.invalid || state.saving;
2161
2337
  const cardClass = open ? `${settings_card_module_css_default.cardOpen} ${settings_card_module_css_default.card}` : settings_card_module_css_default.card;
2162
2338
  if (!state.exposed) return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
@@ -2173,10 +2349,12 @@ window.__ModuleLoader__.load({
2173
2349
  className: settings_card_module_css_default.headText,
2174
2350
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2175
2351
  className: settings_card_module_css_default.name,
2352
+ title,
2176
2353
  children: title
2177
2354
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2178
2355
  className: settings_card_module_css_default.description,
2179
- children: props.t(props.descriptionKey)
2356
+ title: description,
2357
+ children: description
2180
2358
  })]
2181
2359
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
2182
2360
  width: "14",
@@ -2214,14 +2392,17 @@ window.__ModuleLoader__.load({
2214
2392
  className: settings_card_module_css_default.headText,
2215
2393
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2216
2394
  className: settings_card_module_css_default.name,
2395
+ title,
2217
2396
  children: title
2218
2397
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2219
2398
  className: settings_card_module_css_default.description,
2220
- children: props.t(props.descriptionKey)
2399
+ title: description,
2400
+ children: description
2221
2401
  })]
2222
2402
  }),
2223
2403
  state.dirty ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2224
2404
  className: settings_card_module_css_default.pending,
2405
+ title: props.t("settings.unsaved"),
2225
2406
  children: props.t("settings.unsaved")
2226
2407
  }) : null,
2227
2408
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
@@ -2337,11 +2518,13 @@ window.__ModuleLoader__.load({
2337
2518
  field,
2338
2519
  format: (value) => typeof value === "boolean" ? String(value) : "",
2339
2520
  parse: (text) => {
2340
- if (text === "true") return {
2521
+ const trimmed = text.trim();
2522
+ if (trimmed === "") return { kind: "clear" };
2523
+ if (trimmed === "true") return {
2341
2524
  kind: "set",
2342
2525
  value: true
2343
2526
  };
2344
- if (text === "false") return {
2527
+ if (trimmed === "false") return {
2345
2528
  kind: "set",
2346
2529
  value: false
2347
2530
  };
@@ -2443,12 +2626,13 @@ window.__ModuleLoader__.load({
2443
2626
  const plan = this.plan();
2444
2627
  const writes = plan.flatMap((item) => item.run === void 0 ? [] : [item.run]);
2445
2628
  if (plan.length === 0 || this.saving || writes.length !== plan.length) return;
2629
+ const fields = new Set(plan.map((item) => item.field));
2446
2630
  this.saving = true;
2447
2631
  this.failed = false;
2448
2632
  this.publish();
2449
2633
  let landed = true;
2450
2634
  for (const write of writes) landed = await write() && landed;
2451
- if (landed) this.staged.clear();
2635
+ if (landed) for (const field of fields) this.staged.delete(field);
2452
2636
  this.saving = false;
2453
2637
  this.failed = !landed;
2454
2638
  this.publish();
@@ -2631,6 +2815,8 @@ window.__ModuleLoader__.load({
2631
2815
  * @param ctx - client root context (services: sessions, workspaces).
2632
2816
  */
2633
2817
  function apply(ctx) {
2818
+ if (!claimTaskboardApply()) return;
2819
+ ctx.effect(() => releaseTaskboardApply, "task-board: apply claim");
2634
2820
  ctx.effect(() => ctx.locale.register(NS, {
2635
2821
  zh,
2636
2822
  en