@linxin666/dsh-client-ui-task-board 0.3.2 → 0.3.4

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/index.js CHANGED
@@ -5,6 +5,7 @@ import { createHash, timingSafeEqual } from "node:crypto";
5
5
  import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
6
6
  import { dirname, isAbsolute, join, win32 } from "node:path";
7
7
  import { homedir } from "node:os";
8
+ import { isAbsolute as isAbsolute$1, join as join$1 } from "node:path/posix";
8
9
  //#region src/core/schedule.ts
9
10
  /** Inclusive ranges per field, in cron order. */
10
11
  const FIELD_RANGES = [
@@ -182,8 +183,9 @@ function isDigits(value) {
182
183
  */
183
184
  /** Expand a leading ~ (or ~user) in a path, platform-style. */
184
185
  function expandHome(path, home = homedir()) {
186
+ const j = home.startsWith("/") ? join$1 : join;
185
187
  if (path === "~") return home;
186
- if (path.startsWith("~/") || path.startsWith("~\\")) return join(home, path.slice(2));
188
+ if (path.startsWith("~/") || path.startsWith("~\\")) return j(home, path.slice(2));
187
189
  return path;
188
190
  }
189
191
  /**
@@ -193,12 +195,15 @@ function expandHome(path, home = homedir()) {
193
195
  * @returns the absolute DSH home path.
194
196
  */
195
197
  function resolveDshHome(env = process.env, home = homedir()) {
198
+ const isPosix = home.startsWith("/");
199
+ const j = isPosix ? join$1 : join;
200
+ const isAbs = isPosix ? isAbsolute$1 : isAbsolute;
196
201
  const raw = env.DSH_HOME;
197
202
  if (raw !== void 0 && raw.trim() !== "") {
198
203
  const expanded = expandHome(raw.trim(), home);
199
- return isAbsolute(expanded) ? expanded : join(process.cwd(), expanded);
204
+ return isAbs(expanded) ? expanded : j(process.cwd(), expanded);
200
205
  }
201
- return join(home, ".dsh");
206
+ return j(home, ".dsh");
202
207
  }
203
208
  /** Resolve the DSH home directory from the live environment. */
204
209
  function dshHome() {
@@ -611,10 +616,34 @@ function applyScheduleNextRun(tasks, id, nextRunAt, lastTriggeredAt, now) {
611
616
  * fresh updatedAt. Pure ledger transition (no persistence or notify — the
612
617
  * controller orchestrates those).
613
618
  *
614
- * An explicit `undefined` in the patch clears the field (the task falls
615
- * back to the runtime default); an unknown permission string is ignored so
616
- * stale UI can never persist a value the execution service rejects.
619
+ * An explicit `undefined` in the patch clears an execution-target field
620
+ * (the task falls back to the runtime default); content fields are trimmed
621
+ * and never cleared. An unknown permission string is ignored so stale UI
622
+ * can never persist a value the execution service rejects.
617
623
  */
624
+ /**
625
+ * The fields that edit the task's content (what the user reads and what the
626
+ * next execution sends). Unlike the execution targets they stay editable only
627
+ * while the task has never started executing — after the first run the
628
+ * recorded prompt is the record of what actually ran, so it becomes read-only.
629
+ */
630
+ const TASK_CONTENT_FIELDS = [
631
+ "title",
632
+ "description",
633
+ "prompt"
634
+ ];
635
+ /** Whether an update patch touches any task-content field. */
636
+ function hasContentPatch(patch) {
637
+ return TASK_CONTENT_FIELDS.some((field) => field in patch);
638
+ }
639
+ /**
640
+ * Whether a task's content may still be edited: the task must be on-board
641
+ * (not archived) and must never have started executing. Fail-closed: a
642
+ * running, settled, or cancelled-before-launch task keeps its content fixed.
643
+ */
644
+ function canEditTaskContent(task) {
645
+ return task.archivedAt === void 0 && task.status !== "running" && task.executions.length === 0;
646
+ }
618
647
  /** Keep an unknown permission string from entering the ledger. */
619
648
  function normalizePermission(current, value) {
620
649
  if (value === void 0) return void 0;
@@ -639,6 +668,11 @@ function applyUpdateTask(tasks, id, patch, now) {
639
668
  ...patch,
640
669
  updatedAt: now
641
670
  };
671
+ for (const field of TASK_CONTENT_FIELDS) {
672
+ if (!(field in patch)) continue;
673
+ const value = patch[field];
674
+ next[field] = value === void 0 ? task[field] : value.trim();
675
+ }
642
676
  if (workspaceId !== void 0 || "workspaceId" in patch) next.workspaceId = workspaceId;
643
677
  if (mode !== void 0 || "mode" in patch) next.mode = mode;
644
678
  if (permission !== void 0 || "permission" in patch) next.permission = permission;
@@ -1266,6 +1300,8 @@ var HostTaskLedger = class {
1266
1300
  const task = this.document.tasks.find((task) => task.id === action.taskId);
1267
1301
  if (task === void 0) throw new Error("task not found");
1268
1302
  if (task.archivedAt !== void 0) throw new Error("archived task is read-only");
1303
+ if (hasContentPatch(action.patch) && !canEditTaskContent(task)) throw new Error("task has already been executed");
1304
+ if ("title" in action.patch && action.patch.title?.trim() === "") throw new Error("title is required");
1269
1305
  this.document.tasks = [...applyUpdateTask(this.document.tasks, action.taskId, action.patch, now)];
1270
1306
  break;
1271
1307
  }
@@ -2465,7 +2501,7 @@ function makeTaskBoardRoutes(service, access = {}) {
2465
2501
  //#region src/mount-once.ts
2466
2502
  /**
2467
2503
  * Host single-instance guard shared by the plugin family. The family bundle
2468
- * (dsh-web-ui-all / dsh-skins) namespaces every child row id (web-ui-*), so
2504
+ * (dsh-web-all / dsh-skins) namespaces every child row id (web-ui-*), so
2469
2505
  * the loader accepts a standalone install of the same package side by side;
2470
2506
  * without this guard the second instance would still re-register the same
2471
2507
  * webserver routes, tools, settings namespaces, and system-prompt sections
@@ -2478,7 +2514,7 @@ function makeTaskBoardRoutes(service, access = {}) {
2478
2514
  * `ctx.effect` runs its callback immediately and treats the callback's
2479
2515
  * return value as the fiber disposer, so the unmarker is returned, not run.
2480
2516
  */
2481
- const MOUNTED = Symbol.for("dsh-web-ui.mounted-plugins");
2517
+ const MOUNTED = Symbol.for("dsh-web.mounted-plugins");
2482
2518
  function mountedSet() {
2483
2519
  const registry = globalThis;
2484
2520
  return registry[MOUNTED] ??= /* @__PURE__ */ new Set();
@@ -2516,7 +2552,7 @@ const inject = [
2516
2552
  "commands"
2517
2553
  ];
2518
2554
  /** Model-facing announcement: plugin presence, capabilities, and limits. */
2519
- const TASK_BOARD_GUIDANCE = "本机已安装 dsh-task-board 插件(DSH Web GUI 的任务看板):侧边栏「任务看板」入口;在 dsh-web-ui 插件全家桶仓库(packages/dsh-task-board)统一维护,经聚合包 web-ui-all 一键安装。能力:多列看板管理任务;Host 权威账本;关闭浏览器后仍由 Host 执行和结算;任务可钉住工作区、agent 预设和权限;支持 Host 本地时区的 5 段 cron,错过的触发点不补跑;可选且默认关闭的空闲系统睡眠保护允许屏幕熄灭,但不承诺拦截合盖、手动睡眠、休眠、关机或唤醒已睡眠机器。执行消耗 API 额度。用户提到「任务看板 / 看板 / 定时任务」时即指本插件,请据此协作。若你同时用 todo_write 维护会话顶部的可见计划列表,最终回复前必须再次调用 todo_write 收尾:没有剩余工作时不要保留 in_progress,已完成的最后一步要标为 completed。";
2555
+ const TASK_BOARD_GUIDANCE = "本机已安装 dsh-task-board 插件(DSH Web GUI 的任务看板):侧边栏「任务看板」入口;在 dsh-web 插件全家桶仓库(packages/dsh-task-board)统一维护,经聚合包 web-ui-all 一键安装。能力:多列看板管理任务;Host 权威账本;关闭浏览器后仍由 Host 执行和结算;任务可钉住工作区、agent 预设和权限;支持 Host 本地时区的 5 段 cron,错过的触发点不补跑;可选且默认关闭的空闲系统睡眠保护允许屏幕熄灭,但不承诺拦截合盖、手动睡眠、休眠、关机或唤醒已睡眠机器。执行消耗 API 额度。用户提到「任务看板 / 看板 / 定时任务」时即指本插件,请据此协作。若你同时用 todo_write 维护会话顶部的可见计划列表,最终回复前必须再次调用 todo_write 收尾:没有剩余工作时不要保留 in_progress,已完成的最后一步要标为 completed。";
2520
2556
  /**
2521
2557
  * Settings namespace of the board's announcement capability — the section the
2522
2558
  * web settings surface edits. Spelled here rather than imported: the browser
@@ -22,6 +22,8 @@ export interface PluginSettingsCardProps<TKey extends string = string> {
22
22
  titleKey: TKey;
23
23
  /** Locale key of the line describing what this plugin's settings govern. */
24
24
  descriptionKey: TKey;
25
+ /** Optional rich header description rendered instead of the plain t(descriptionKey) text (the plain text stays as the tooltip). */
26
+ descriptionNode?: ReactNode;
25
27
  /** The card's form state: availability, writability, and what a save would do. */
26
28
  state: CardShell;
27
29
  /** Write every staged edit. */
@@ -0,0 +1,8 @@
1
+ import type { BoardController } from '../../core/controller.ts';
2
+ import type { TaskRecord } from '../../core/tasks.ts';
3
+ /** Edit-task form overlay. */
4
+ export declare function EditTaskModal({ controller, task, onClose }: {
5
+ controller: BoardController;
6
+ task: TaskRecord;
7
+ onClose: () => void;
8
+ }): import("react").JSX.Element;
@@ -37,7 +37,7 @@ export interface SettingsPluginItemOwnerProps {
37
37
  declare module '@deepseek-ai/cordis' {
38
38
  interface Context {
39
39
  /**
40
- * Optional rc.6 compatibility binder provided by dsh-web-ui-settings;
40
+ * Optional rc.6 compatibility binder provided by dsh-web-settings;
41
41
  * absent when that group plugin is not installed, so callers fall back to
42
42
  * the official settings scope.
43
43
  */
@@ -40,7 +40,10 @@ export declare const zh: {
40
40
  'new.cancel': string;
41
41
  'new.required': string;
42
42
  'detail.title': string;
43
+ 'detail.edit': string;
43
44
  'detail.close': string;
45
+ 'edit.title': string;
46
+ 'edit.save': string;
44
47
  'detail.prompt': string;
45
48
  'detail.description': string;
46
49
  'detail.execution': string;
@@ -173,7 +173,7 @@ export declare class CardForm<T> {
173
173
  /**
174
174
  * Write every staged edit, then re-seed from what the Host accepted.
175
175
  *
176
- * When the scope carries the optional batch surface (the dsh-web-ui
176
+ * When the scope carries the optional batch surface (the dsh-web
177
177
  * bridge scope), every planned write rides one mutation so cross-field
178
178
  * validate hooks (baseURL+model) judge the batch as a unit instead of
179
179
  * deadlocking on per-field writes. Otherwise the per-field loop runs.
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Anonymous install telemetry shared by the DSH Web UI family plugins.
3
+ *
4
+ * Once per UTC day per browser, each wired plugin sends one heartbeat to
5
+ * the dsh-market edge API listing its package name (and version when known).
6
+ * The payload carries no conversation data and no identifiers beyond a random
7
+ * UUID generated in localStorage; the server hashes it with a deployment salt
8
+ * before storage and never persists IP addresses. Sends are fire-and-forget:
9
+ * failures stay silent and simply retry on a later mount or day. The system
10
+ * is documented in docs/telemetry.md.
11
+ */
12
+ export type TelemetryChannel = 'market' | 'npm' | 'unknown';
13
+ export interface TelemetryItem {
14
+ /** npm package name or asset id, e.g. "@linxin666/dsh-pet" or "skin:harbor". */
15
+ name: string;
16
+ /** Installed version when known; omitted otherwise. */
17
+ version?: string;
18
+ /** Install channel when determinable (market = Workshop install). */
19
+ channel?: TelemetryChannel;
20
+ }
21
+ /**
22
+ * Fire the daily heartbeat for the given items at most once per UTC day per
23
+ * browser. Never throws and never blocks the caller. Items without an explicit
24
+ * version inherit the bundle's baked build version.
25
+ */
26
+ export declare function reportDailyHeartbeat(items: readonly TelemetryItem[]): void;
@@ -125,7 +125,15 @@ export declare class BoardController {
125
125
  createTask(input: NewTaskInput): TaskRecord | undefined;
126
126
  /** Create through the Host and expose the task only after confirmation. */
127
127
  createTaskConfirmed(input: NewTaskInput): Promise<TaskRecord | undefined>;
128
- updateTask(id: string, patch: TaskUpdatePatch): void;
128
+ /**
129
+ * Apply an editable-field patch (task content + execution targets).
130
+ * Host-backed: the Host ledger owns the fail-closed checks (the content of
131
+ * an executed task is read-only) and confirms the mutation, so the resolved
132
+ * value reflects whether the Host accepted it; the legacy in-memory path
133
+ * applies and persists synchronously.
134
+ * @returns true when the patch was accepted by the authority.
135
+ */
136
+ updateTask(id: string, patch: TaskUpdatePatch): Promise<boolean>;
129
137
  /**
130
138
  * Replace (a part of) the picker option sets the UI feeds (workspace list
131
139
  * and agent-preset roster come from the runtime, not the ledger).
@@ -4,13 +4,29 @@
4
4
  * fresh updatedAt. Pure ledger transition (no persistence or notify — the
5
5
  * controller orchestrates those).
6
6
  *
7
- * An explicit `undefined` in the patch clears the field (the task falls
8
- * back to the runtime default); an unknown permission string is ignored so
9
- * stale UI can never persist a value the execution service rejects.
7
+ * An explicit `undefined` in the patch clears an execution-target field
8
+ * (the task falls back to the runtime default); content fields are trimmed
9
+ * and never cleared. An unknown permission string is ignored so stale UI
10
+ * can never persist a value the execution service rejects.
10
11
  */
11
12
  import { type TaskRecord } from '../tasks.ts';
12
13
  /** Editable fields on a task (the update patch surface). */
13
14
  export type TaskUpdatePatch = Partial<Pick<TaskRecord, 'title' | 'description' | 'prompt' | 'workspaceId' | 'mode' | 'permission'>>;
15
+ /**
16
+ * The fields that edit the task's content (what the user reads and what the
17
+ * next execution sends). Unlike the execution targets they stay editable only
18
+ * while the task has never started executing — after the first run the
19
+ * recorded prompt is the record of what actually ran, so it becomes read-only.
20
+ */
21
+ export declare const TASK_CONTENT_FIELDS: readonly ["title", "description", "prompt"];
22
+ /** Whether an update patch touches any task-content field. */
23
+ export declare function hasContentPatch(patch: TaskUpdatePatch): boolean;
24
+ /**
25
+ * Whether a task's content may still be edited: the task must be on-board
26
+ * (not archived) and must never have started executing. Fail-closed: a
27
+ * running, settled, or cancelled-before-launch task keeps its content fixed.
28
+ */
29
+ export declare function canEditTaskContent(task: TaskRecord): boolean;
14
30
  /**
15
31
  * Apply an update across the ledger. Tasks that do not match the id are left
16
32
  * untouched; the matched task receives the patch plus a fresh updatedAt.
@@ -10,7 +10,7 @@
10
10
  * edit this shared source and re-run the sync instead of editing a copy.
11
11
  * Consumer code is migrated onto it in follow-up waves; no call site changes
12
12
  * belong in the same change as its introduction.
13
- * @module dsh-web-ui-shared/host/http
13
+ * @module dsh-web-shared/host/http
14
14
  */
15
15
  import type { IncomingMessage, OutgoingHttpHeaders, ServerResponse } from 'node:http';
16
16
  /**
@@ -11,7 +11,7 @@ import z from 'schemastery';
11
11
  export declare const DEFAULT_PROXY_TOKEN_ENV = "DSH_TASK_BOARD_PROXY_TOKEN";
12
12
  export declare const inject: string[];
13
13
  /** Model-facing announcement: plugin presence, capabilities, and limits. */
14
- export declare const TASK_BOARD_GUIDANCE = "\u672C\u673A\u5DF2\u5B89\u88C5 dsh-task-board \u63D2\u4EF6\uFF08DSH Web GUI \u7684\u4EFB\u52A1\u770B\u677F\uFF09\uFF1A\u4FA7\u8FB9\u680F\u300C\u4EFB\u52A1\u770B\u677F\u300D\u5165\u53E3\uFF1B\u5728 dsh-web-ui \u63D2\u4EF6\u5168\u5BB6\u6876\u4ED3\u5E93\uFF08packages/dsh-task-board\uFF09\u7EDF\u4E00\u7EF4\u62A4\uFF0C\u7ECF\u805A\u5408\u5305 web-ui-all \u4E00\u952E\u5B89\u88C5\u3002\u80FD\u529B\uFF1A\u591A\u5217\u770B\u677F\u7BA1\u7406\u4EFB\u52A1\uFF1BHost \u6743\u5A01\u8D26\u672C\uFF1B\u5173\u95ED\u6D4F\u89C8\u5668\u540E\u4ECD\u7531 Host \u6267\u884C\u548C\u7ED3\u7B97\uFF1B\u4EFB\u52A1\u53EF\u9489\u4F4F\u5DE5\u4F5C\u533A\u3001agent \u9884\u8BBE\u548C\u6743\u9650\uFF1B\u652F\u6301 Host \u672C\u5730\u65F6\u533A\u7684 5 \u6BB5 cron\uFF0C\u9519\u8FC7\u7684\u89E6\u53D1\u70B9\u4E0D\u8865\u8DD1\uFF1B\u53EF\u9009\u4E14\u9ED8\u8BA4\u5173\u95ED\u7684\u7A7A\u95F2\u7CFB\u7EDF\u7761\u7720\u4FDD\u62A4\u5141\u8BB8\u5C4F\u5E55\u7184\u706D\uFF0C\u4F46\u4E0D\u627F\u8BFA\u62E6\u622A\u5408\u76D6\u3001\u624B\u52A8\u7761\u7720\u3001\u4F11\u7720\u3001\u5173\u673A\u6216\u5524\u9192\u5DF2\u7761\u7720\u673A\u5668\u3002\u6267\u884C\u6D88\u8017 API \u989D\u5EA6\u3002\u7528\u6237\u63D0\u5230\u300C\u4EFB\u52A1\u770B\u677F / \u770B\u677F / \u5B9A\u65F6\u4EFB\u52A1\u300D\u65F6\u5373\u6307\u672C\u63D2\u4EF6\uFF0C\u8BF7\u636E\u6B64\u534F\u4F5C\u3002\u82E5\u4F60\u540C\u65F6\u7528 todo_write \u7EF4\u62A4\u4F1A\u8BDD\u9876\u90E8\u7684\u53EF\u89C1\u8BA1\u5212\u5217\u8868\uFF0C\u6700\u7EC8\u56DE\u590D\u524D\u5FC5\u987B\u518D\u6B21\u8C03\u7528 todo_write \u6536\u5C3E\uFF1A\u6CA1\u6709\u5269\u4F59\u5DE5\u4F5C\u65F6\u4E0D\u8981\u4FDD\u7559 in_progress\uFF0C\u5DF2\u5B8C\u6210\u7684\u6700\u540E\u4E00\u6B65\u8981\u6807\u4E3A completed\u3002";
14
+ export declare const TASK_BOARD_GUIDANCE = "\u672C\u673A\u5DF2\u5B89\u88C5 dsh-task-board \u63D2\u4EF6\uFF08DSH Web GUI \u7684\u4EFB\u52A1\u770B\u677F\uFF09\uFF1A\u4FA7\u8FB9\u680F\u300C\u4EFB\u52A1\u770B\u677F\u300D\u5165\u53E3\uFF1B\u5728 dsh-web \u63D2\u4EF6\u5168\u5BB6\u6876\u4ED3\u5E93\uFF08packages/dsh-task-board\uFF09\u7EDF\u4E00\u7EF4\u62A4\uFF0C\u7ECF\u805A\u5408\u5305 web-ui-all \u4E00\u952E\u5B89\u88C5\u3002\u80FD\u529B\uFF1A\u591A\u5217\u770B\u677F\u7BA1\u7406\u4EFB\u52A1\uFF1BHost \u6743\u5A01\u8D26\u672C\uFF1B\u5173\u95ED\u6D4F\u89C8\u5668\u540E\u4ECD\u7531 Host \u6267\u884C\u548C\u7ED3\u7B97\uFF1B\u4EFB\u52A1\u53EF\u9489\u4F4F\u5DE5\u4F5C\u533A\u3001agent \u9884\u8BBE\u548C\u6743\u9650\uFF1B\u652F\u6301 Host \u672C\u5730\u65F6\u533A\u7684 5 \u6BB5 cron\uFF0C\u9519\u8FC7\u7684\u89E6\u53D1\u70B9\u4E0D\u8865\u8DD1\uFF1B\u53EF\u9009\u4E14\u9ED8\u8BA4\u5173\u95ED\u7684\u7A7A\u95F2\u7CFB\u7EDF\u7761\u7720\u4FDD\u62A4\u5141\u8BB8\u5C4F\u5E55\u7184\u706D\uFF0C\u4F46\u4E0D\u627F\u8BFA\u62E6\u622A\u5408\u76D6\u3001\u624B\u52A8\u7761\u7720\u3001\u4F11\u7720\u3001\u5173\u673A\u6216\u5524\u9192\u5DF2\u7761\u7720\u673A\u5668\u3002\u6267\u884C\u6D88\u8017 API \u989D\u5EA6\u3002\u7528\u6237\u63D0\u5230\u300C\u4EFB\u52A1\u770B\u677F / \u770B\u677F / \u5B9A\u65F6\u4EFB\u52A1\u300D\u65F6\u5373\u6307\u672C\u63D2\u4EF6\uFF0C\u8BF7\u636E\u6B64\u534F\u4F5C\u3002\u82E5\u4F60\u540C\u65F6\u7528 todo_write \u7EF4\u62A4\u4F1A\u8BDD\u9876\u90E8\u7684\u53EF\u89C1\u8BA1\u5212\u5217\u8868\uFF0C\u6700\u7EC8\u56DE\u590D\u524D\u5FC5\u987B\u518D\u6B21\u8C03\u7528 todo_write \u6536\u5C3E\uFF1A\u6CA1\u6709\u5269\u4F59\u5DE5\u4F5C\u65F6\u4E0D\u8981\u4FDD\u7559 in_progress\uFF0C\u5DF2\u5B8C\u6210\u7684\u6700\u540E\u4E00\u6B65\u8981\u6807\u4E3A completed\u3002";
15
15
  /**
16
16
  * Settings namespace of the board's announcement capability — the section the
17
17
  * web settings surface edits. Spelled here rather than imported: the browser
@@ -7,7 +7,7 @@
7
7
  * Semantics: RFC 5735 IPv4 127/8, ::1, IPv4-mapped ::ffff:127/8 (matching the
8
8
  * remote-web-ui gate), localhost hostnames, plus the browser same-origin
9
9
  * markers (sec-fetch-site and Origin) for the request-level fence.
10
- * @module dsh-web-ui-shared/host/loopback
10
+ * @module dsh-web-shared/host/loopback
11
11
  */
12
12
  import type { IncomingMessage } from 'node:http';
13
13
  /** IPv4 127/8 predicate (four decimal octets, first == 127). */
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Host single-instance guard shared by the plugin family. The family bundle
3
- * (dsh-web-ui-all / dsh-skins) namespaces every child row id (web-ui-*), so
3
+ * (dsh-web-all / dsh-skins) namespaces every child row id (web-ui-*), so
4
4
  * the loader accepts a standalone install of the same package side by side;
5
5
  * without this guard the second instance would still re-register the same
6
6
  * webserver routes, tools, settings namespaces, and system-prompt sections
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@linxin666/dsh-client-ui-task-board",
3
3
  "description": "Host-authoritative task board for the DSH Web GUI with real session execution, Host cron scheduling, and optional cross-platform idle-sleep protection; mounted without DSH source changes.",
4
- "version": "0.3.2",
4
+ "version": "0.3.4",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/types/index.d.ts",
@@ -38,8 +38,6 @@
38
38
  }
39
39
  },
40
40
  "peerDependencies": {
41
- "@deepseek-ai/dsh-agent": "^0.1.1-rc.2",
42
- "@deepseek-ai/dsh-commands": "^0.1.1-rc.2",
43
41
  "react": "^18.2.0"
44
42
  },
45
43
  "dependencies": {
@@ -79,7 +77,7 @@
79
77
  "license": "Apache-2.0",
80
78
  "repository": {
81
79
  "type": "git",
82
- "url": "https://github.com/zhu1090093659/dsh-web-ui.git"
80
+ "url": "https://github.com/zhu1090093659/dsh-web.git"
83
81
  },
84
82
  "scripts": {
85
83
  "build": "tsc -p tsconfig.build.json && tsdown",
@@ -39,6 +39,8 @@ export interface PluginSettingsCardProps<TKey extends string = string> {
39
39
  titleKey: TKey
40
40
  /** Locale key of the line describing what this plugin's settings govern. */
41
41
  descriptionKey: TKey
42
+ /** Optional rich header description rendered instead of the plain t(descriptionKey) text (the plain text stays as the tooltip). */
43
+ descriptionNode?: ReactNode
42
44
  /** The card's form state: availability, writability, and what a save would do. */
43
45
  state: CardShell
44
46
  /** Write every staged edit. */
@@ -87,7 +89,7 @@ export function PluginSettingsCard<TKey extends string = string>(props: PluginSe
87
89
  <div className={css.headerStatic}>
88
90
  <span className={css.headText}>
89
91
  <span className={css.name} title={title}>{title}</span>
90
- <span className={css.description} title={description}>{description}</span>
92
+ <span className={css.description} title={description}>{props.descriptionNode ?? description}</span>
91
93
  </span>
92
94
  {state.dirty ? <span className={css.pending} title={props.t('settings.unsaved')}>{props.t('settings.unsaved')}</span> : null}
93
95
  </div>
@@ -102,7 +104,7 @@ export function PluginSettingsCard<TKey extends string = string>(props: PluginSe
102
104
  >
103
105
  <span className={css.headText}>
104
106
  <span className={css.name} title={title}>{title}</span>
105
- <span className={css.description} title={description}>{description}</span>
107
+ <span className={css.description} title={description}>{props.descriptionNode ?? description}</span>
106
108
  </span>
107
109
  {state.dirty ? <span className={css.pending} title={props.t('settings.unsaved')}>{props.t('settings.unsaved')}</span> : null}
108
110
  <svg
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Edit-task modal: title + description + the prompt the next execution will
3
+ * send, pre-filled from the task. Shown only for tasks that have never
4
+ * started executing (the detail view gates on canEditTaskContent); the Host
5
+ * still re-checks at submit, so a task that started running while the modal
6
+ * was open fails closed and the error surfaces here.
7
+ */
8
+ import { useState } from 'react'
9
+ import type { BoardController } from '../../core/controller.ts'
10
+ import type { TaskRecord } from '../../core/tasks.ts'
11
+ import { t } from '../locales.ts'
12
+ import css from '../board.module.css'
13
+
14
+ /** Edit-task form overlay. */
15
+ export function EditTaskModal({ controller, task, onClose }: { controller: BoardController; task: TaskRecord; onClose: () => void }) {
16
+ const [title, setTitle] = useState(task.title)
17
+ const [description, setDescription] = useState(task.description)
18
+ const [prompt, setPrompt] = useState(task.prompt)
19
+ const [error, setError] = useState<string | undefined>(undefined)
20
+ const [pending, setPending] = useState(false)
21
+
22
+ const submit = async (): Promise<void> => {
23
+ if (title.trim() === '') {
24
+ setError(t('new.required'))
25
+ return
26
+ }
27
+ setPending(true)
28
+ // The Host confirms the mutation (and its fail-closed checks); only a
29
+ // confirmed save closes the modal.
30
+ if (await controller.updateTask(task.id, { title, description, prompt })) {
31
+ onClose()
32
+ return
33
+ }
34
+ setPending(false)
35
+ setError(controller.getSnapshot().transportError ?? t('new.required'))
36
+ }
37
+
38
+ return (
39
+ <div className={css.modalBackdrop} onMouseDown={event => { if (event.target === event.currentTarget) onClose() }}>
40
+ <form
41
+ className={css.modal}
42
+ role="dialog"
43
+ aria-label={t('edit.title')}
44
+ onSubmit={event => { event.preventDefault(); void submit() }}
45
+ >
46
+ <h2 className={css.modalTitle}>{t('edit.title')}</h2>
47
+
48
+ <label className={css.field}>
49
+ <span className={css.fieldLabel}>{t('new.title')}</span>
50
+ <input
51
+ className={css.input}
52
+ value={title}
53
+ autoFocus
54
+ placeholder={t('new.titlePlaceholder')}
55
+ onChange={event => { setTitle(event.target.value); setError(undefined) }}
56
+ />
57
+ </label>
58
+
59
+ <label className={css.field}>
60
+ <span className={css.fieldLabel}>{t('new.description')}</span>
61
+ <textarea
62
+ className={css.input}
63
+ rows={3}
64
+ value={description}
65
+ placeholder={t('new.descriptionPlaceholder')}
66
+ onChange={event => { setDescription(event.target.value) }}
67
+ />
68
+ </label>
69
+
70
+ <label className={css.field}>
71
+ <span className={css.fieldLabel}>{t('new.prompt')}</span>
72
+ <textarea
73
+ className={css.input}
74
+ rows={4}
75
+ value={prompt}
76
+ placeholder={t('new.promptPlaceholder')}
77
+ onChange={event => { setPrompt(event.target.value) }}
78
+ />
79
+ </label>
80
+
81
+ {error !== undefined && <p className={css.formError}>{error}</p>}
82
+
83
+ <footer className={css.modalFooter}>
84
+ <button type="button" className={css.ghostButton} onClick={onClose}>
85
+ {t('new.cancel')}
86
+ </button>
87
+ <button type="submit" className={css.primaryButton} disabled={pending}>
88
+ {t('edit.save')}
89
+ </button>
90
+ </footer>
91
+ </form>
92
+ </div>
93
+ )
94
+ }
@@ -52,7 +52,7 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
52
52
  return (
53
53
  <div className={css.board} data-dsh-taskboard-board="" data-dsh-plugin="task-board">
54
54
  <header className={css.boardHeader}>
55
- {/* Shared hook: dsh-web-ui-all offsets center-view back controls beside the collapsed mobile sidebar. */}
55
+ {/* Shared hook: dsh-web-all offsets center-view back controls beside the collapsed mobile sidebar. */}
56
56
  <button
57
57
  type="button"
58
58
  className={`${css.ghostButton} ${css.backButton}`}
@@ -8,10 +8,12 @@ import { useEffect, useState } from 'react'
8
8
  import type { BoardController } from '../../core/controller.ts'
9
9
  import { isValidCron } from '../../core/schedule.ts'
10
10
  import { MANUAL_STATUSES, TASK_PERMISSIONS, type ExecutionRecord, type TaskPermission, type TaskRecord } from '../../core/tasks.ts'
11
+ import { canEditTaskContent } from '../../core/use-cases/task-update.ts'
11
12
  import { t, type TaskBoardKey } from '../locales.ts'
12
13
  import { SCHEDULE_PRESETS } from '../schedule-presets.ts'
13
14
  import css from '../board.module.css'
14
15
  import { ConfirmDialog } from './ConfirmDialog.tsx'
16
+ import { EditTaskModal } from './EditTaskModal.tsx'
15
17
  import { formatHostTimestamp, formatTime } from './TaskCard.tsx'
16
18
  import { STATUS_KEY } from './status-key.ts'
17
19
 
@@ -232,10 +234,13 @@ function ScheduleSection({ controller, task, pending }: { controller: BoardContr
232
234
  /** Task detail overlay. */
233
235
  export function TaskDetail({ controller, task }: { controller: BoardController; task: TaskRecord }) {
234
236
  const [confirmDelete, setConfirmDelete] = useState(false)
237
+ const [showEdit, setShowEdit] = useState(false)
235
238
 
236
239
  // Keep the overlay in sync if the task record changes underneath.
237
240
  const [latest, setLatest] = useState(task)
238
241
  useEffect(() => { setLatest(task) }, [task])
242
+ // A re-used overlay instance must not carry an edit session across tasks.
243
+ useEffect(() => { setShowEdit(false) }, [task.id])
239
244
  const current = latest
240
245
  const snapshot = controller.getSnapshot()
241
246
  const running = current.status === 'running'
@@ -328,6 +333,16 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
328
333
 
329
334
  <footer className={css.detailFooter}>
330
335
  {!archived && pending && <span className={css.detailMeta}>{t('board.pending')}…</span>}
336
+ {!archived && canEditTaskContent(current) && (
337
+ <button
338
+ type="button"
339
+ className={css.ghostButton}
340
+ disabled={pending}
341
+ onClick={() => { setShowEdit(true) }}
342
+ >
343
+ {t('detail.edit')}
344
+ </button>
345
+ )}
331
346
  {!archived && (
332
347
  <button
333
348
  type="button"
@@ -395,6 +410,10 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
395
410
  }}
396
411
  />
397
412
  )}
413
+
414
+ {showEdit && !archived && canEditTaskContent(current) && (
415
+ <EditTaskModal controller={controller} task={current} onClose={() => { setShowEdit(false) }} />
416
+ )}
398
417
  </div>
399
418
  )
400
419
  }
@@ -24,6 +24,7 @@ import { mountSidebarEntry } from './sidebar-entry.ts'
24
24
  import { TaskBoardSettingsCard, TaskBoardSettingsCardController, type TaskBoardSettings } from './TaskBoardSettingsCard.tsx'
25
25
  import { en, zh, type TaskBoardKey } from './locales.ts'
26
26
  import { HttpTaskBoardHostTransport } from './host-api.ts'
27
+ import { reportDailyHeartbeat } from './telemetry.ts'
27
28
 
28
29
  /** Locale namespace this plugin owns. */
29
30
  const NS = 'task-board'
@@ -57,7 +58,7 @@ export interface SettingsPluginItemOwnerProps {
57
58
  declare module '@deepseek-ai/cordis' {
58
59
  interface Context {
59
60
  /**
60
- * Optional rc.6 compatibility binder provided by dsh-web-ui-settings;
61
+ * Optional rc.6 compatibility binder provided by dsh-web-settings;
61
62
  * absent when that group plugin is not installed, so callers fall back to
62
63
  * the official settings scope.
63
64
  */
@@ -74,6 +75,10 @@ export const inject = ['slots', 'sessions', 'workspaces', 'connection', 'setting
74
75
  * @param ctx - client root context (services: sessions, workspaces).
75
76
  */
76
77
  export function apply(ctx: ClientContext): void {
78
+ // Anonymous install heartbeat (docs/telemetry.md): one beat per browser per
79
+ // UTC day, package name only, silent failure.
80
+ reportDailyHeartbeat([{ name: '@linxin666/dsh-client-ui-task-board' }])
81
+
77
82
  // A duplicated client injection (module factory executed twice in one page
78
83
  // lifetime) would otherwise mount a second sidebar entry and board view.
79
84
  // First application wins; later calls become no-ops (see apply-guard.ts).
@@ -41,7 +41,10 @@ export const zh = {
41
41
  'new.cancel': '取消',
42
42
  'new.required': '标题不能为空',
43
43
  'detail.title': '任务详情',
44
+ 'detail.edit': '编辑',
44
45
  'detail.close': '关闭',
46
+ 'edit.title': '编辑任务',
47
+ 'edit.save': '保存',
45
48
  'detail.prompt': '执行 Prompt',
46
49
  'detail.description': '描述',
47
50
  'detail.execution': '执行记录',
@@ -164,7 +167,10 @@ export const en: Record<keyof typeof zh, string> = {
164
167
  'new.cancel': 'Cancel',
165
168
  'new.required': 'Title is required',
166
169
  'detail.title': 'Task Detail',
170
+ 'detail.edit': 'Edit',
167
171
  'detail.close': 'Close',
172
+ 'edit.title': 'Edit Task',
173
+ 'edit.save': 'Save',
168
174
  'detail.prompt': 'Run Prompt',
169
175
  'detail.description': 'Description',
170
176
  'detail.execution': 'Execution History',
@@ -24,6 +24,10 @@
24
24
  .header {
25
25
  appearance: none;
26
26
  width: 100%;
27
+ /* The suite does not force a global border-box, so width:100% + padding
28
+ would stretch the row 32px past the card and push the trailing pending
29
+ badge (last flex item) outside the card border. */
30
+ box-sizing: border-box;
27
31
  font: inherit;
28
32
  color: inherit;
29
33
  text-align: left;
@@ -44,6 +48,7 @@
44
48
 
45
49
  .headerStatic {
46
50
  width: 100%;
51
+ box-sizing: border-box;
47
52
  border-radius: 12px;
48
53
  align-items: center;
49
54
  gap: 12px;
@@ -320,7 +320,7 @@ export class CardForm<T> {
320
320
  /**
321
321
  * Write every staged edit, then re-seed from what the Host accepted.
322
322
  *
323
- * When the scope carries the optional batch surface (the dsh-web-ui
323
+ * When the scope carries the optional batch surface (the dsh-web
324
324
  * bridge scope), every planned write rides one mutation so cross-field
325
325
  * validate hooks (baseURL+model) judge the batch as a unit instead of
326
326
  * deadlocking on per-field writes. Otherwise the per-field loop runs.