@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.
@@ -1,22 +1,27 @@
1
1
  /**
2
- * Shared chrome for the plugin settings card: a disclosure header naming the
3
- * plugin and what its settings govern, the controls inside, and the save that
4
- * writes them. Renders nothing while the namespace is unavailable — a
2
+ * Family-shared chrome for plugin settings cards: a disclosure header naming
3
+ * the plugin and what its settings govern, the controls inside, and the save
4
+ * that writes them. Renders nothing while the namespace is unavailable — a
5
5
  * deployment that does not compose the owning plugin should show no trace of
6
- * it. Mirrors the official ui-plugin-config PluginCard in a self-contained
7
- * slice (this package must not depend on a sibling UI package).
6
+ * it. Inlined into each consumer's client bundle; mirrors the official
7
+ * ui-plugin-config PluginCard in a self-contained slice.
8
8
  */
9
9
  import { type ReactNode } from 'react';
10
10
  import type { CardShell } from './settings-form.ts';
11
- import type { SettingsCardKey } from './locales.ts';
11
+ /** Copy keys the card chrome itself reads; every consumer locale carries this shared vocabulary. */
12
+ export declare const CARD_COPY_KEYS: readonly ["settings.collapse", "settings.expand", "settings.notExposed", "settings.unsaved", "settings.readOnly", "settings.saveFailed", "settings.discard", "settings.save", "settings.saving"];
13
+ /** Copy key the card chrome itself reads. */
14
+ export type CardCopyKey = (typeof CARD_COPY_KEYS)[number];
15
+ /** Key domain for the plugin's own copy (inferred per consumer). */
16
+ export type SettingsCardKey<TKey extends string = string> = TKey | CardCopyKey;
12
17
  /** Card chrome shared by every plugin settings card. */
