dsh-taskboard 0.4.5 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +24 -1
  2. package/lib/client.js +434 -193
  3. package/lib/host/execution.js +80 -33
  4. package/lib/host/execution.js.map +1 -1
  5. package/lib/host/git.js +49 -5
  6. package/lib/host/git.js.map +1 -1
  7. package/lib/host/routes.js +210 -112
  8. package/lib/host/routes.js.map +1 -1
  9. package/lib/host/scheduler.js +50 -28
  10. package/lib/host/scheduler.js.map +1 -1
  11. package/lib/host/sdk.js +7 -2
  12. package/lib/host/sdk.js.map +1 -1
  13. package/lib/host/store.js +41 -8
  14. package/lib/host/store.js.map +1 -1
  15. package/lib/host/templates.js +10 -3
  16. package/lib/host/templates.js.map +1 -1
  17. package/lib/host/tools.js +128 -97
  18. package/lib/host/tools.js.map +1 -1
  19. package/lib/index.js +3 -1
  20. package/lib/index.js.map +1 -1
  21. package/lib/shared/api.js.map +1 -1
  22. package/lib/shared/protocol.js +48 -5
  23. package/lib/shared/protocol.js.map +1 -1
  24. package/package.json +9 -8
  25. package/src/client/api.ts +26 -8
  26. package/src/client/board/ImportModal.tsx +1 -1
  27. package/src/client/board/SettingsModal.tsx +84 -0
  28. package/src/client/board/TaskBoard.tsx +47 -40
  29. package/src/client/board/TaskCard.tsx +3 -5
  30. package/src/client/board/TaskDetail.tsx +30 -21
  31. package/src/client/board/TaskFormModal.tsx +39 -31
  32. package/src/client/board/format.ts +26 -0
  33. package/src/client/board/labels.ts +44 -0
  34. package/src/client/controller.ts +86 -34
  35. package/src/client/index.ts +7 -5
  36. package/src/client/sidebar-entry.ts +5 -1
  37. package/src/client/styles.ts +4 -0
  38. package/src/host/execution.ts +90 -16
  39. package/src/host/git.ts +39 -10
  40. package/src/host/routes.ts +263 -128
  41. package/src/host/scheduler.ts +62 -36
  42. package/src/host/sdk.ts +12 -1
  43. package/src/host/store.ts +53 -7
  44. package/src/host/templates.ts +12 -3
  45. package/src/host/tools.ts +187 -126
  46. package/src/index.ts +10 -1
  47. package/src/shared/api.ts +11 -2
  48. package/src/shared/protocol.ts +83 -6
  49. package/src/shared/version.ts +1 -1
  50. package/src/client/board/NewTaskModal.tsx +0 -8
