@linxin666/dsh-client-ui-task-board 0.1.14 → 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
@@ -1352,6 +1483,15 @@ window.__ModuleLoader__.load({
1352
1483
  }
1353
1484
  //#endregion
1354
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
+ */
1355
1495
  /** Compact relative/absolute time label. */
1356
1496
  function formatTime(ms) {
1357
1497
  const date = new Date(ms);
@@ -1362,7 +1502,7 @@ window.__ModuleLoader__.load({
1362
1502
  return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
1363
1503
  }
1364
1504
  /** One card in a column. */
1365
- function TaskCard({ task, onClick }) {
1505
+ function TaskCardInner({ task, onClick }) {
1366
1506
  const latest = task.executions[task.executions.length - 1];
1367
1507
  const runs = task.executions.length;
1368
1508
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
@@ -1423,6 +1563,8 @@ window.__ModuleLoader__.load({
1423
1563
  ]
1424
1564
  });
1425
1565
  }
1566
+ /** Memoized card: re-renders only when the card's own task record changes. */
1567
+ const TaskCard = (0, react.memo)(TaskCardInner);
1426
1568
  //#endregion
1427
1569
  //#region src/client/board/ConfirmDialog.tsx
1428
1570
  /**
@@ -1826,6 +1968,20 @@ window.__ModuleLoader__.load({
1826
1968
  const needle = filter.trim().toLowerCase();
1827
1969
  return task.title.toLowerCase().includes(needle) || task.description.toLowerCase().includes(needle);
1828
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
+ });
1829
1985
  /** Board component; subscribes to the controller snapshot. */
1830
1986
  function TaskBoard({ controller }) {
1831
1987
  const [snapshot, setSnapshot] = (0, react.useState)(controller.getSnapshot());
@@ -1834,6 +1990,9 @@ window.__ModuleLoader__.load({
1834
1990
  const [showNew, setShowNew] = (0, react.useState)(false);
1835
1991
  const selected = selectedTaskOf(snapshot);
1836
1992
  const visible = snapshot.tasks.filter((task) => matchesFilter(task, filter));
1993
+ const openTask = (0, react.useCallback)((id) => {
1994
+ controller.openTask(id);
1995
+ }, [controller]);
1837
1996
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1838
1997
  className: board_module_css_default.board,
1839
1998
  "data-dsh-taskboard-board": "",
@@ -1899,11 +2058,9 @@ window.__ModuleLoader__.load({
1899
2058
  ]
1900
2059
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1901
2060
  className: board_module_css_default.cards,
1902
- children: [tasks.map((task) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TaskCard, {
2061
+ children: [tasks.map((task) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MemoTaskCard, {
1903
2062
  task,
1904
- onClick: () => {
1905
- controller.openTask(task.id);
1906
- }
2063
+ onOpen: openTask
1907
2064
  }, task.id)), tasks.length === 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1908
2065
  className: board_module_css_default.columnEmpty,
1909
2066
  children: t("board.empty")
@@ -2117,7 +2274,7 @@ window.__ModuleLoader__.load({
2117
2274
  }
2118
2275
  //#endregion
2119
2276
  //#region \0dsh-css:packages/dsh-task-board/src/client/settings-card.module.css.mjs
2120
- 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}}";
2121
2278
  const tagId = "@linxin666/dsh-client-ui-task-board/settings-card.module.css";
2122
2279
  if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
2123
2280
  const tag = document.createElement("style");
@@ -2158,12 +2315,12 @@ window.__ModuleLoader__.load({
2158
2315
  //#endregion
2159
2316
  //#region src/client/PluginSettingsCard.tsx
2160
2317
  /**
2161
- * Shared chrome for the plugin settings card: a disclosure header naming the
2162
- * plugin and what its settings govern, the controls inside, and the save that
2163
- * 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
2164
2321
  * deployment that does not compose the owning plugin should show no trace of
2165
- * it. Mirrors the official ui-plugin-config PluginCard in a self-contained
2166
- * 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.
2167
2324
  */
2168
2325
  /**
2169
2326
  * Render one plugin settings card.
@@ -2175,6 +2332,7 @@ window.__ModuleLoader__.load({
2175
2332
  const { state } = props;
2176
2333
  if (!state.available) return null;
2177
2334
  const title = props.t(props.titleKey);
2335
+ const description = props.t(props.descriptionKey);
2178
2336
  const blocked = !state.dirty || state.invalid || state.saving;
2179
2337
  const cardClass = open ? `${settings_card_module_css_default.cardOpen} ${settings_card_module_css_default.card}` : settings_card_module_css_default.card;
2180
2338
  if (!state.exposed) return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
@@ -2191,10 +2349,12 @@ window.__ModuleLoader__.load({
2191
2349
  className: settings_card_module_css_default.headText,
2192
2350
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2193
2351
  className: settings_card_module_css_default.name,
2352
+ title,
2194
2353
  children: title
2195
2354
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2196
2355
  className: settings_card_module_css_default.description,
2197
- children: props.t(props.descriptionKey)
2356
+ title: description,
2357
+ children: description
2198
2358
  })]
2199
2359
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
2200
2360
  width: "14",
@@ -2232,14 +2392,17 @@ window.__ModuleLoader__.load({
2232
2392
  className: settings_card_module_css_default.headText,
2233
2393
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2234
2394
  className: settings_card_module_css_default.name,
2395
+ title,
2235
2396
  children: title
2236
2397
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2237
2398
  className: settings_card_module_css_default.description,
2238
- children: props.t(props.descriptionKey)
2399
+ title: description,
2400
+ children: description
2239
2401
  })]
2240
2402
  }),
2241
2403
  state.dirty ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2242
2404
  className: settings_card_module_css_default.pending,
2405
+ title: props.t("settings.unsaved"),
2243
2406
  children: props.t("settings.unsaved")
2244
2407
  }) : null,
2245
2408
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
@@ -2355,11 +2518,13 @@ window.__ModuleLoader__.load({
2355
2518
  field,
2356
2519
  format: (value) => typeof value === "boolean" ? String(value) : "",
2357
2520
  parse: (text) => {
2358
- if (text === "true") return {
2521
+ const trimmed = text.trim();
2522
+ if (trimmed === "") return { kind: "clear" };
2523
+ if (trimmed === "true") return {
2359
2524
  kind: "set",
2360
2525
  value: true
2361
2526
  };
2362
- if (text === "false") return {
2527
+ if (trimmed === "false") return {
2363
2528
  kind: "set",
2364
2529
  value: false
2365
2530
  };
@@ -2461,12 +2626,13 @@ window.__ModuleLoader__.load({
2461
2626
  const plan = this.plan();
2462
2627
  const writes = plan.flatMap((item) => item.run === void 0 ? [] : [item.run]);
2463
2628
  if (plan.length === 0 || this.saving || writes.length !== plan.length) return;
2629
+ const fields = new Set(plan.map((item) => item.field));
2464
2630
  this.saving = true;
2465
2631
  this.failed = false;
2466
2632
  this.publish();
2467
2633
  let landed = true;
2468
2634
  for (const write of writes) landed = await write() && landed;
2469
- if (landed) this.staged.clear();
2635
+ if (landed) for (const field of fields) this.staged.delete(field);
2470
2636
  this.saving = false;
2471
2637
  this.failed = !landed;
2472
2638
  this.publish();