13
- export interface PluginSettingsCardProps {
18
+ export interface PluginSettingsCardProps<TKey extends string = string> {
14
19
  /** Locale reader for this card's copy. */
15
- t: (key: SettingsCardKey) => string;
20
+ t: (key: SettingsCardKey<TKey>, params?: Record<string, string | number>) => string;
16
21
  /** Locale key of the plugin's name. */
17
- titleKey: SettingsCardKey;
22
+ titleKey: TKey;
18
23
  /** Locale key of the line describing what this plugin's settings govern. */
19
- descriptionKey: SettingsCardKey;
24
+ descriptionKey: TKey;
20
25
  /** The card's form state: availability, writability, and what a save would do. */
21
26
  state: CardShell;
22
27
  /** Write every staged edit. */
@@ -31,7 +36,7 @@ export interface PluginSettingsCardProps {
31
36
  * @param props - the plugin's copy keys, its form state, and its controls.
32
37
  * @returns the card, or nothing while the namespace is still loading.
33
38
  */
34
- export declare function PluginSettingsCard(props: PluginSettingsCardProps): import("react").JSX.Element | null;
39
+ export declare function PluginSettingsCard<TKey extends string = string>(props: PluginSettingsCardProps<TKey>): import("react").JSX.Element | null;
35
40
  /** Props every field control needs regardless of its value type. */
36
41
  export interface FieldProps {
37
42
  /** Stable id associating the label with its control. */
@@ -75,3 +80,13 @@ export declare function BooleanField(props: FieldProps & {
75
80
  /** Copy for the off option. */
76
81
  offLabel: string;
77
82
  }): import("react").JSX.Element;
83
+ /** A staged enumerated field rendered as a select. */
84
+ export declare function ChoiceField(props: FieldProps & {
85
+ /** Copy for the inherit option (draft text is the empty string). */
86
+ inheritLabel: string;
87
+ /** Choices rendered in order; `value` is the draft/stored text. */
88
+ choices: ReadonlyArray<{
89
+ value: string;
90
+ label: string;
91
+ }>;
92
+ }): import("react").JSX.Element;
@@ -1,12 +1,11 @@
1
- /**
2
- * Task card: the board's column item. Clicking opens the task detail — it
3
- * never executes anything directly (detail holds the Run button).
4
- */
5
1
  import type { TaskRecord } from '../../core/tasks.ts';
6
2
  /** Compact relative/absolute time label. */
7
3
  export declare function formatTime(ms: number): string;
8
4
  /** One card in a column. */
9
- export declare function TaskCard({ task, onClick }: {
5
+ declare function TaskCardInner({ task, onClick }: {
10
6
  task: TaskRecord;
11
7
  onClick: () => void;
12
8
  }): import("react").JSX.Element;
9
+ /** Memoized card: re-renders only when the card's own task record changes. */
10
+ export declare const TaskCard: import("react").MemoExoticComponent<typeof TaskCardInner>;
11
+ export {};
@@ -2,9 +2,9 @@
2
2
  * Staged form model behind the plugin settings card. A card stages what the
3
3
  * user types and writes it only when they save — the settings write is a
4
4
  * durable, revision-fenced document mutation, so staging keeps what is on
5
- * screen exactly what a save would store. Mirrors the official
6
- * ui-plugin-config card-store pattern in a self-contained slice: this
7
- * package must not depend on a sibling UI package.
5
+ * screen exactly what a save would store. Family-shared slice inlined into
6
+ * each plugin's client bundle; mirrors the official ui-plugin-config
7
+ * card-store pattern.
8
8
  */
9
9
  import type { SettingsScope, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client';
10
10
  /** The write one field's staged text performs when the card is saved. */
@@ -69,12 +69,21 @@ export interface CardActions {
69
69
  /** Drop every staged edit. */
70
70
  discard: () => void;
71
71
  }
72
- /** A whole-number field. An empty draft clears the field; any other draft that is not a finite number blocks the save. */
73
- export declare function numberField(field: string): FieldSpec;
72
+ /** Constraints a numeric field's accepted drafts must satisfy, mirroring the host schema. */
73
+ export interface NumberConstraints {
74
+ /** The accepted value must be a whole number. */
75
+ integer?: boolean;
76
+ /** The accepted value must be at least this. */
77
+ min?: number;
78
+ }
79
+ /** A whole- or decimal-number field. An empty draft clears the field; any other draft that is not a finite number within the constraints blocks the save. */
80
+ export declare function numberField(field: string, constraints?: NumberConstraints): FieldSpec;
74
81
  /** A free-text field. An empty draft clears the field. */
75
82
  export declare function textField(field: string): FieldSpec;
76
83
  /** A boolean field, edited through true/false draft text. */
77
84
  export declare function booleanField(field: string): FieldSpec;
85
+ /** An enumerated string field; only the listed choices are accepted. An empty draft clears the field. */
86
+ export declare function choiceField(field: string, choices: readonly string[]): FieldSpec;
78
87
  /**
79
88
  * Stages one card's edits over one settings namespace and writes them on save.
80
89
  *
@@ -7,6 +7,11 @@
7
7
  * navigates to a session (the sessions-list `current` selection changes).
8
8
  * Framework-free (structural runtime faces) so the whole orchestration is
9
9
  * unit-testable with fakes.
10
+ *
11
+ * The per use-case domain transitions (create/update/delete/schedule) live in
12
+ * dedicated modules under core/use-cases and are applied here; the controller
13
+ * owns only the orchestration seam (state, persistence, notify, execution,
14
+ * navigation, reconciliation).
10
15
  */
11
16
  import { ExecutionService } from './execution.ts';
12
17
  import type { TaskStore } from './store.ts';
@@ -76,7 +81,7 @@ export declare class BoardController {
76
81
  * Update a task's schedule rule. A blank or invalid cron expression is
77
82
  * rejected (returns false, state untouched). When the rule ends up enabled
78
83
  * the next run instant is computed immediately; a disabled rule carries no
79
- * next-run instant.
84
+ * next-run instant. Delegates the domain transition to the schedule use case.
80
85
  * @param id - the task to schedule.
81
86
  * @param patch - fields to change (absent fields keep their current value).
82
87
  * @returns true when applied, false when rejected (invalid cron / unknown task).
@@ -31,11 +31,23 @@ export interface SchedulerDeps {
31
31
  export declare class SchedulerService {
32
32
  private readonly deps;
33
33
  private timer;
34
+ private environmentListener;
34
35
  private disposed;
36
+ private started;
35
37
  /** @param deps - tasks/clock/trigger/apply faces (see {@link SchedulerDeps}). */
36
38
  constructor(deps: SchedulerDeps);
37
- /** Start ticking: one immediate check (catch-up after reload) + the interval. */
39
+ /**
40
+ * Start ticking: one immediate check (catch-up after reload) + the interval.
41
+ * Single-instance: a second start while already armed is a no-op, so a
42
+ * re-entrant mount can never stack a duplicate timer.
43
+ */
38
44
  start(): void;
45
+ /**
46
+ * Stop ticking and drop listeners (idempotent). Preferred shutdown verb for
47
+ * callers that treat the scheduler as a controlled ticker; `dispose` is an
48
+ * alias.
49
+ */
50
+ stop(): void;
39
51
  /** Stop ticking and drop listeners (idempotent). */
40
52
  dispose(): void;
41
53
  /**
@@ -45,5 +57,4 @@ export declare class SchedulerService {
45
57
  * tick instead of being silently dropped.
46
58
  */
47
59
  tick(): Promise<void>;
48
- private readonly onVisibility;
49
60
  }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Create-task use case: mint a new task from user input, rejecting a blank
3
+ * title. Pure ledger transition (no persistence or notify — the controller
4
+ * orchestrates those), so it is unit-testable without any runtime face.
5
+ */
6
+ import { type NewTaskInput, type TaskRecord } from '../tasks.ts';
7
+ /** Result of a create transition: the new task (when accepted) + the next ledger. */
8
+ export interface CreateTaskResult {
9
+ /** The minted task, or undefined when the input was rejected (blank title). */
10
+ task: TaskRecord | undefined;
11
+ /** The next ledger; identical reference when rejected. */
12
+ tasks: readonly TaskRecord[];
13
+ }
14
+ /**
15
+ * Apply a create against the current ledger. Returns the new task and the
16
+ * appended ledger, or the unchanged ledger when the title is blank.
17
+ * @param tasks - current ledger.
18
+ * @param input - raw user input (title/description/prompt).
19
+ * @param now - clock instant (ms epoch).
20
+ * @param id - minted task id.
21
+ */
22
+ export declare function applyCreateTask(tasks: readonly TaskRecord[], input: NewTaskInput, now: number, id: string): CreateTaskResult;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Delete-task use case: drop a task from the ledger and clear the selection
3
+ * when it pointed at the removed task. Pure ledger transition (no persistence
4
+ * or notify — the controller orchestrates those).
5
+ */
6
+ import type { TaskRecord } from '../tasks.ts';
7
+ /** Result of a delete transition. */
8
+ export interface DeleteTaskResult {
9
+ /** The next ledger (the removed task absent). */
10
+ tasks: readonly TaskRecord[];
11
+ /** Whether the previous selection referenced the removed task. */
12
+ selectionCleared: boolean;
13
+ }
14
+ /**
15
+ * Apply a delete across the ledger. The selection (a task id) is cleared when
16
+ * it matches the removed task, so the UI never lingers on a vanished detail.
17
+ * @param tasks - current ledger.
18
+ * @param selectedTaskId - the currently selected task id (may be undefined).
19
+ * @param id - the task to remove.
20
+ */
21
+ export declare function applyDeleteTask(tasks: readonly TaskRecord[], selectedTaskId: string | undefined, id: string): DeleteTaskResult;
@@ -0,0 +1,34 @@
1
+ import { type TaskRecord } from '../tasks.ts';
2
+ /** Fields the schedule use case may change on a rule. */
3
+ export interface SetSchedulePatch {
4
+ enabled?: boolean;
5
+ cron?: string;
6
+ }
7
+ /** Result of arming/disarming a rule. */
8
+ export interface SetScheduleResult {
9
+ /** The next ledger; unchanged reference when rejected. */
10
+ tasks: readonly TaskRecord[];
11
+ /** Whether the rule was applied (false on unknown task / invalid cron). */
12
+ applied: boolean;
13
+ }
14
+ /**
15
+ * Set a task's schedule rule. A blank or invalid cron is rejected (state
16
+ * untouched); an enabled rule computes the next run instant immediately, a
17
+ * disabled or invalid one carries no next-run instant.
18
+ * @param tasks - current ledger.
19
+ * @param id - the task to schedule.
20
+ * @param patch - rule fields to change (absent fields keep their current value).
21
+ * @param now - clock instant (ms epoch).
22
+ */
23
+ export declare function applySetSchedule(tasks: readonly TaskRecord[], id: string, patch: SetSchedulePatch, now: number): SetScheduleResult;
24
+ /**
25
+ * Roll a task's schedule rule forward (scheduler callback): persist the next
26
+ * due instant and the trigger instant. No-op for tasks without a rule (deleted
27
+ * mid-tick, for example).
28
+ * @param tasks - current ledger.
29
+ * @param id - the task to roll forward.
30
+ * @param nextRunAt - next due instant (may be undefined to clear).
31
+ * @param lastTriggeredAt - the trigger instant of this run.
32
+ * @param now - clock instant (ms epoch).
33
+ */
34
+ export declare function applyScheduleNextRun(tasks: readonly TaskRecord[], id: string, nextRunAt: number | undefined, lastTriggeredAt: number | undefined, now: number): readonly TaskRecord[];
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Update-task use case: apply an editable-field patch (title/description/
3
+ * prompt) with a fresh updatedAt. Pure ledger transition (no persistence or
4
+ * notify — the controller orchestrates those).
5
+ */
6
+ import type { TaskRecord } from '../tasks.ts';
7
+ /** Editable fields on a task (the update patch surface). */
8
+ export type TaskUpdatePatch = Partial<Pick<TaskRecord, 'title' | 'description' | 'prompt'>>;
9
+ /**
10
+ * Apply an update across the ledger. Tasks that do not match the id are left
11
+ * untouched; the matched task receives the patch plus a fresh updatedAt.
12
+ * @param tasks - current ledger.
13
+ * @param id - the task to update.
14
+ * @param patch - editable-field changes.
15
+ * @param now - clock instant (ms epoch).
16
+ */
17
+ export declare function applyUpdateTask(tasks: readonly TaskRecord[], id: string, patch: TaskUpdatePatch, now: number): readonly TaskRecord[];
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@linxin666/dsh-client-ui-task-board",
3
3
  "description": "Task board for the dsh web GUI: a sidebar entry plus a multi-column kanban view with local persistence, task details, and real execution through dsh sessions (session.prompt). Hot-pluggable — mounted via ~/.dsh/cordis.patch.yml + a profile node_modules symlink, no dsh source changes.",
4
- "version": "0.1.14",
4
+ "version": "0.1.15",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/types/index.d.ts",
@@ -1,25 +1,44 @@
1
+ // Generated by scripts/sync-shared.mjs from shared/client/settings/PluginSettingsCard.tsx. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
1
2
  /**
2
- * Shared chrome for the plugin settings card: a disclosure header naming the
3
- * plugin and what its settings govern, the controls inside, and the save that
4
- * writes them. Renders nothing while the namespace is unavailable — a
3
+ * Family-shared chrome for plugin settings cards: a disclosure header naming
4
+ * the plugin and what its settings govern, the controls inside, and the save
5
+ * that writes them. Renders nothing while the namespace is unavailable — a
5
6
  * deployment that does not compose the owning plugin should show no trace of
6
- * it. Mirrors the official ui-plugin-config PluginCard in a self-contained
7
- * slice (this package must not depend on a sibling UI package).
7
+ * it. Inlined into each consumer's client bundle; mirrors the official
8
+ * ui-plugin-config PluginCard in a self-contained slice.
8
9
  */
9
10
 
10
11
  import { useState, type ReactNode } from 'react'
11
12
  import type { CardShell } from './settings-form.ts'
12
- import type { SettingsCardKey } from './locales.ts'
13
13
  import css from './settings-card.module.css'
14
14
 
15
+ /** Copy keys the card chrome itself reads; every consumer locale carries this shared vocabulary. */
16
+ export const CARD_COPY_KEYS = [
17
+ 'settings.collapse',
18
+ 'settings.expand',
19
+ 'settings.notExposed',
20
+ 'settings.unsaved',
21
+ 'settings.readOnly',
22
+ 'settings.saveFailed',
23
+ 'settings.discard',
24
+ 'settings.save',
25
+ 'settings.saving',
26
+ ] as const
27
+
28
+ /** Copy key the card chrome itself reads. */
29
+ export type CardCopyKey = (typeof CARD_COPY_KEYS)[number]
30
+
31
+ /** Key domain for the plugin's own copy (inferred per consumer). */
32
+ export type SettingsCardKey<TKey extends string = string> = TKey | CardCopyKey
33
+
15
34
  /** Card chrome shared by every plugin settings card. */
16
- export interface PluginSettingsCardProps {
35
+ export interface PluginSettingsCardProps<TKey extends string = string> {
17
36
  /** Locale reader for this card's copy. */
18
- t: (key: SettingsCardKey) => string
37
+ t: (key: SettingsCardKey<TKey>, params?: Record<string, string | number>) => string
19
38
  /** Locale key of the plugin's name. */
20
- titleKey: SettingsCardKey
39
+ titleKey: TKey
21
40
  /** Locale key of the line describing what this plugin's settings govern. */
22
- descriptionKey: SettingsCardKey
41
+ descriptionKey: TKey
23
42
  /** The card's form state: availability, writability, and what a save would do. */
24
43
  state: CardShell
25
44
  /** Write every staged edit. */
@@ -35,11 +54,12 @@ export interface PluginSettingsCardProps {
35
54
  * @param props - the plugin's copy keys, its form state, and its controls.
36
55
  * @returns the card, or nothing while the namespace is still loading.
37
56
  */
38
- export function PluginSettingsCard(props: PluginSettingsCardProps) {
57
+ export function PluginSettingsCard<TKey extends string = string>(props: PluginSettingsCardProps<TKey>) {
39
58
  const [open, setOpen] = useState(false)
40
59
  const { state } = props
41
60
  if (!state.available) return null
42
61
  const title = props.t(props.titleKey)
62
+ const description = props.t(props.descriptionKey)
43
63
  const blocked = !state.dirty || state.invalid || state.saving
44
64
  const cardClass = open ? `${css.cardOpen} ${css.card}` : css.card
45
65
  // The namespace exists but the Host does not serve it to this client (the
@@ -57,8 +77,8 @@ export function PluginSettingsCard(props: PluginSettingsCardProps) {
57
77
  onClick={() => { setOpen(!open) }}
58
78
  >
59
79
  <span className={css.headText}>
60
- <span className={css.name}>{title}</span>
61
- <span className={css.description}>{props.t(props.descriptionKey)}</span>
80
+ <span className={css.name} title={title}>{title}</span>
81
+ <span className={css.description} title={description}>{description}</span>
62
82
  </span>
63
83
  <svg
64
84
  width="14"
@@ -94,10 +114,10 @@ export function PluginSettingsCard(props: PluginSettingsCardProps) {
94
114
  onClick={() => { setOpen(!open) }}
95
115
  >
96
116
  <span className={css.headText}>
97
- <span className={css.name}>{title}</span>
98
- <span className={css.description}>{props.t(props.descriptionKey)}</span>
117
+ <span className={css.name} title={title}>{title}</span>
118
+ <span className={css.description} title={description}>{description}</span>
99
119
  </span>
100
- {state.dirty ? <span className={css.pending}>{props.t('settings.unsaved')}</span> : null}
120
+ {state.dirty ? <span className={css.pending} title={props.t('settings.unsaved')}>{props.t('settings.unsaved')}</span> : null}
101
121
  <svg
102
122
  width="14"
103
123
  height="14"
@@ -259,4 +279,50 @@ export function BooleanField(props: FieldProps & {
259
279
  <p className={css.hint}>{props.hint}</p>
260
280
  </div>
261
281
  )
262
- }
282
+ }
283
+
284
+ /** A staged enumerated field rendered as a select. */
285
+ export function ChoiceField(props: FieldProps & {
286
+ /** Copy for the inherit option (draft text is the empty string). */
287
+ inheritLabel: string
288
+ /** Choices rendered in order; `value` is the draft/stored text. */
289
+ choices: ReadonlyArray<{ value: string; label: string }>
290
+ }) {
291
+ return (
292
+ <div className={css.field}>
293
+ <div className={css.head}>
294
+ <label className={css.label} htmlFor={props.id}>{props.label}</label>
295
+ {props.overridden
296
+ ? (
297
+ <span className={css.badges}>
298
+ <span className={css.badge}>{props.overriddenLabel}</span>
299
+ <button
300
+ type="button"
301
+ className={css.reset}
302
+ disabled={props.disabled}
303
+ onClick={props.onReset}
304
+ >
305
+ {props.resetLabel}
306
+ </button>
307
+ </span>
308
+ )
309
+ : null}
310
+ </div>
311
+ <select
312
+ id={props.id}
313
+ className={css.select}
314
+ value={props.text}
315
+ disabled={props.disabled}
316
+ onChange={(event) => { props.onEdit(event.target.value) }}
317
+ >
318
+ <option value="">{props.inheritLabel}</option>
319
+ {props.choices.map(choice => (
320
+ <option key={choice.value} value={choice.value}>{choice.label}</option>
321
+ ))}
322
+ </select>
323
+ <p className={props.invalid ? css.invalid : css.hint}>
324
+ {props.invalid ? props.invalidLabel : props.hint}
325
+ </p>
326
+ </div>
327
+ )
328
+ }
@@ -3,7 +3,7 @@
3
3
  * active. Cards open the task detail (never execute directly); the header
4
4
  * offers filter, new-task, and a back-to-chat escape.
5
5
  */
6
- import { useEffect, useState } from 'react'
6
+ import { memo, useCallback, useEffect, useState } from 'react'
7
7
  import { selectedTaskOf, type BoardController } from '../../core/controller.ts'
8
8
  import { COLUMNS, type TaskRecord, type TaskStatus } from '../../core/tasks.ts'
9
9
  import { t, type TaskBoardKey } from '../locales.ts'
@@ -28,6 +28,17 @@ function matchesFilter(task: TaskRecord, filter: string): boolean {
28
28
  return task.title.toLowerCase().includes(needle) || task.description.toLowerCase().includes(needle)
29
29
  }
30
30
 
31
+ /**
32
+ * Memoized per-card adapter: with a stable `onOpen` from the board and an
33
+ * immutable task record (only the changed card gets a new object ref), a card
34
+ * re-renders only when its own task changes — not when a sibling card status,
35
+ * the filter, or the selection moves.
36
+ */
37
+ const MemoTaskCard = memo(function MemoTaskCard({ task, onOpen }: { task: TaskRecord; onOpen: (id: string) => void }) {
38
+ const onClick = useCallback(() => { onOpen(task.id) }, [task.id, onOpen])
39
+ return <TaskCard task={task} onClick={onClick} />
40
+ })
41
+
31
42
  /** Board component; subscribes to the controller snapshot. */
32
43
  export function TaskBoard({ controller }: { controller: BoardController }) {
33
44
  const [snapshot, setSnapshot] = useState(controller.getSnapshot())
@@ -39,6 +50,7 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
39
50
  const [showNew, setShowNew] = useState(false)
40
51
  const selected = selectedTaskOf(snapshot)
41
52
  const visible = snapshot.tasks.filter(task => matchesFilter(task, filter))
53
+ const openTask = useCallback((id: string): void => { controller.openTask(id) }, [controller])
42
54
 
43
55
  return (
44
56
  <div className={css.board} data-dsh-taskboard-board="">
@@ -80,11 +92,7 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
80
92
  </header>
81
93
  <div className={css.cards}>
82
94
  {tasks.map(task => (
83
- <TaskCard
84
- key={task.id}
85
- task={task}
86
- onClick={() => { controller.openTask(task.id) }}
87
- />
95
+ <MemoTaskCard key={task.id} task={task} onOpen={openTask} />
88
96
  ))}
89
97
  {tasks.length === 0 && <div className={css.columnEmpty}>{t('board.empty')}</div>}
90
98
  </div>
@@ -1,7 +1,13 @@
1
1
  /**
2
2
  * Task card: the board's column item. Clicking opens the task detail — it
3
3
  * never executes anything directly (detail holds the Run button).
4
+ *
5
+ * Memoized: the card re-renders only when its own task record changes, so a
6
+ * status/filter update on one card (or scrolling) never re-renders every
7
+ * card on the board. The per-card onClick is built with a stable task reference
8
+ * by the board, so the memo boundary is effective.
4
9
  */
10
+ import { memo } from 'react'
5
11
  import type { TaskRecord } from '../../core/tasks.ts'
6
12
  import { executionLabel } from '../../core/tasks.ts'
7
13
  import { t } from '../locales.ts'
@@ -19,7 +25,7 @@ export function formatTime(ms: number): string {
19
25
  }
20
26
 
21
27
  /** One card in a column. */
22
- export function TaskCard({ task, onClick }: { task: TaskRecord; onClick: () => void }) {
28
+ function TaskCardInner({ task, onClick }: { task: TaskRecord; onClick: () => void }) {
23
29
  const latest = task.executions[task.executions.length - 1]
24
30
  const runs = task.executions.length
25
31
  return (
@@ -60,3 +66,6 @@ export function TaskCard({ task, onClick }: { task: TaskRecord; onClick: () => v
60
66
  </button>
61
67
  )
62
68
  }
69
+
70
+ /** Memoized card: re-renders only when the card's own task record changes. */
71
+ export const TaskCard = memo(TaskCardInner)
@@ -1,3 +1,4 @@
1
+ /* Generated by scripts/sync-shared.mjs from shared/client/settings/settings-card.module.css. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs". */
1
2
  /* Plugin settings card chrome + staged form fields.
2
3
  * Aligned with the official ui-settings-plugins PluginCard / fields CSS:
3
4
  * same semantic tokens, radius, typography and states so family cards read
@@ -229,6 +230,11 @@
229
230
  outline-offset: 2px;
230
231
  }
231
232
 
233
+ .reset:focus-visible {
234
+ outline: 2px solid var(--dsw-alias-brand-primary);
235
+ outline-offset: 2px;
236
+ }
237
+
232
238
  .input,
233
239
  .select {
234
240
  border: 1px solid var(--dsw-alias-border-l2);
@@ -1,10 +1,11 @@
1
+ // Generated by scripts/sync-shared.mjs from shared/client/settings/settings-form.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
1
2
  /**
2
3
  * Staged form model behind the plugin settings card. A card stages what the
3
4
  * user types and writes it only when they save — the settings write is a
4
5
  * durable, revision-fenced document mutation, so staging keeps what is on
5
- * screen exactly what a save would store. Mirrors the official
6
- * ui-plugin-config card-store pattern in a self-contained slice: this
7
- * package must not depend on a sibling UI package.
6
+ * screen exactly what a save would store. Family-shared slice inlined into
7
+ * each plugin's client bundle; mirrors the official ui-plugin-config
8
+ * card-store pattern.
8
9
  */
9
10
 
10
11
  import type { SettingsScope, SettingsScopeSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
@@ -90,8 +91,17 @@ interface PlannedWrite {
90
91
  run: (() => Promise<boolean>) | undefined
91
92
  }
92
93
 
93
- /** A whole-number field. An empty draft clears the field; any other draft that is not a finite number blocks the save. */
94
- export function numberField(field: string): FieldSpec {
94
+ /** Constraints a numeric field's accepted drafts must satisfy, mirroring the host schema. */
95
+ export interface NumberConstraints {
96
+ /** The accepted value must be a whole number. */
97
+ integer?: boolean
98
+ /** The accepted value must be at least this. */
99
+ min?: number
100
+ }
101
+
102
+ /** A whole- or decimal-number field. An empty draft clears the field; any other draft that is not a finite number within the constraints blocks the save. */
103
+ export function numberField(field: string, constraints: NumberConstraints = {}): FieldSpec {
104
+ const { integer = false, min } = constraints
95
105
  return {
96
106
  field,
97
107
  format: value => typeof value === 'number' ? String(value) : '',
@@ -99,7 +109,10 @@ export function numberField(field: string): FieldSpec {
99
109
  const trimmed = text.trim()
100
110
  if (trimmed === '') return { kind: 'clear' }
101
111
  const parsed = Number(trimmed)
102
- return Number.isFinite(parsed) ? { kind: 'set', value: parsed } : undefined
112
+ if (!Number.isFinite(parsed)) return undefined
113
+ if (integer && !Number.isInteger(parsed)) return undefined
114
+ if (min !== undefined && parsed < min) return undefined
115
+ return { kind: 'set', value: parsed }
103
116
  },
104
117
  }
105
118
  }
@@ -122,13 +135,27 @@ export function booleanField(field: string): FieldSpec {
122
135
  field,
123
136
  format: value => typeof value === 'boolean' ? String(value) : '',
124
137
  parse: (text) => {
125
- if (text === 'true') return { kind: 'set', value: true }
126
- if (text === 'false') return { kind: 'set', value: false }
138
+ const trimmed = text.trim()
139
+ if (trimmed === '') return { kind: 'clear' }
140
+ if (trimmed === 'true') return { kind: 'set', value: true }
141
+ if (trimmed === 'false') return { kind: 'set', value: false }
127
142
  return undefined
128
143
  },
129
144
  }
130
145
  }
131
146
 
147
+ /** An enumerated string field; only the listed choices are accepted. An empty draft clears the field. */
148
+ export function choiceField(field: string, choices: readonly string[]): FieldSpec {
149
+ return {
150
+ field,
151
+ format: value => typeof value === 'string' && choices.includes(value) ? value : '',
152
+ parse: (text) => {
153
+ if (text === '') return { kind: 'clear' }
154
+ return choices.includes(text) ? { kind: 'set', value: text } : undefined
155
+ },
156
+ }
157
+ }
158
+
132
159
  /**
133
160
  * Stages one card's edits over one settings namespace and writes them on save.
134
161
  *
@@ -215,6 +242,9 @@ export class CardForm<T> {
215
242
  const plan = this.plan()
216
243
  const writes = plan.flatMap(item => item.run === undefined ? [] : [item.run])
217
244
  if (plan.length === 0 || this.saving || writes.length !== plan.length) return
245
+ // Snapshot the fields this save writes, so edits staged while it is in
246
+ // flight survive: only the staged keys this save actually wrote are cleared.
247
+ const fields = new Set(plan.map(item => item.field))
218
248
  this.saving = true
219
249
  this.failed = false
220
250
  this.publish()
@@ -222,7 +252,9 @@ export class CardForm<T> {
222
252
  for (const write of writes) {
223
253
  landed = await write() && landed
224
254
  }
225
- if (landed) this.staged.clear()
255
+ if (landed) {
256
+ for (const field of fields) this.staged.delete(field)
257
+ }
226
258
  this.saving = false
227
259
  this.failed = !landed
228
260
  this.publish()