package/lib/client.js CHANGED
@@ -18,20 +18,26 @@ window.__ModuleLoader__.load({
18
18
  if (!body.ok) throw new Error(`taskboard: ${body.error.code}: ${body.error.message}`);
19
19
  return body.value;
20
20
  }
21
+ /** Request timeout (S15: a hung fetch must never pin refreshInFlight forever). */
22
+ const TIMEOUT_MS = 1e4;
23
+ async function get(path) {
24
+ return unwrap(fetch(path, { signal: AbortSignal.timeout(TIMEOUT_MS) }));
25
+ }
21
26
  async function post(path, body) {
22
27
  return unwrap(await fetch(path, {
23
28
  method: "POST",
24
29
  headers: { "content-type": "application/json" },
25
- body: JSON.stringify(body)
30
+ body: JSON.stringify(body),
31
+ signal: AbortSignal.timeout(TIMEOUT_MS)
26
32
  }));
27
33
  }
28
34
  /** Build the client over fetch + EventSource. */
29
35
  function createClient() {
30
36
  return {
31
- state: () => unwrap(fetch("/dsh-taskboard/state")),
32
- workspaces: () => unwrap(fetch("/dsh-taskboard/workspaces")),
37
+ state: () => get("/dsh-taskboard/state"),
38
+ workspaces: () => get("/dsh-taskboard/workspaces"),
33
39
  create: (body) => post("/dsh-taskboard/tasks", body),
34
- get: (id) => unwrap(fetch(`/dsh-taskboard/tasks/${encodeURIComponent(id)}`)),
40
+ get: (id) => get(`/dsh-taskboard/tasks/${encodeURIComponent(id)}`),
35
41
  update: (id, body) => post(`/dsh-taskboard/tasks/${encodeURIComponent(id)}/update`, body),
36
42
  move: (id, body) => post(`/dsh-taskboard/tasks/${encodeURIComponent(id)}/move`, body),
37
43
  reject: (id, body) => post(`/dsh-taskboard/tasks/${encodeURIComponent(id)}/reject`, body),
@@ -41,7 +47,7 @@ window.__ModuleLoader__.load({
41
47
  cancel: (id) => post(`/dsh-taskboard/tasks/${encodeURIComponent(id)}/cancel`, {}),
42
48
  mergeBranch: (id) => post(`/dsh-taskboard/tasks/${encodeURIComponent(id)}/merge`, {}),
43
49
  worktreeRemove: (id, body) => post(`/dsh-taskboard/tasks/${encodeURIComponent(id)}/worktree-remove`, body),
44
- diagnostics: () => unwrap(fetch("/dsh-taskboard/diagnostics")),
50
+ diagnostics: () => get("/dsh-taskboard/diagnostics"),
45
51
  worktreeCleanup: (workspaceId, taskId) => post("/dsh-taskboard/worktree-cleanup", {
46
52
  workspaceId,
47
53
  taskId
@@ -50,26 +56,38 @@ window.__ModuleLoader__.load({
50
56
  const params = new URLSearchParams({ execution: query.execution });
51
57
  if (query.commit !== void 0) params.set("commit", query.commit);
52
58
  if (query.path !== void 0) params.set("path", query.path);
53
- return unwrap(fetch(`/dsh-taskboard/tasks/${encodeURIComponent(taskId)}/diff?${params.toString()}`));
59
+ return get(`/dsh-taskboard/tasks/${encodeURIComponent(taskId)}/diff?${params.toString()}`);
54
60
  },
55
61
  importPreview: (file) => post("/dsh-taskboard/import/preview", file),
56
62
  importCommit: (mode, ledger) => post("/dsh-taskboard/import", {
57
63
  mode,
58
64
  ledger
59
65
  }),
60
- templates: () => unwrap(fetch("/dsh-taskboard/templates")),
66
+ templates: () => get("/dsh-taskboard/templates"),
61
67
  templateUpsert: (body) => post("/dsh-taskboard/templates", body),
62
68
  templateDelete: (id) => post("/dsh-taskboard/templates/delete", { id }),
69
+ settings: () => get("/dsh-taskboard/settings"),
70
+ updateSettings: (body) => post("/dsh-taskboard/settings/update", body),
63
71
  stream(onChange, onGap) {
64
72
  const es = new EventSource("/dsh-taskboard/events");
65
73
  let revision;
66
74
  const hello = (event) => {
67
- const payload = JSON.parse(event.data);
75
+ let payload;
76
+ try {
77
+ payload = JSON.parse(event.data);
78
+ } catch {
79
+ return;
80
+ }
68
81
  if (revision !== void 0 && payload.revision !== revision) onGap();
69
82
  revision = payload.revision;
70
83
  };
71
84
  const change = (event) => {
72
- const payload = JSON.parse(event.data);
85
+ let payload;
86
+ try {
87
+ payload = JSON.parse(event.data);
88
+ } catch {
89
+ return;
90
+ }
73
91
  if (revision !== void 0 && payload.revision !== revision + 1) onGap();
74
92
  revision = payload.revision;
75
93
  onChange(payload);
@@ -134,6 +152,16 @@ window.__ModuleLoader__.load({
134
152
  return TRANSITIONS[from].includes(to);
135
153
  }
136
154
  /**
155
+ * Factory-default isolation (0.5.0): 原目录执行. Applies when neither the
156
+ * task record nor the board setting (`BoardSettings.defaultIsolation`)
157
+ * says otherwise. Before 0.5.0 the implicit default was 'worktree'.
158
+ */
159
+ const DEFAULT_ISOLATION = "none";
160
+ /** The effective default isolation for NEW tasks (board setting → factory default). */
161
+ function defaultIsolationOf(settings) {
162
+ return settings?.defaultIsolation ?? "none";
163
+ }
164
+ /**
137
165
  * Parse a five-field cron expression. Supported field syntax: star, star/step
138
166
  * (`* / n` without spaces), a single number, an `a-b` range, and comma lists
139
167
  * of those. Day-of-week accepts both 0 and 7 as Sunday (normalized to 0).
@@ -238,22 +266,6 @@ window.__ModuleLoader__.load({
238
266
  //#region src/client/controller.ts
239
267
  /** localStorage key for persisted view state (filters + sort). */
240
268
  const VIEW_KEY = "dsh-taskboard-view-v1";
241
- /** localStorage key for the remembered isolation toggle choice (0.3.0). */
242
- const ISOLATION_KEY = "dsh-taskboard-isolation-v1";
243
- /** Load the remembered default isolation (worktree unless explicitly turned off). */
244
- function loadDefaultIsolation() {
245
- try {
246
- return localStorage.getItem(ISOLATION_KEY) === "none" ? "none" : "worktree";
247
- } catch {
248
- return "worktree";
249
- }
250
- }
251
- /** Remember the isolation toggle choice across forms (best effort). */
252
- function saveDefaultIsolation(mode) {
253
- try {
254
- localStorage.setItem(ISOLATION_KEY, mode);
255
- } catch {}
256
- }
257
269
  /** Load the persisted view state (never throws; fresh on any parse error). */
258
270
  function loadView() {
259
271
  try {
@@ -294,7 +306,8 @@ window.__ModuleLoader__.load({
294
306
  diagOpen: false,
295
307
  templates: [],
296
308
  tplManagerOpen: false,
297
- importOpen: false
309
+ importOpen: false,
310
+ settingsOpen: false
298
311
  };
299
312
  }
300
313
  /**
@@ -307,7 +320,11 @@ window.__ModuleLoader__.load({
307
320
  disposed = false;
308
321
  disposeStream;
309
322
  refreshInFlight;
323
+ /** Newest change-frame revision seen on the SSE stream (S16 refresh chase). */
324
+ seenRevision;
310
325
  sessionJumper;
326
+ /** Composer catalog faces, installed formally by the client entry (T13). */
327
+ catalogFaces = {};
311
328
  /** @param client - the route client. */
312
329
  constructor(client) {
313
330
  this.client = client;
@@ -336,10 +353,7 @@ window.__ModuleLoader__.load({
336
353
  start() {
337
354
  this.refresh();
338
355
  this.disposeStream = this.client.stream((change) => {
339
- this.setState({ ledger: {
340
- ...this.state.ledger,
341
- revision: change.revision
342
- } });
356
+ this.seenRevision = change.revision;
343
357
  this.refresh();
344
358
  }, () => {
345
359
  this.refresh();
@@ -350,15 +364,18 @@ window.__ModuleLoader__.load({
350
364
  if (this.refreshInFlight !== void 0) return this.refreshInFlight;
351
365
  this.refreshInFlight = (async () => {
352
366
  try {
353
- const [ledger, workspaces] = await Promise.all([this.client.state(), this.client.workspaces()]);
354
- let selected;
355
- if (this.state.selectedId !== void 0) selected = ledger.tasks.find((t) => t.id === this.state.selectedId);
356
- this.setState({
357
- ledger,
358
- workspaces,
359
- error: void 0,
360
- selectedId: selected === void 0 ? void 0 : this.state.selectedId
361
- });
367
+ for (let round = 0; round < 3; round++) {
368
+ const [ledger, workspaces] = await Promise.all([this.client.state(), this.client.workspaces()]);
369
+ let selected;
370
+ if (this.state.selectedId !== void 0) selected = ledger.tasks.find((t) => t.id === this.state.selectedId);
371
+ this.setState({
372
+ ledger,
373
+ workspaces,
374
+ error: void 0,
375
+ selectedId: selected === void 0 ? void 0 : this.state.selectedId
376
+ });
377
+ if (this.seenRevision === void 0 || ledger.revision >= this.seenRevision) break;
378
+ }
362
379
  } catch (error) {
363
380
  this.setState({ error: error instanceof Error ? error.message : String(error) });
364
381
  } finally {
@@ -476,6 +493,22 @@ window.__ModuleLoader__.load({
476
493
  installSessionJumper(jumper) {
477
494
  this.sessionJumper = jumper;
478
495
  }
496
+ /** T13: formal installers for the composer catalog faces (was a monkeypatch from the client entry). */
497
+ installModelCatalog(fn) {
498
+ this.catalogFaces.models = fn;
499
+ }
500
+ /** T13: formal installer for the preset roster face. */
501
+ installPresetRoster(fn) {
502
+ this.catalogFaces.presets = fn;
503
+ }
504
+ /** The installed model catalog face, when the runtime provides one. */
505
+ get modelCatalog() {
506
+ return this.catalogFaces.models;
507
+ }
508
+ /** The installed preset roster face, when the runtime provides one. */
509
+ get presetCatalog() {
510
+ return this.catalogFaces.presets;
511
+ }
479
512
  /**
480
513
  * Jump to an execution's session (open it in the GUI). On success the board
481
514
  * closes so the conversation shows; a deleted-or-archived session reports
@@ -609,13 +642,18 @@ window.__ModuleLoader__.load({
609
642
  return;
610
643
  }
611
644
  }
612
- /** Append a user comment. */
645
+ /**
646
+ * Append a user comment. Returns whether it landed — the composer keeps its
647
+ * text on failure (T13: it used to clear unconditionally and lose the draft).
648
+ */
613
649
  async comment(id, body) {
614
650
  try {
615
651
  await this.client.comment(id, body);
616
652
  await this.refresh();
653
+ return true;
617
654
  } catch (error) {
618
655
  this.setState({ error: error instanceof Error ? error.message : String(error) });
656
+ return false;
619
657
  }
620
658
  }
621
659
  /** Trigger a manual run (fresh in-project session, pinned model); `reuse` = 续跑. */
@@ -683,6 +721,29 @@ window.__ModuleLoader__.load({
683
721
  closeDiagnostics() {
684
722
  this.setState({ diagOpen: false });
685
723
  }
724
+ /** Open the board-settings modal (0.5.0). */
725
+ openSettings() {
726
+ this.setState({ settingsOpen: true });
727
+ }
728
+ /** Close the board-settings modal. */
729
+ closeSettings() {
730
+ this.setState({ settingsOpen: false });
731
+ }
732
+ /**
733
+ * Replace board settings (0.5.0). The host broadcasts a settings-updated
734
+ * frame; refresh() pulls ledger.settings so every open view follows.
735
+ * @returns whether the write succeeded.
736
+ */
737
+ async updateSettings(body) {
738
+ try {
739
+ await this.client.updateSettings(body);
740
+ await this.refresh();
741
+ return true;
742
+ } catch (error) {
743
+ this.setState({ error: error instanceof Error ? error.message : String(error) });
744
+ return false;
745
+ }
746
+ }
686
747
  /** Clean one orphan worktree (⚙ panel); refreshes the diagnostics payload. */
687
748
  async cleanupOrphan(workspaceId, taskId) {
688
749
  try {
@@ -710,7 +771,7 @@ window.__ModuleLoader__.load({
710
771
  async duplicate(task) {
711
772
  try {
712
773
  await this.client.create({
713
- title: `${task.title}(副本)`,
774
+ title: `${task.title.slice(0, 196)}(副本)`,
714
775
  workspaceId: task.workspaceId,
715
776
  urgency: task.urgency,
716
777
  description: task.description.length > 0 ? task.description : void 0,
@@ -835,7 +896,8 @@ window.__ModuleLoader__.load({
835
896
  /** Download the task list as a CSV (BOM-prefixed for Excel + Chinese text). */
836
897
  exportCsv() {
837
898
  const esc = (v) => {
838
- const s = String(v ?? "");
899
+ let s = String(v ?? "");
900
+ if (/^[=+\-@\t\r]/.test(s)) s = `'${s}`;
839
901
  return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, "\"\"")}"` : s;
840
902
  };
841
903
  const header = [
@@ -1585,6 +1647,10 @@ window.__ModuleLoader__.load({
1585
1647
  .dsh-atb-imp-row-status { font-size: 10.5px; color: var(--dsw-alias-label-tertiary, gray); flex-shrink: 0; }
1586
1648
  .dsh-atb-imp-result { font-size: 12px; color: var(--dsw-alias-state-success-primary, #30a46c); margin-top: 10px; }
1587
1649
  .dsh-atb-badge[data-kind="checklist"] { color: var(--dsw-alias-label-secondary, inherit); }
1650
+ /* ---------- 0.5.0 board settings ---------- */
1651
+ .dsh-atb-set { max-width: 460px; width: min(460px, 92vw); }
1652
+ .dsh-atb-set .dsh-atb-mode-picker { margin-top: 8px; }
1653
+ .dsh-atb-set .dsh-atb-isolation-note { margin-top: 10px; }
1588
1654
  `;
1589
1655
  /** Style element id (stable since 0.1.x: hook for tests and debugging). */
1590
1656
  const STYLE_ID = "dsh-taskboard-styles";
@@ -1794,7 +1860,8 @@ window.__ModuleLoader__.load({
1794
1860
  found: false,
1795
1861
  placed: false
1796
1862
  };
1797
- window.__atbDebug = debug;
1863
+ const host = globalThis.location?.hostname;
1864
+ if (host === "localhost" || host === "127.0.0.1") window.__atbDebug = debug;
1798
1865
  let root;
1799
1866
  let placed = false;
1800
1867
  const tryPlace = () => {
@@ -1865,7 +1932,70 @@ window.__ModuleLoader__.load({
1865
1932
  * @module dsh-taskboard/shared/version
1866
1933
  */
1867
1934
  /** The package version (must equal package.json "version"). */
1868
- const PLUGIN_VERSION = "0.4.5";
1935
+ const PLUGIN_VERSION = "0.5.1";
1936
+
1937
+ //#endregion
1938
+ //#region src/client/board/labels.ts
1939
+ /** Column headers on the five-column main board (+ secondary tab). */
1940
+ const COLUMN_LABELS = {
1941
+ backlog: "待规划",
1942
+ todo: "待办",
1943
+ in_progress: "进行中",
1944
+ in_review: "待验收",
1945
+ done: "已完成",
1946
+ canceled: "已取消",
1947
+ archived: "已归档"
1948
+ };
1949
+ /** Status pill text (detail pane) — historical wording kept verbatim:
1950
+ * terminal states read short here, the column headers carry the full forms. */
1951
+ const STATUS_LABEL = {
1952
+ backlog: "待规划",
1953
+ todo: "待办",
1954
+ in_progress: "进行中",
1955
+ in_review: "待验收",
1956
+ done: "完成",
1957
+ canceled: "取消",
1958
+ archived: "归档"
1959
+ };
1960
+ /** Move-button verbs (shorter than the pill text). */
1961
+ const MOVE_LABEL = {
1962
+ backlog: "待规划",
1963
+ todo: "待办",
1964
+ in_progress: "进行中",
1965
+ in_review: "待验收",
1966
+ done: "完成",
1967
+ canceled: "取消",
1968
+ archived: "归档"
1969
+ };
1970
+ /** Urgency chip labels. */
1971
+ const URGENCY_LABEL = {
1972
+ urgent: "紧急",
1973
+ normal: "一般",
1974
+ relaxed: "不急"
1975
+ };
1976
+ /** Execution outcome labels. */
1977
+ const OUTCOME_LABEL = {
1978
+ running: "执行中",
1979
+ succeeded: "成功",
1980
+ failed: "失败",
1981
+ cancelled: "已取消"
1982
+ };
1983
+
1984
+ //#endregion
1985
+ //#region src/client/board/format.ts
1986
+ /** Format an epoch ms as a short local stamp. */
1987
+ function fmtTime(ms) {
1988
+ if (ms === void 0) return "";
1989
+ const d = new Date(ms);
1990
+ const pad = (n) => String(n).padStart(2, "0");
1991
+ return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
1992
+ }
1993
+ /** A claim idle for longer than this is highlighted as stale (ms). */
1994
+ const STALE_CLAIM_MS = 30 * 6e4;
1995
+ /** Whether the task's claim is stale (in_progress, held, idle too long). */
1996
+ function isStaleClaim(task, now) {
1997
+ return task.status === "in_progress" && task.claimedAt !== void 0 && now - task.claimedAt > 18e5;
1998
+ }
1869
1999
 
1870
2000
  //#endregion
1871
2001
  //#region src/client/board/TaskCard.tsx
@@ -1882,17 +2012,6 @@ window.__ModuleLoader__.load({
1882
2012
  *
1883
2013
  * @module dsh-taskboard/client/board/TaskCard
1884
2014
  */
1885
- const URGENCY_LABEL$1 = {
1886
- urgent: "紧急",
1887
- normal: "一般",
1888
- relaxed: "不急"
1889
- };
1890
- const OUTCOME_LABEL$1 = {
1891
- running: "执行中",
1892
- succeeded: "成功",
1893
- failed: "失败",
1894
- cancelled: "已取消"
1895
- };
1896
2015
  /** dataTransfer type carrying the dragged task id. */
1897
2016
  const DRAG_TYPE = "application/x-dsh-atb-task";
1898
2017
  /**
@@ -1928,7 +2047,7 @@ window.__ModuleLoader__.load({
1928
2047
  onDragStart: (e) => {
1929
2048
  if (running !== void 0) {
1930
2049
  e.preventDefault();
1931
- const msg = `该任务正在由【${task.title}】会话执行,不能拖动`;
2050
+ const msg = `该任务正由会话执行中(${task.title}),不能拖动`;
1932
2051
  if (onAlert !== void 0) onAlert(msg);
1933
2052
  else alert(msg);
1934
2053
  return;
@@ -1958,7 +2077,7 @@ window.__ModuleLoader__.load({
1958
2077
  children: [
1959
2078
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1960
2079
  className: "dsh-atb-badge",
1961
- children: URGENCY_LABEL$1[task.urgency]
2080
+ children: URGENCY_LABEL[task.urgency]
1962
2081
  }),
1963
2082
  task.blocked && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1964
2083
  className: "dsh-atb-badge",
@@ -1998,7 +2117,7 @@ window.__ModuleLoader__.load({
1998
2117
  last !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1999
2118
  className: "dsh-atb-badge",
2000
2119
  "data-kind": last.outcome === "running" ? "running" : last.outcome,
2001
- children: OUTCOME_LABEL$1[last.outcome] ?? last.outcome
2120
+ children: OUTCOME_LABEL[last.outcome] ?? last.outcome
2002
2121
  }),
2003
2122
  task.comments.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: ["💬 ", task.comments.length] }),
2004
2123
  task.trashedAt !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
@@ -2151,27 +2270,6 @@ window.__ModuleLoader__.load({
2151
2270
  "archived"
2152
2271
  ].filter((to) => canTransition(task.status, to));
2153
2272
  }
2154
- const MOVE_LABEL = {
2155
- backlog: "待规划",
2156
- todo: "待办",
2157
- in_progress: "进行中",
2158
- in_review: "待验收",
2159
- done: "完成",
2160
- canceled: "取消",
2161
- archived: "归档"
2162
- };
2163
- const STATUS_LABEL = { ...MOVE_LABEL };
2164
- const URGENCY_LABEL = {
2165
- urgent: "紧急",
2166
- normal: "一般",
2167
- relaxed: "不急"
2168
- };
2169
- const OUTCOME_LABEL = {
2170
- running: "执行中",
2171
- succeeded: "成功",
2172
- failed: "失败",
2173
- cancelled: "已取消"
2174
- };
2175
2273
  /** Compact session-id display (execution sessions carry the taskboard infix). */
2176
2274
  function shortId(id) {
2177
2275
  if (id === void 0) return "";
@@ -2640,6 +2738,7 @@ window.__ModuleLoader__.load({
2640
2738
  const [confirmDone, setConfirmDone] = (0, react.useState)(false);
2641
2739
  const [confirmPurge, setConfirmPurge] = (0, react.useState)(false);
2642
2740
  const [confirmCancel, setConfirmCancel] = (0, react.useState)(false);
2741
+ const [actionBusy, setActionBusy] = (0, react.useState)(false);
2643
2742
  const { alert: showAlert, el: alertEl } = useAlert();
2644
2743
  const ws = controller.getSnapshot().workspaces.find((w) => w.id === task.workspaceId);
2645
2744
  const canRun = task.status !== "in_progress" && task.status !== "done" && task.status !== "archived";
@@ -2647,6 +2746,12 @@ window.__ModuleLoader__.load({
2647
2746
  const holder = task.status === "in_progress" ? task.claimedBy : void 0;
2648
2747
  const stale = now !== void 0 && isStaleClaim(task, now);
2649
2748
  const unchecked = (task.checklist ?? []).filter((i) => !i.checked).length;
2749
+ /** Fire one top action under the shared busy guard; re-enable on settle. */
2750
+ const runAction = (action) => {
2751
+ if (actionBusy) return;
2752
+ setActionBusy(true);
2753
+ action().catch(() => void 0).finally(() => setActionBusy(false));
2754
+ };
2650
2755
  /** Jump to an execution's session; prompt precisely when it cannot open. */
2651
2756
  const jumpToSession = (sessionId) => {
2652
2757
  controller.openSession(sessionId).then((result) => {
@@ -2746,7 +2851,7 @@ window.__ModuleLoader__.load({
2746
2851
  "更新 ",
2747
2852
  fmtTime(task.updatedAt),
2748
2853
  " · 最近操作 ",
2749
- task.updatedBy.kind === "agent" ? `🤖 ${shortId(task.updatedBy.sessionId)}` : "👤 用户"
2854
+ task.updatedBy.kind === "agent" ? `🤖 ${shortId(task.updatedBy.sessionId)}` : task.updatedBy.kind === "system" ? "⚙️ 系统" : "👤 用户"
2750
2855
  ]
2751
2856
  })
2752
2857
  ]
@@ -2763,32 +2868,34 @@ window.__ModuleLoader__.load({
2763
2868
  type: "button",
2764
2869
  className: "dsh-atb-detail-edit",
2765
2870
  title: "复制此任务的全部配置为一张新卡(待办列)",
2766
- onClick: () => void controller.duplicate(task),
2871
+ disabled: actionBusy,
2872
+ onClick: () => runAction(() => controller.duplicate(task)),
2767
2873
  children: "⧉ 复制"
2768
2874
  }),
2769
2875
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2770
2876
  type: "button",
2771
2877
  className: "dsh-atb-detail-edit",
2772
2878
  title: "把此任务的配置(含清单)保存为模板,新建任务时可用",
2773
- onClick: () => {
2774
- controller.saveAsTemplate(task).then((ok) => {
2775
- if (ok) showAlert("已存为模板(新建任务 ▼ 下拉可用,可在模板管理中改名)");
2776
- });
2777
- },
2879
+ disabled: actionBusy,
2880
+ onClick: () => runAction(async () => {
2881
+ if (await controller.saveAsTemplate(task)) showAlert("已存为模板(新建任务 ▼ 下拉可用,可在模板管理中改名)");
2882
+ }),
2778
2883
  children: "⌗ 存为模板"
2779
2884
  }),
2780
2885
  canRun && task.branch !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2781
2886
  type: "button",
2782
2887
  className: "dsh-atb-detail-run",
2783
2888
  title: "续跑:保留现有 worktree 与分支(上次的改动和提交都在原处),在其上继续执行;默认「立即执行」会重置为全新基线",
2784
- onClick: () => void controller.run(task.id, true),
2889
+ disabled: actionBusy,
2890
+ onClick: () => runAction(() => controller.run(task.id, true)),
2785
2891
  children: "↻ 续跑"
2786
2892
  }),
2787
2893
  canRun && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2788
2894
  type: "button",
2789
2895
  className: "dsh-atb-detail-run",
2790
2896
  title: task.model !== void 0 ? `新会话执行(${task.model.model})` : "新会话执行(默认模型)",
2791
- onClick: () => void controller.run(task.id),
2897
+ disabled: actionBusy,
2898
+ onClick: () => runAction(() => controller.run(task.id)),
2792
2899
  children: "▶ 立即执行"
2793
2900
  }),
2794
2901
  runningExecution !== void 0 && (confirmCancel ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
@@ -2962,18 +3069,18 @@ window.__ModuleLoader__.load({
2962
3069
  placeholder: "以用户身份留言(agent 开工前会读)…",
2963
3070
  onChange: (e) => setComment(e.target.value),
2964
3071
  onKeyDown: (e) => {
2965
- if ((e.ctrlKey || e.metaKey) && e.key === "Enter" && comment.trim().length > 0) {
2966
- controller.comment(task.id, comment);
2967
- setComment("");
2968
- }
3072
+ if ((e.ctrlKey || e.metaKey) && e.key === "Enter" && comment.trim().length > 0) controller.comment(task.id, comment).then((ok) => {
3073
+ if (ok) setComment("");
3074
+ });
2969
3075
  }
2970
3076
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2971
3077
  type: "button",
2972
3078
  className: "dsh-atb-composer-send",
2973
3079
  disabled: comment.trim().length === 0,
2974
3080
  onClick: () => {
2975
- controller.comment(task.id, comment);
2976
- setComment("");
3081
+ controller.comment(task.id, comment).then((ok) => {
3082
+ if (ok) setComment("");
3083
+ });
2977
3084
  },
2978
3085
  children: "发表"
2979
3086
  })]
@@ -3231,12 +3338,13 @@ window.__ModuleLoader__.load({
3231
3338
  const [presetId, setPresetId] = (0, react.useState)(initialPreset);
3232
3339
  const [presets, setPresets] = (0, react.useState)([]);
3233
3340
  const [presetDefault, setPresetDefault] = (0, react.useState)(void 0);
3234
- const [isolation, setIsolation] = (0, react.useState)(task?.isolation ?? (prefill?.isolation === "none" ? "none" : prefill?.isolation === "worktree" ? "worktree" : loadDefaultIsolation()));
3341
+ const [isolation, setIsolation] = (0, react.useState)(task?.isolation ?? (prefill?.isolation === "none" ? "none" : prefill?.isolation === "worktree" ? "worktree" : defaultIsolationOf(state.ledger.settings)));
3235
3342
  const [checkRows, setCheckRows] = (0, react.useState)(task?.checklist !== void 0 && task.checklist.length > 0 ? task.checklist.map((i) => ({ ...i })) : (prefill?.checklist ?? []).map((text) => ({
3236
3343
  text,
3237
3344
  checked: false
3238
3345
  })));
3239
3346
  const titleRef = (0, react.useRef)(null);
3347
+ const [busy, setBusy] = (0, react.useState)(false);
3240
3348
  (0, react.useEffect)(() => {
3241
3349
  titleRef.current?.focus();
3242
3350
  const onKey = (e) => {
@@ -3256,10 +3364,11 @@ window.__ModuleLoader__.load({
3256
3364
  face().then((roster) => {
3257
3365
  setPresets(roster.presets);
3258
3366
  setPresetDefault(roster.defaultId);
3259
- if (task?.presetId === void 0 && initialPreset === "" && roster.defaultId !== void 0) setPresetId(roster.defaultId);
3367
+ if (!editing && initialPreset === "" && roster.defaultId !== void 0) setPresetId(roster.defaultId);
3260
3368
  }).catch(() => setPresets([]));
3261
3369
  }, [
3262
3370
  controller,
3371
+ editing,
3263
3372
  task?.presetId,
3264
3373
  initialPreset
3265
3374
  ]);
@@ -3271,10 +3380,12 @@ window.__ModuleLoader__.load({
3271
3380
  const isolationLocked = editing && ((task.executions?.length ?? 0) > 0 || task.status === "in_progress");
3272
3381
  const gitOk = controller.gitAvailable(workspaceId);
3273
3382
  const isolationDisabled = isolationLocked || !gitOk;
3274
- /** Isolation payload for submit: undefined keeps the default (degrades naturally). */
3383
+ /**
3384
+ * Isolation payload for submit: undefined lets the HOST materialize the
3385
+ * current board default at creation (non-git projects degrade naturally).
3386
+ */
3275
3387
  const isolationPayload = () => {
3276
3388
  if (!gitOk) return void 0;
3277
- if (!editing) saveDefaultIsolation(isolation);
3278
3389
  return isolation;
3279
3390
  };
3280
3391
  /** Preset payload: '' = follow the deployment default (submit omits). */
@@ -3285,12 +3396,13 @@ window.__ModuleLoader__.load({
3285
3396
  text: r.text.trim()
3286
3397
  })).filter((r) => r.text.length > 0);
3287
3398
  const submit = () => {
3288
- if (!valid) return;
3399
+ if (!valid || busy) return;
3289
3400
  const picked = model !== "" ? JSON.parse(model) : void 0;
3290
3401
  const isolationOut = isolationPayload();
3291
3402
  const presetOut = presetPayload();
3292
3403
  const rows = filledRows();
3293
- if (editing) controller.update(task.id, task.version, {
3404
+ setBusy(true);
3405
+ (editing ? controller.update(task.id, task.version, {
3294
3406
  title,
3295
3407
  description,
3296
3408
  prompt,
@@ -3304,8 +3416,7 @@ window.__ModuleLoader__.load({
3304
3416
  ...isolationOut !== void 0 && !isolationLocked ? { isolation: isolationOut } : {},
3305
3417
  presetId: presetOut ?? null,
3306
3418
  checklist: rows.length > 0 ? rows : null
3307
- });
3308
- else controller.create({
3419
+ }) : controller.create({
3309
3420
  title,
3310
3421
  workspaceId,
3311
3422
  urgency,
@@ -3319,50 +3430,52 @@ window.__ModuleLoader__.load({
3319
3430
  ...isolationOut !== void 0 ? { isolation: isolationOut } : {},
3320
3431
  ...presetOut !== void 0 ? { presetId: presetOut } : {},
3321
3432
  ...rows.length > 0 ? { checklist: rows.map((r) => r.text) } : {}
3322
- });
3433
+ })).catch(() => void 0).finally(() => setBusy(false));
3323
3434
  };
3324
3435
  /** Save the form, then immediately trigger a manual run of the task. */
3325
3436
  const submitAndRun = () => {
3326
- if (!valid || runBlocked) return;
3437
+ if (!valid || runBlocked || busy) return;
3327
3438
  const picked = model !== "" ? JSON.parse(model) : void 0;
3328
3439
  const isolationOut = isolationPayload();
3329
3440
  const presetOut = presetPayload();
3330
3441
  const rows = filledRows();
3331
- if (editing) (async () => {
3332
- if (await controller.update(task.id, task.version, {
3333
- title,
3334
- description,
3335
- prompt,
3336
- urgency,
3337
- workspaceId,
3338
- execution: mode === "scheduled" ? {
3339
- mode,
3340
- cron: cron.trim()
3341
- } : { mode },
3342
- model: picked ?? null,
3343
- ...isolationOut !== void 0 && !isolationLocked ? { isolation: isolationOut } : {},
3344
- presetId: presetOut ?? null,
3345
- checklist: rows.length > 0 ? rows : null
3346
- })) await controller.run(task.id);
3347
- })();
3348
- else (async () => {
3349
- const id = await controller.create({
3350
- title,
3351
- workspaceId,
3352
- urgency,
3353
- description: description.length > 0 ? description : void 0,
3354
- prompt: prompt.length > 0 ? prompt : void 0,
3355
- execution: mode === "scheduled" ? {
3356
- mode,
3357
- cron: cron.trim()
3358
- } : { mode },
3359
- model: picked,
3360
- ...isolationOut !== void 0 ? { isolation: isolationOut } : {},
3361
- ...presetOut !== void 0 ? { presetId: presetOut } : {},
3362
- ...rows.length > 0 ? { checklist: rows.map((r) => r.text) } : {}
3363
- });
3364
- if (id !== void 0) await controller.run(id);
3365
- })();
3442
+ setBusy(true);
3443
+ (async () => {
3444
+ if (editing) {
3445
+ if (await controller.update(task.id, task.version, {
3446
+ title,
3447
+ description,
3448
+ prompt,
3449
+ urgency,
3450
+ workspaceId,
3451
+ execution: mode === "scheduled" ? {
3452
+ mode,
3453
+ cron: cron.trim()
3454
+ } : { mode },
3455
+ model: picked ?? null,
3456
+ ...isolationOut !== void 0 && !isolationLocked ? { isolation: isolationOut } : {},
3457
+ presetId: presetOut ?? null,
3458
+ checklist: rows.length > 0 ? rows : null
3459
+ })) await controller.run(task.id);
3460
+ } else {
3461
+ const id = await controller.create({
3462
+ title,
3463
+ workspaceId,
3464
+ urgency,
3465
+ description: description.length > 0 ? description : void 0,
3466
+ prompt: prompt.length > 0 ? prompt : void 0,
3467
+ execution: mode === "scheduled" ? {
3468
+ mode,
3469
+ cron: cron.trim()
3470
+ } : { mode },
3471
+ model: picked,
3472
+ ...isolationOut !== void 0 ? { isolation: isolationOut } : {},
3473
+ ...presetOut !== void 0 ? { presetId: presetOut } : {},
3474
+ ...rows.length > 0 ? { checklist: rows.map((r) => r.text) } : {}
3475
+ });
3476
+ if (id !== void 0) await controller.run(id);
3477
+ }
3478
+ })().catch(() => void 0).finally(() => setBusy(false));
3366
3479
  };
3367
3480
  const hint = !valid ? title.trim().length === 0 ? "请填写标题" : workspaceId === "" ? "请选择项目" : "Cron 表达式无效(分 时 日 月 周)" : mode === "scheduled" && nextRun !== null ? `下次运行 ${fmtTime(nextRun)}` : editing ? `保存后版本 v${task.version} → v${task.version + 1}` : "创建后项目内会话可认领执行";
3368
3481
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
@@ -3628,8 +3741,8 @@ window.__ModuleLoader__.load({
3628
3741
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3629
3742
  type: "button",
3630
3743
  className: "dsh-atb-btn",
3631
- disabled: !valid || runBlocked,
3632
- title: runBlocked ? "任务正在执行中,不能重复发起" : "保存后立即发起执行(新会话)",
3744
+ disabled: !valid || runBlocked || busy,
3745
+ title: runBlocked ? "任务正在执行中,不能重复发起" : busy ? "正在提交…" : "保存后立即发起执行(新会话)",
3633
3746
  onClick: submitAndRun,
3634
3747
  children: "⚡ 立即执行"
3635
3748
  }),
@@ -3637,7 +3750,7 @@ window.__ModuleLoader__.load({
3637
3750
  type: "button",
3638
3751
  className: "dsh-atb-btn",
3639
3752
  "data-primary": "true",
3640
- disabled: !valid,
3753
+ disabled: !valid || busy,
3641
3754
  onClick: submit,
3642
3755
  children: editing ? "保存修改" : "创建任务"
3643
3756
  })
@@ -3649,6 +3762,133 @@ window.__ModuleLoader__.load({
3649
3762
  });
3650
3763
  }
3651
3764
 
3765
+ //#endregion
3766
+ //#region src/client/board/SettingsModal.tsx
3767
+ /**
3768
+ * Board-settings modal (0.5.0): the user-owned defaults applied when a NEW
3769
+ * task is created without an explicit choice. Currently one section — 默认执行
3770
+ * 隔离 (worktree vs original directory); further sections can slot into the
3771
+ * body below. Saving goes through the host route (whole-object replace) and
3772
+ * the SSE change stream refreshes every open view.
3773
+ *
3774
+ * @module dsh-taskboard/client/board/SettingsModal
3775
+ */
3776
+ /** The isolation options with one-line hints (mirrors the task form). */
3777
+ const ISOLATION_OPTIONS = [{
3778
+ value: "none",
3779
+ name: "📁 原目录执行",
3780
+ hint: "不使用 git,直接在项目目录工作(出厂默认)"
3781
+ }, {
3782
+ value: "worktree",
3783
+ name: "🌿 Worktree 隔离",
3784
+ hint: "每次执行在独立 worktree 分支上进行(task/标题+ID),互不污染"
3785
+ }];
3786
+ /**
3787
+ * The 看板设置 modal: reads the live ledger settings, stages a local draft,
3788
+ * and writes back through the controller on save.
3789
+ * @param controller - the board controller.
3790
+ */
3791
+ function SettingsModal({ controller }) {
3792
+ const current = controller.getSnapshot().ledger.settings?.defaultIsolation ?? "none";
3793
+ const [draft, setDraft] = (0, react.useState)(current);
3794
+ const dirty = draft !== current;
3795
+ const save = () => {
3796
+ controller.updateSettings({ defaultIsolation: draft }).then((ok) => {
3797
+ if (ok) controller.closeSettings();
3798
+ });
3799
+ };
3800
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3801
+ className: "dsh-atb-modal-backdrop",
3802
+ onClick: (e) => {
3803
+ if (e.target === e.currentTarget) controller.closeSettings();
3804
+ },
3805
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3806
+ className: "dsh-atb-modal dsh-atb-set",
3807
+ role: "dialog",
3808
+ "aria-modal": "true",
3809
+ "aria-label": "看板设置",
3810
+ children: [
3811
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3812
+ className: "dsh-atb-modal-head",
3813
+ children: [
3814
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3815
+ className: "dsh-atb-modal-headicon",
3816
+ children: "🛠"
3817
+ }),
3818
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3819
+ className: "dsh-atb-modal-headtext",
3820
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", { children: "看板设置" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: "新建任务时应用的默认值(不影响已有任务)" })]
3821
+ }),
3822
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3823
+ type: "button",
3824
+ className: "dsh-atb-modal-close",
3825
+ "aria-label": "关闭",
3826
+ onClick: () => controller.closeSettings(),
3827
+ children: "✕"
3828
+ })
3829
+ ]
3830
+ }),
3831
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3832
+ className: "dsh-atb-modal-body",
3833
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
3834
+ className: "dsh-atb-diag-sec",
3835
+ children: [
3836
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("h4", { children: "默认执行隔离" }),
3837
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3838
+ className: "dsh-atb-mode-picker",
3839
+ children: ISOLATION_OPTIONS.map((o) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
3840
+ type: "button",
3841
+ className: "dsh-atb-mode-opt",
3842
+ "data-on": draft === o.value,
3843
+ title: o.hint,
3844
+ onClick: () => setDraft(o.value),
3845
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3846
+ className: "dsh-atb-mode-name",
3847
+ children: o.name
3848
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3849
+ className: "dsh-atb-mode-hint",
3850
+ children: o.hint
3851
+ })]
3852
+ }, o.value))
3853
+ }),
3854
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
3855
+ className: "dsh-atb-isolation-note",
3856
+ children: [
3857
+ "当前保存的默认:",
3858
+ current === "worktree" ? "🌿 Worktree 隔离" : "📁 原目录执行",
3859
+ "。 仅影响之后新建的任务;已有任务保持创建时的选择,非 git 项目运行时仍自动降级原目录。"
3860
+ ]
3861
+ })
3862
+ ]
3863
+ })
3864
+ }),
3865
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3866
+ className: "dsh-atb-modal-foot",
3867
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3868
+ className: "dsh-atb-modal-hint",
3869
+ children: dirty ? "有未保存的修改" : "与看板当前设置一致"
3870
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
3871
+ className: "dsh-atb-modal-footbtns",
3872
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3873
+ type: "button",
3874
+ className: "dsh-atb-btn",
3875
+ onClick: () => controller.closeSettings(),
3876
+ children: "取消"
3877
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3878
+ type: "button",
3879
+ className: "dsh-atb-btn",
3880
+ "data-primary": "true",
3881
+ disabled: !dirty,
3882
+ onClick: save,
3883
+ children: "保存设置"
3884
+ })]
3885
+ })]
3886
+ })
3887
+ ]
3888
+ })
3889
+ });
3890
+ }
3891
+
3652
3892
  //#endregion
3653
3893
  //#region src/client/board/ImportModal.tsx
3654
3894
  /**
@@ -3892,7 +4132,7 @@ window.__ModuleLoader__.load({
3892
4132
  className: "dsh-atb-btn",
3893
4133
  "data-primary": "true",
3894
4134
  "data-danger": mode === "replace" && confirmReplace ? "true" : void 0,
3895
- disabled: plan === void 0 || busy || result !== void 0 && false,
4135
+ disabled: plan === void 0 || busy,
3896
4136
  onClick: commit,
3897
4137
  children: mode === "replace" && confirmReplace ? "确认整册替换" : "执行导入"
3898
4138
  })]
@@ -4082,35 +4322,6 @@ window.__ModuleLoader__.load({
4082
4322
  *
4083
4323
  * @module dsh-taskboard/client/board/TaskBoard
4084
4324
  */
4085
- /** Column labels. */
4086
- const COLUMN_LABELS = {
4087
- backlog: "待规划",
4088
- todo: "待办",
4089
- in_progress: "进行中",
4090
- in_review: "待验收",
4091
- done: "已完成",
4092
- canceled: "已取消",
4093
- archived: "已归档"
4094
- };
4095
- /** Urgency chip labels. */
4096
- const URGENCY_LABELS = {
4097
- urgent: "紧急",
4098
- normal: "一般",
4099
- relaxed: "不急"
4100
- };
4101
- /** Format an epoch ms as a short local stamp. */
4102
- function fmtTime(ms) {
4103
- if (ms === void 0) return "";
4104
- const d = new Date(ms);
4105
- const pad = (n) => String(n).padStart(2, "0");
4106
- return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
4107
- }
4108
- /** A claim idle for longer than this is highlighted as stale (ms). */
4109
- const STALE_CLAIM_MS = 30 * 6e4;
4110
- /** Whether the task's claim is stale (in_progress, held, idle too long). */
4111
- function isStaleClaim(task, now) {
4112
- return task.status === "in_progress" && task.claimedAt !== void 0 && now - task.claimedAt > 18e5;
4113
- }
4114
4325
  /** Urgency sort rank (urgent first). */
4115
4326
  const URGENCY_RANK = {
4116
4327
  urgent: 0,
@@ -4144,6 +4355,8 @@ window.__ModuleLoader__.load({
4144
4355
  const { alert: showAlert, el: alertEl } = useAlert();
4145
4356
  const [newMenuOpen, setNewMenuOpen] = (0, react.useState)(false);
4146
4357
  const closeMenu = () => setNewMenuOpen(false);
4358
+ const [exportOpen, setExportOpen] = (0, react.useState)(false);
4359
+ const closeExport = () => setExportOpen(false);
4147
4360
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4148
4361
  className: "dsh-atb-board",
4149
4362
  children: [
@@ -4189,7 +4402,7 @@ window.__ModuleLoader__.load({
4189
4402
  },
4190
4403
  children: "空白任务"
4191
4404
  }),
4192
- state.templates.map((t) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
4405
+ state.templates.map((t) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
4193
4406
  type: "button",
4194
4407
  className: "dsh-atb-newmenu-opt",
4195
4408
  title: t.task.description !== void 0 && t.task.description.length > 0 ? t.task.description.slice(0, 120) : t.name,
@@ -4197,7 +4410,7 @@ window.__ModuleLoader__.load({
4197
4410
  closeMenu();
4198
4411
  controller.newFromTemplate(t.task);
4199
4412
  },
4200
- children: [t.name, t.builtin === true ? "" : ""]
4413
+ children: t.name
4201
4414
  }, t.id)),
4202
4415
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { className: "dsh-atb-newmenu-sep" }),
4203
4416
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
@@ -4269,7 +4482,7 @@ window.__ModuleLoader__.load({
4269
4482
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4270
4483
  className: "dsh-atb-dot",
4271
4484
  "data-urgency": u
4272
- }), URGENCY_LABELS[u]]
4485
+ }), URGENCY_LABEL[u]]
4273
4486
  }, u)),
4274
4487
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
4275
4488
  type: "button",
@@ -4277,6 +4490,13 @@ window.__ModuleLoader__.load({
4277
4490
  onClick: () => controller.toggleSecondary(),
4278
4491
  children: state.secondaryOpen ? "返回看板" : "其它任务"
4279
4492
  }),
4493
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
4494
+ type: "button",
4495
+ className: "dsh-atb-btn",
4496
+ title: "看板设置:新建任务的默认执行隔离等",
4497
+ onClick: () => controller.openSettings(),
4498
+ children: "🛠 设置"
4499
+ }),
4280
4500
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
4281
4501
  type: "button",
4282
4502
  className: "dsh-atb-btn",
@@ -4291,19 +4511,39 @@ window.__ModuleLoader__.load({
4291
4511
  onClick: () => controller.openImport(),
4292
4512
  children: "⬆ 导入"
4293
4513
  }),
4294
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
4295
- type: "button",
4296
- className: "dsh-atb-btn",
4297
- title: "下载完整台账备份(JSON)",
4298
- onClick: () => controller.exportJson(),
4299
- children: " JSON"
4300
- }),
4301
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
4302
- type: "button",
4303
- className: "dsh-atb-btn",
4304
- title: "下载任务清单(CSV)",
4305
- onClick: () => controller.exportCsv(),
4306
- children: "⬇ CSV"
4514
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4515
+ className: "dsh-atb-newmenu",
4516
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
4517
+ type: "button",
4518
+ className: "dsh-atb-btn",
4519
+ title: "导出台账:完整 JSON 备份或任务清单 CSV",
4520
+ onClick: () => setExportOpen(!exportOpen),
4521
+ children: "⬇ 导出 "
4522
+ }), exportOpen && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4523
+ className: "dsh-atb-newmenu-backdrop",
4524
+ onClick: closeExport
4525
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4526
+ className: "dsh-atb-newmenu-list",
4527
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
4528
+ type: "button",
4529
+ className: "dsh-atb-newmenu-opt",
4530
+ title: "完整台账备份(含执行历史与看板设置),可用于导入恢复",
4531
+ onClick: () => {
4532
+ closeExport();
4533
+ controller.exportJson();
4534
+ },
4535
+ children: "完整台账(JSON)"
4536
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
4537
+ type: "button",
4538
+ className: "dsh-atb-newmenu-opt",
4539
+ title: "任务清单表格(Excel 可直接打开,中文已加 BOM)",
4540
+ onClick: () => {
4541
+ closeExport();
4542
+ controller.exportCsv();
4543
+ },
4544
+ children: "任务清单(CSV)"
4545
+ })]
4546
+ })] })]
4307
4547
  }),
4308
4548
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("a", {
4309
4549
  className: "dsh-atb-ver",
@@ -4385,13 +4625,14 @@ window.__ModuleLoader__.load({
4385
4625
  task: selected,
4386
4626
  controller,
4387
4627
  now
4388
- })
4628
+ }, selected.id)
4389
4629
  }),
4390
4630
  state.composerOpen && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TaskFormModal, {
4391
4631
  controller,
4392
4632
  task: state.editingId === void 0 ? void 0 : state.ledger.tasks.find((t) => t.id === state.editingId)
4393
4633
  }),
4394
4634
  state.diagOpen && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DiagnosticsPanel, { controller }),
4635
+ state.settingsOpen && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SettingsModal, { controller }),
4395
4636
  state.importOpen && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ImportModal, { controller }),
4396
4637
  state.tplManagerOpen && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TemplateManager, { controller }),
4397
4638
  alertEl
@@ -4734,7 +4975,7 @@ window.__ModuleLoader__.load({
4734
4975
  const controller = new BoardController(createClient());
4735
4976
  const connection = ctx.get?.("connection");
4736
4977
  if (connection !== void 0) {
4737
- controller.modelCatalog = async () => {
4978
+ controller.installModelCatalog(async () => {
4738
4979
  const response = await connection.api.llm.models({});
4739
4980
  if (!response.result.ok) return [];
4740
4981
  const out = [];
@@ -4744,8 +4985,8 @@ window.__ModuleLoader__.load({
4744
4985
  name: model.name
4745
4986
  });
4746
4987
  return out;
4747
- };
4748
- controller.presetCatalog = async () => {
4988
+ });
4989
+ controller.installPresetRoster(async () => {
4749
4990
  const list = connection.api.agentPresets;
4750
4991
  if (list === void 0) return { presets: [] };
4751
4992
  const response = await list.list({});
@@ -4759,7 +5000,7 @@ window.__ModuleLoader__.load({
4759
5000
  presets,
4760
5001
  ...def !== void 0 ? { defaultId: def.id } : {}
4761
5002
  };
4762
- };
5003
+ });
4763
5004
  }
4764
5005
  controller.installSessionJumper(createSessionJumper({
4765
5006
  getSessions: () => ctx.get?.("sessions"),