dsh-taskboard 0.1.1 → 0.1.2

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/README.md CHANGED
@@ -46,4 +46,21 @@ npm test # vitest 41 项
46
46
  node scripts/screenshot.mjs # 重新生成 img/ 截图(需本机 Edge)
47
47
  ```
48
48
 
49
+ ## 升级日志
50
+
51
+ ### 0.1.2
52
+
53
+ - **看板拖拽**:所有列的卡片可互相拖动(按状态机校验合法流转,非法拖放弹窗提示);正在执行的任务拖动会被拦截,提示「该任务正在由【任务名】会话执行,不能拖动」
54
+ - **执行会话体验**:执行会话标题固定为任务名(经 `sessionTitle.rename` 写入,不会被自动重命名覆盖);首条消息以正常用户消息呈现(不再是插件上下文行)
55
+ - **交互优化**:
56
+ - 新建/编辑弹框新增「⚡ 立即执行」按钮(保存后直接发起执行)
57
+ - 详情页「立即执行」移至「编辑」按钮旁
58
+ - 「+ 新建任务」移至看板标题统计旁
59
+ - 状态流转按钮文案统一为「移至→{状态}」
60
+ - **弹窗**:原生 `alert()` 全部替换为主题化模态弹窗(Esc/遮罩可关)
61
+
62
+ ### 0.1.1
63
+
64
+ - 初始 npm 发布:看板协作、8 个 agent 工具、手动/cron 执行、SSE 实时视图
65
+
49
66
  License: Apache-2.0
package/lib/client.js CHANGED
@@ -349,17 +349,19 @@ window.__ModuleLoader__.load({
349
349
  toggleSecondary() {
350
350
  this.setState({ secondaryOpen: !this.state.secondaryOpen });
351
351
  }
352
- /** Create a task (composer submit). */
352
+ /** Create a task (composer submit); returns the new task id, undefined on failure. */
353
353
  async create(body) {
354
354
  try {
355
- await this.client.create(body);
355
+ const summary = await this.client.create(body);
356
356
  this.setState({
357
357
  composerOpen: false,
358
358
  error: void 0
359
359
  });
360
360
  await this.refresh();
361
+ return summary.id;
361
362
  } catch (error) {
362
363
  this.setState({ error: error instanceof Error ? error.message : String(error) });
364
+ return;
363
365
  }
364
366
  }
365
367
  /** Edit task fields (form modal submit; the GUI is the owner surface). */
@@ -375,8 +377,10 @@ window.__ModuleLoader__.load({
375
377
  error: void 0
376
378
  });
377
379
  await this.refresh();
380
+ return true;
378
381
  } catch (error) {
379
382
  this.setState({ error: error instanceof Error ? error.message : String(error) });
383
+ return false;
380
384
  }
381
385
  }
382
386
  /** Move a task (user surface: done allowed). */
@@ -608,12 +612,12 @@ window.__ModuleLoader__.load({
608
612
  .dsh-atb-desc { white-space: pre-wrap; word-break: break-word; font-size: 13px; line-height: 1.55; }
609
613
 
610
614
  .dsh-atb-detail-actions { display: flex; flex-direction: column; gap: 8px; }
611
- .dsh-atb-runbtn {
612
- font: inherit; font-size: 13px; font-weight: 600; padding: 8px 14px; border-radius: 9px; cursor: pointer;
613
- border: 1px solid transparent; background: var(--dsw-alias-button-primary-fill, var(--dsw-alias-brand-primary, #1f2328)); color: var(--dsw-alias-label-primary-foreground, #fff); text-align: center;
615
+ .dsh-atb-detail-run {
616
+ font: inherit; font-size: 12px; font-weight: 600; padding: 4px 11px; border-radius: 7px; cursor: pointer;
617
+ border: 1px solid transparent; background: var(--dsw-alias-button-primary-fill, var(--dsw-alias-brand-primary, #1f2328)); color: var(--dsw-alias-label-primary-foreground, #fff);
614
618
  transition: filter .12s ease;
615
619
  }
616
- .dsh-atb-runbtn:hover { filter: brightness(1.1); }
620
+ .dsh-atb-detail-run:hover { filter: brightness(1.1); }
617
621
  .dsh-atb-movebtns { display: flex; gap: 6px; flex-wrap: wrap; }
618
622
  .dsh-atb-movebtn {
619
623
  font: inherit; font-size: 12px; padding: 4px 11px; border-radius: 999px; cursor: pointer;
@@ -812,6 +816,29 @@ window.__ModuleLoader__.load({
812
816
  .dsh-atb-secondary { flex: 1; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 8px; }
813
817
  .dsh-atb-link { color: var(--dsw-alias-state-business-primary, #3e63dd); cursor: pointer; text-decoration: none; }
814
818
  .dsh-atb-link:hover { text-decoration: underline; }
819
+
820
+ /* ---------- alert modal ---------- */
821
+ .dsh-atb-alert-backdrop {
822
+ position: fixed; inset: 0; z-index: 90;
823
+ background: var(--dsw-alias-bg-mask-drop, rgba(28,30,36,.4)); backdrop-filter: var(--dsw-mask-blur, blur(2px));
824
+ display: flex; align-items: center; justify-content: center;
825
+ animation: dsh-atb-fade .12s ease;
826
+ }
827
+ .dsh-atb-alert {
828
+ min-width: 280px; max-width: 380px; padding: 20px 24px; border-radius: 14px;
829
+ background: var(--dsw-alias-bg-overlay, #fff); color: var(--dsw-alias-label-primary, inherit);
830
+ border: 1px solid var(--dsw-alias-border-l2, rgba(128,128,128,.25));
831
+ box-shadow: var(--dsw-shadow-lv3, 0 12px 32px rgba(0,0,0,.18));
832
+ display: flex; flex-direction: column; align-items: center; gap: 14px;
833
+ animation: dsh-atb-pop .14s ease;
834
+ }
835
+ .dsh-atb-alert-icon { font-size: 28px; line-height: 1; }
836
+ .dsh-atb-alert-msg {
837
+ font-size: 13.5px; line-height: 1.55; text-align: center;
838
+ word-break: break-word; white-space: pre-wrap;
839
+ color: var(--dsw-alias-label-primary, inherit);
840
+ }
841
+ .dsh-atb-alert .dsh-atb-btn { padding: 6px 28px; font-size: 13px; }
815
842
  `;
816
843
  let injected = false;
817
844
  /** Inject the stylesheet once (idempotent). */
@@ -968,9 +995,10 @@ window.__ModuleLoader__.load({
968
995
  * The card view.
969
996
  * @param task - the task record.
970
997
  * @param controller - the controller.
971
- * @param draggable - enable dragging (backlog/todo columns only).
998
+ * @param draggable - enable dragging.
999
+ * @param onAlert - show an alert message (replaces native alert).
972
1000
  */
973
- function TaskCard({ task, controller, draggable = false }) {
1001
+ function TaskCard({ task, controller, draggable = false, onAlert }) {
974
1002
  const last = task.executions.length > 0 ? task.executions[task.executions.length - 1] : void 0;
975
1003
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
976
1004
  type: "button",
@@ -978,6 +1006,13 @@ window.__ModuleLoader__.load({
978
1006
  "data-urgency": task.urgency,
979
1007
  draggable,
980
1008
  onDragStart: (e) => {
1009
+ if (task.executions.find((ex) => ex.outcome === "running") !== void 0) {
1010
+ e.preventDefault();
1011
+ const msg = `该任务正在由【${task.title}】会话执行,不能拖动`;
1012
+ if (onAlert !== void 0) onAlert(msg);
1013
+ else alert(msg);
1014
+ return;
1015
+ }
981
1016
  e.dataTransfer.setData(DRAG_TYPE, task.id);
982
1017
  e.dataTransfer.effectAllowed = "move";
983
1018
  e.currentTarget.dataset.dragging = "true";
@@ -1179,18 +1214,28 @@ window.__ModuleLoader__.load({
1179
1214
  ]
1180
1215
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1181
1216
  className: "dsh-atb-detail-topbtns",
1182
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1183
- type: "button",
1184
- className: "dsh-atb-detail-edit",
1185
- onClick: () => controller.openEditor(task.id),
1186
- children: "✎ 编辑"
1187
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1188
- type: "button",
1189
- className: "dsh-atb-detail-close",
1190
- "aria-label": "关闭",
1191
- onClick: () => controller.select(void 0),
1192
- children: ""
1193
- })]
1217
+ children: [
1218
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1219
+ type: "button",
1220
+ className: "dsh-atb-detail-edit",
1221
+ onClick: () => controller.openEditor(task.id),
1222
+ children: "✎ 编辑"
1223
+ }),
1224
+ canRun && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1225
+ type: "button",
1226
+ className: "dsh-atb-detail-run",
1227
+ title: task.model !== void 0 ? `新会话执行(${task.model.model})` : "新会话执行(默认模型)",
1228
+ onClick: () => void controller.run(task.id),
1229
+ children: "▶ 立即执行"
1230
+ }),
1231
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1232
+ type: "button",
1233
+ className: "dsh-atb-detail-close",
1234
+ "aria-label": "关闭",
1235
+ onClick: () => controller.select(void 0),
1236
+ children: "✕"
1237
+ })
1238
+ ]
1194
1239
  })]
1195
1240
  }),
1196
1241
  task.description.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
@@ -1214,14 +1259,9 @@ window.__ModuleLoader__.load({
1214
1259
  children: task.prompt
1215
1260
  })]
1216
1261
  }),
1217
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1262
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1218
1263
  className: "dsh-atb-detail-actions",
1219
- children: [canRun && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
1220
- type: "button",
1221
- className: "dsh-atb-runbtn",
1222
- onClick: () => void controller.run(task.id),
1223
- children: ["▶ 执行 · 新会话", task.model !== void 0 ? `(${task.model.model})` : "(默认模型)"]
1224
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1264
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1225
1265
  className: "dsh-atb-movebtns",
1226
1266
  children: [moveTargets(task).map((to) => to === "done" ? confirmDone ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1227
1267
  className: "dsh-atb-confirm",
@@ -1252,13 +1292,13 @@ window.__ModuleLoader__.load({
1252
1292
  className: "dsh-atb-movebtn",
1253
1293
  "data-to": to,
1254
1294
  onClick: () => setConfirmDone(true),
1255
- children: ["", MOVE_LABEL[to]]
1256
- }, to) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1295
+ children: ["移至→", MOVE_LABEL[to]]
1296
+ }, to) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
1257
1297
  type: "button",
1258
1298
  className: "dsh-atb-movebtn",
1259
1299
  "data-to": to,
1260
1300
  onClick: () => void controller.move(task.id, task.version, to),
1261
- children: MOVE_LABEL[to]
1301
+ children: ["移至→", MOVE_LABEL[to]]
1262
1302
  }, to)), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1263
1303
  type: "button",
1264
1304
  className: "dsh-atb-movebtn",
@@ -1266,7 +1306,7 @@ window.__ModuleLoader__.load({
1266
1306
  onClick: () => void controller.toggleBlocked(task),
1267
1307
  children: task.blocked ? "✓ 解除受阻" : "⛔ 标记受阻"
1268
1308
  })]
1269
- })]
1309
+ })
1270
1310
  }),
1271
1311
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1272
1312
  className: "dsh-atb-section",
@@ -1509,6 +1549,7 @@ window.__ModuleLoader__.load({
1509
1549
  const nextRun = cronMatch !== null ? nextCronTime(cronMatch, Date.now()) : null;
1510
1550
  const cronBad = mode === "scheduled" && (cronMatch === null || nextRun === null);
1511
1551
  const valid = title.trim().length > 0 && workspaceId !== "" && !cronBad;
1552
+ const runBlocked = editing && task.status === "in_progress";
1512
1553
  const submit = () => {
1513
1554
  if (!valid) return;
1514
1555
  const picked = model !== "" ? JSON.parse(model) : void 0;
@@ -1537,6 +1578,40 @@ window.__ModuleLoader__.load({
1537
1578
  model: picked
1538
1579
  });
1539
1580
  };
1581
+ /** Save the form, then immediately trigger a manual run of the task. */
1582
+ const submitAndRun = () => {
1583
+ if (!valid || runBlocked) return;
1584
+ const picked = model !== "" ? JSON.parse(model) : void 0;
1585
+ if (editing) (async () => {
1586
+ if (await controller.update(task.id, task.version, {
1587
+ title,
1588
+ description,
1589
+ prompt,
1590
+ urgency,
1591
+ workspaceId,
1592
+ execution: mode === "scheduled" ? {
1593
+ mode,
1594
+ cron: cron.trim()
1595
+ } : { mode },
1596
+ model: picked ?? null
1597
+ })) await controller.run(task.id);
1598
+ })();
1599
+ else (async () => {
1600
+ const id = await controller.create({
1601
+ title,
1602
+ workspaceId,
1603
+ urgency,
1604
+ description: description.length > 0 ? description : void 0,
1605
+ prompt: prompt.length > 0 ? prompt : void 0,
1606
+ execution: mode === "scheduled" ? {
1607
+ mode,
1608
+ cron: cron.trim()
1609
+ } : { mode },
1610
+ model: picked
1611
+ });
1612
+ if (id !== void 0) await controller.run(id);
1613
+ })();
1614
+ };
1540
1615
  const hint = !valid ? title.trim().length === 0 ? "请填写标题" : workspaceId === "" ? "请选择项目" : "Cron 表达式无效(分 时 日 月 周)" : mode === "scheduled" && nextRun !== null ? `下次运行 ${fmtTime(nextRun)}` : editing ? `保存后版本 v${task.version} → v${task.version + 1}` : "创建后项目内会话可认领执行";
1541
1616
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1542
1617
  className: "dsh-atb-modal-backdrop",
@@ -1727,19 +1802,30 @@ window.__ModuleLoader__.load({
1727
1802
  children: hint
1728
1803
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1729
1804
  className: "dsh-atb-modal-footbtns",
1730
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1731
- type: "button",
1732
- className: "dsh-atb-btn",
1733
- onClick: () => controller.closeForm(),
1734
- children: "取消"
1735
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1736
- type: "button",
1737
- className: "dsh-atb-btn",
1738
- "data-primary": "true",
1739
- disabled: !valid,
1740
- onClick: submit,
1741
- children: editing ? "保存修改" : "创建任务"
1742
- })]
1805
+ children: [
1806
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1807
+ type: "button",
1808
+ className: "dsh-atb-btn",
1809
+ onClick: () => controller.closeForm(),
1810
+ children: "取消"
1811
+ }),
1812
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1813
+ type: "button",
1814
+ className: "dsh-atb-btn",
1815
+ disabled: !valid || runBlocked,
1816
+ title: runBlocked ? "任务正在执行中,不能重复发起" : "保存后立即发起执行(新会话)",
1817
+ onClick: submitAndRun,
1818
+ children: "⚡ 立即执行"
1819
+ }),
1820
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1821
+ type: "button",
1822
+ className: "dsh-atb-btn",
1823
+ "data-primary": "true",
1824
+ disabled: !valid,
1825
+ onClick: submit,
1826
+ children: editing ? "保存修改" : "创建任务"
1827
+ })
1828
+ ]
1743
1829
  })]
1744
1830
  })
1745
1831
  ]
@@ -1747,6 +1833,62 @@ window.__ModuleLoader__.load({
1747
1833
  });
1748
1834
  }
1749
1835
 
1836
+ //#endregion
1837
+ //#region src/client/board/AlertModal.tsx
1838
+ /**
1839
+ * A lightweight alert modal — replaces native alert() with a themed overlay
1840
+ * that matches the shell design tokens.
1841
+ *
1842
+ * @module dsh-taskboard/client/board/AlertModal
1843
+ */
1844
+ /** Show a non-blocking alert modal. Returns true when opened. */
1845
+ function useAlert() {
1846
+ const [msg, setMsg] = (0, react.useState)(null);
1847
+ const show = (m) => setMsg(m);
1848
+ const close = () => setMsg(null);
1849
+ return {
1850
+ alert: show,
1851
+ el: msg !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AlertModal, {
1852
+ message: msg,
1853
+ onClose: close
1854
+ }) : null
1855
+ };
1856
+ }
1857
+ function AlertModal({ message, onClose }) {
1858
+ (0, react.useEffect)(() => {
1859
+ const handler = (e) => {
1860
+ if (e.key === "Escape") onClose();
1861
+ };
1862
+ window.addEventListener("keydown", handler);
1863
+ return () => window.removeEventListener("keydown", handler);
1864
+ }, [onClose]);
1865
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1866
+ className: "dsh-atb-alert-backdrop",
1867
+ onClick: onClose,
1868
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1869
+ className: "dsh-atb-alert",
1870
+ onClick: (e) => e.stopPropagation(),
1871
+ children: [
1872
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1873
+ className: "dsh-atb-alert-icon",
1874
+ children: "⛔"
1875
+ }),
1876
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1877
+ className: "dsh-atb-alert-msg",
1878
+ children: message
1879
+ }),
1880
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1881
+ type: "button",
1882
+ className: "dsh-atb-btn",
1883
+ "data-primary": "true",
1884
+ onClick: onClose,
1885
+ children: "知道了"
1886
+ })
1887
+ ]
1888
+ })
1889
+ });
1890
+ }
1891
+
1750
1892
  //#endregion
1751
1893
  //#region src/client/board/TaskBoard.tsx
1752
1894
  /**
@@ -1765,8 +1907,6 @@ window.__ModuleLoader__.load({
1765
1907
  canceled: "已取消",
1766
1908
  archived: "已归档"
1767
1909
  };
1768
- /** The two columns between which cards may be dragged both ways. */
1769
- const DRAGGABLE_STATUSES = /* @__PURE__ */ new Set(["backlog", "todo"]);
1770
1910
  /** Urgency chip labels. */
1771
1911
  const URGENCY_LABELS = {
1772
1912
  urgent: "紧急",
@@ -1792,6 +1932,7 @@ window.__ModuleLoader__.load({
1792
1932
  const state = (0, react.useSyncExternalStore)((cb) => controller.subscribe(cb), () => controller.getSnapshot());
1793
1933
  const live = filterTasks(state, state.ledger.tasks.filter((t) => t.trashedAt === void 0));
1794
1934
  const selected = state.selectedId === void 0 ? void 0 : state.ledger.tasks.find((t) => t.id === state.selectedId);
1935
+ const { alert: showAlert, el: alertEl } = useAlert();
1795
1936
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1796
1937
  className: "dsh-atb-board",
1797
1938
  children: [
@@ -1810,6 +1951,13 @@ window.__ModuleLoader__.load({
1810
1951
  state.ledger.revision
1811
1952
  ]
1812
1953
  }),
1954
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1955
+ type: "button",
1956
+ className: "dsh-atb-btn",
1957
+ "data-primary": "true",
1958
+ onClick: () => controller.setComposer(true),
1959
+ children: "+ 新建任务"
1960
+ }),
1813
1961
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { className: "dsh-atb-spacer" }),
1814
1962
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
1815
1963
  className: "dsh-atb-select",
@@ -1843,13 +1991,6 @@ window.__ModuleLoader__.load({
1843
1991
  className: "dsh-atb-btn",
1844
1992
  onClick: () => controller.toggleSecondary(),
1845
1993
  children: state.secondaryOpen ? "返回看板" : "其它任务"
1846
- }),
1847
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1848
- type: "button",
1849
- className: "dsh-atb-btn",
1850
- "data-primary": "true",
1851
- onClick: () => controller.setComposer(true),
1852
- children: "+ 新建任务"
1853
1994
  })
1854
1995
  ]
1855
1996
  }),
@@ -1864,28 +2005,31 @@ window.__ModuleLoader__.load({
1864
2005
  className: "dsh-atb-columns",
1865
2006
  children: MAIN_STATUSES.map((status) => {
1866
2007
  const columnTasks = live.filter((t) => t.status === status);
1867
- const dropTarget = DRAGGABLE_STATUSES.has(status);
1868
2008
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1869
2009
  className: "dsh-atb-column",
1870
- onDragOver: dropTarget ? (e) => {
2010
+ onDragOver: (e) => {
1871
2011
  if (e.dataTransfer.types.includes("application/x-dsh-atb-task")) {
1872
2012
  e.preventDefault();
1873
2013
  e.dataTransfer.dropEffect = "move";
1874
2014
  e.currentTarget.dataset.dragover = "true";
1875
2015
  }
1876
- } : void 0,
1877
- onDragLeave: dropTarget ? (e) => {
2016
+ },
2017
+ onDragLeave: (e) => {
1878
2018
  delete e.currentTarget.dataset.dragover;
1879
- } : void 0,
1880
- onDrop: dropTarget ? (e) => {
2019
+ },
2020
+ onDrop: (e) => {
1881
2021
  e.preventDefault();
1882
2022
  delete e.currentTarget.dataset.dragover;
1883
2023
  const id = e.dataTransfer.getData(DRAG_TYPE);
1884
2024
  if (id.length === 0) return;
1885
2025
  const task = state.ledger.tasks.find((t) => t.id === id);
1886
2026
  if (task === void 0 || task.status === status) return;
2027
+ if (!canTransition(task.status, status)) {
2028
+ showAlert(`无法从「${COLUMN_LABELS[task.status]}」拖至「${COLUMN_LABELS[status]}」`);
2029
+ return;
2030
+ }
1887
2031
  controller.move(id, task.version, status);
1888
- } : void 0,
2032
+ },
1889
2033
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1890
2034
  className: "dsh-atb-colhead",
1891
2035
  children: [COLUMN_LABELS[status], /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
@@ -1897,7 +2041,8 @@ window.__ModuleLoader__.load({
1897
2041
  children: [columnTasks.map((task) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TaskCard, {
1898
2042
  task,
1899
2043
  controller,
1900
- draggable: dropTarget
2044
+ draggable: true,
2045
+ onAlert: showAlert
1901
2046
  }, task.id)), columnTasks.length === 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1902
2047
  className: "dsh-atb-empty",
1903
2048
  children: "无任务"
@@ -1916,7 +2061,8 @@ window.__ModuleLoader__.load({
1916
2061
  state.composerOpen && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TaskFormModal, {
1917
2062
  controller,
1918
2063
  task: state.editingId === void 0 ? void 0 : state.ledger.tasks.find((t) => t.id === state.editingId)
1919
- })
2064
+ }),
2065
+ alertEl
1920
2066
  ]
1921
2067
  });
1922
2068
  }
@@ -126,6 +126,9 @@ var ExecutionService = class {
126
126
  };
127
127
  }
128
128
  await this.deps.workspaces.attach(task.workspaceId, sessionId).catch(() => {});
129
+ try {
130
+ this.deps.renameSession?.(sessionId, task.title);
131
+ } catch {}
129
132
  await this.patchExecution(executionId, { sessionId });
130
133
  const message = {
131
134
  id: this.deps.mintMessageId?.() ?? MessageId(`msg-taskboard-${crypto.randomUUID()}`),
@@ -134,10 +137,7 @@ var ExecutionService = class {
134
137
  type: "text",
135
138
  text: this.executionPrompt(task)
136
139
  }],
137
- source: {
138
- kind: "plugin",
139
- plugin: "dsh-taskboard"
140
- }
140
+ source: { kind: "user" }
141
141
  };
142
142
  handle.agent.followup(message);
143
143
  const settle = () => {
@@ -166,10 +166,9 @@ var ExecutionService = class {
166
166
  }
167
167
  /** The prompt text one execution submits (task context + instructions). */
168
168
  executionPrompt(task) {
169
- const head = `【任务看板执行】${task.title}(任务 ID: ${task.id})`;
170
169
  const state = "本任务由执行服务启动本会话并已置为 in_progress(你无需再认领,也无需移到 done)。";
171
170
  const tail = `完成后请:1) 用 taskboard_get 读取任务 ${task.id} 拿最新 version;2) 用 taskboard_comment_add 留评论(做了什么改动、如何验证、剩余风险);3) 用 taskboard_move 把任务 ${task.id} 移到 in_review(带 ifVersion)。`;
172
- return `${head}\n\n${state}\n\n${effectivePrompt(task)}\n\n${tail}`;
171
+ return `【任务】${task.title}(任务 ID: ${task.id})\n\n${state}\n\n${effectivePrompt(task)}\n\n${tail}`;
173
172
  }
174
173
  /** Move a task back out of in_progress after a failed start. */
175
174
  async revertProgress(taskId) {
@@ -1 +1 @@
1
- {"version":3,"file":"execution.js","names":[],"sources":["../../src/host/execution.ts"],"sourcesContent":["/**\n * Host execution service: runs a task through dsh's REAL session machinery —\n * a fresh agent+session inside the task's project workspace (creation carries\n * the pinned model when the task has one), the session is attached to the\n * workspace so it appears in the GUI's project session list, the effective\n * prompt is submitted as an ordinary user message, and the turn settlement\n * (turn/end reason) is folded back into the task's execution record.\n *\n * Every execution is a NEW session: clean context, no reuse of previous runs.\n *\n * @module dsh-taskboard/host/execution\n */\nimport { effectivePrompt, newExecutionId, type ExecutionRecord, type TaskRecord } from '../shared/protocol.ts'\nimport { MessageId } from './sdk.ts'\nimport type { TaskStore } from './store.ts'\n\n/** Narrow agents face (the registry's create, structurally). */\nexport interface AgentsFace {\n create(options: {\n sessionId: string\n meta?: { cwd?: string }\n agentOptions?: { provider?: string; model?: string }\n }): Promise<{\n agent: {\n id: string\n followup(message: unknown): void\n whenIdle(): Promise<void>\n }\n dispose(): Promise<void>\n }>\n}\n\n/** Narrow workspaces face for execution. */\nexport interface ExecutionWorkspaceFace {\n get(id: string): { id: string; path: string } | undefined\n attach(workspaceId: string, sessionId: string): Promise<void>\n}\n\n/** Narrow event-bus face for settlement listening. */\nexport interface EventsFace {\n onSessionEvent(listener: (sessionId: string, event: { type: string; data?: unknown }) => void): () => void\n}\n\n/** Everything the execution service needs. */\nexport interface ExecutionDeps {\n store: TaskStore\n agents: AgentsFace\n workspaces: ExecutionWorkspaceFace\n events: EventsFace\n now: () => number\n /** The deployment default model (fills sessions of unpinned tasks). */\n defaultModel?: () => { provider: string; model: string } | undefined\n /** Mint session ids (injectable for tests). */\n mintSessionId?: () => string\n /** Mint message ids (injectable for tests). */\n mintMessageId?: () => string\n}\n\n/** Outcome of a run request (immediate; the run settles asynchronously). */\nexport type RunRequestResult =\n | { ok: true; executionId: string; sessionId: string }\n | { ok: false; error: string }\n\n/** Whether a turn/end payload closed with an error reason. */\nfunction isErrorTurnEnd(data: unknown): { message: string } | undefined {\n if (typeof data !== 'object' || data === null) return undefined\n const reason = (data as { reason?: unknown }).reason\n if (typeof reason !== 'object' || reason === null) return undefined\n const kind = (reason as { kind?: unknown }).kind\n if (kind !== 'error') return undefined\n const error = (reason as { error?: { message?: unknown } }).error\n const detail = JSON.stringify(error) ?? ''\n const message = typeof error?.message === 'string' ? error.message : 'turn failed'\n console.error('[dsh-taskboard] turn error detail:', detail.slice(0, 2000))\n void detail\n return { message }\n}\n\n/**\n * The execution service.\n */\nexport class ExecutionService {\n /** Execution ids currently settling. */\n private readonly settling = new Map<string, () => void>()\n\n /** @param deps - store + agents + workspaces + events + clock. */\n constructor(private readonly deps: ExecutionDeps) {\n deps.events.onSessionEvent((sessionId, event) => {\n if (event.type !== 'turn/end') return\n const failure = isErrorTurnEnd(event.data)\n if (failure !== undefined) this.noteFailure(sessionId, failure.message)\n })\n }\n\n /** Record a turn failure against the running execution of that session. */\n private noteFailure(sessionId: string, message: string): void {\n void this.deps.store.mutate('execution-recorded', (ledger) => {\n for (const task of ledger.tasks) {\n for (const execution of task.executions) {\n if (execution.sessionId === sessionId && execution.outcome === 'running') {\n execution.outcome = 'failed'\n execution.error = message.slice(0, 500)\n execution.endedAt = this.deps.now()\n return [task]\n }\n }\n }\n return undefined\n })\n }\n\n /** Patch one task's execution record in the ledger. */\n private async patchExecution(executionId: string, patch: Partial<ExecutionRecord>): Promise<void> {\n await this.deps.store.mutate('execution-recorded', (ledger) => {\n for (const task of ledger.tasks) {\n const execution = task.executions.find(e => e.id === executionId)\n if (execution !== undefined) {\n Object.assign(execution, patch)\n return [task]\n }\n }\n return undefined\n })\n }\n\n /**\n * Run one task now (manual button or scheduler tick).\n * @param taskId - the task to run.\n * @param trigger - what started it.\n * @returns the immediate result; settlement lands in the ledger.\n */\n async run(taskId: string, trigger: ExecutionRecord['trigger']): Promise<RunRequestResult> {\n const task = this.deps.store.get(taskId)\n if (task === undefined || task.trashedAt !== undefined) {\n return { ok: false, error: `no task ${taskId}` }\n }\n if (task.status === 'in_progress') {\n return { ok: false, error: 'task is already in progress' }\n }\n const workspace = this.deps.workspaces.get(task.workspaceId)\n if (workspace === undefined) {\n return { ok: false, error: `unknown workspace ${task.workspaceId}` }\n }\n\n const executionId = newExecutionId()\n const sessionId = this.deps.mintSessionId?.() ?? `session-taskboard-${crypto.randomUUID()}`\n\n // 1. Open the execution record and move the card to in_progress in one write.\n await this.deps.store.mutate('execution-recorded', (ledger) => {\n const target = ledger.tasks.find(t => t.id === taskId)\n if (target === undefined) return undefined\n target.executions.push({\n id: executionId,\n trigger,\n startedAt: this.deps.now(),\n outcome: 'running',\n })\n target.status = 'in_progress'\n target.updatedAt = this.deps.now()\n target.updatedBy = { kind: 'user' }\n return [target]\n })\n\n // 2. Create the fresh agent+session inside the task's project, carrying\n // the pinned model — or the deployment default when unpinned (the\n // persona template renders {{model}}, so the session always needs one).\n let handle: Awaited<ReturnType<AgentsFace['create']>>\n try {\n const model = task.model ?? this.deps.defaultModel?.()\n handle = await this.deps.agents.create({\n sessionId,\n meta: { cwd: workspace.path },\n ...(model !== undefined ? { agentOptions: { provider: model.provider, model: model.model } } : {}),\n })\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error)\n await this.patchExecution(executionId, { outcome: 'failed', error: message.slice(0, 500), endedAt: this.deps.now() })\n await this.revertProgress(taskId)\n return { ok: false, error: message }\n }\n\n // 3. Attach the session to the workspace (GUI project session list).\n await this.deps.workspaces.attach(task.workspaceId, sessionId).catch(() => { /* cosmetic */ })\n\n // 4. Record the session id (execution is really started now).\n await this.patchExecution(executionId, { sessionId })\n\n // 5. Submit the effective prompt as an ordinary user message and settle\n // on quiescence (turn/end errors were already folded by the listener).\n const message = {\n id: this.deps.mintMessageId?.() ?? MessageId(`msg-taskboard-${crypto.randomUUID()}`),\n role: 'user' as const,\n content: [{ type: 'text' as const, text: this.executionPrompt(task) }],\n source: { kind: 'plugin' as const, plugin: 'dsh-taskboard' },\n }\n handle.agent.followup(message)\n\n // 6. Settlement watcher.\n const settle = (): void => {\n this.settling.delete(executionId)\n void this.deps.store.mutate('execution-recorded', (ledger) => {\n for (const t of ledger.tasks) {\n const execution = t.executions.find(e => e.id === executionId)\n if (execution !== undefined && execution.outcome === 'running') {\n execution.outcome = 'succeeded'\n execution.endedAt = this.deps.now()\n return [t]\n }\n }\n return undefined\n })\n }\n this.settling.set(executionId, settle)\n void handle.agent.whenIdle().then(settle, () => {\n this.noteFailure(sessionId, 'agent did not reach quiescence')\n settle()\n })\n\n return { ok: true, executionId, sessionId }\n }\n\n /** The prompt text one execution submits (task context + instructions). */\n private executionPrompt(task: TaskRecord): string {\n const head = `【任务看板执行】${task.title}(任务 ID: ${task.id})`\n const state = '本任务由执行服务启动本会话并已置为 in_progress(你无需再认领,也无需移到 done)。'\n const tail = `完成后请:1) 用 taskboard_get 读取任务 ${task.id} 拿最新 version;`\n + `2) 用 taskboard_comment_add 留评论(做了什么改动、如何验证、剩余风险);`\n + `3) 用 taskboard_move 把任务 ${task.id} 移到 in_review(带 ifVersion)。`\n return `${head}\\n\\n${state}\\n\\n${effectivePrompt(task)}\\n\\n${tail}`\n }\n\n /** Move a task back out of in_progress after a failed start. */\n private async revertProgress(taskId: string): Promise<void> {\n await this.deps.store.mutate('execution-recorded', (ledger) => {\n const target = ledger.tasks.find(t => t.id === taskId)\n if (target !== undefined && target.status === 'in_progress') {\n target.status = 'todo'\n target.updatedAt = this.deps.now()\n return [target]\n }\n return undefined\n })\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"execution.js","names":[],"sources":["../../src/host/execution.ts"],"sourcesContent":["/**\n * Host execution service: runs a task through dsh's REAL session machinery —\n * a fresh agent+session inside the task's project workspace (creation carries\n * the pinned model when the task has one), the session is attached to the\n * workspace so it appears in the GUI's project session list, the effective\n * prompt is submitted as an ordinary user message, and the turn settlement\n * (turn/end reason) is folded back into the task's execution record.\n *\n * Every execution is a NEW session: clean context, no reuse of previous runs.\n *\n * @module dsh-taskboard/host/execution\n */\nimport { effectivePrompt, newExecutionId, type ExecutionRecord, type TaskRecord } from '../shared/protocol.ts'\nimport { MessageId } from './sdk.ts'\nimport type { TaskStore } from './store.ts'\n\n/** Narrow agents face (the registry's create, structurally). */\nexport interface AgentsFace {\n create(options: {\n sessionId: string\n meta?: { cwd?: string }\n agentOptions?: { provider?: string; model?: string }\n }): Promise<{\n agent: {\n id: string\n followup(message: unknown): void\n whenIdle(): Promise<void>\n }\n dispose(): Promise<void>\n }>\n}\n\n/** Narrow workspaces face for execution. */\nexport interface ExecutionWorkspaceFace {\n get(id: string): { id: string; path: string } | undefined\n attach(workspaceId: string, sessionId: string): Promise<void>\n}\n\n/** Narrow event-bus face for settlement listening. */\nexport interface EventsFace {\n onSessionEvent(listener: (sessionId: string, event: { type: string; data?: unknown }) => void): () => void\n}\n\n/** Everything the execution service needs. */\nexport interface ExecutionDeps {\n store: TaskStore\n agents: AgentsFace\n workspaces: ExecutionWorkspaceFace\n events: EventsFace\n now: () => number\n /** The deployment default model (fills sessions of unpinned tasks). */\n defaultModel?: () => { provider: string; model: string } | undefined\n /** Mint session ids (injectable for tests). */\n mintSessionId?: () => string\n /** Mint message ids (injectable for tests). */\n mintMessageId?: () => string\n /** Best-effort session rename (pins the session list title to the task title). */\n renameSession?: (sessionId: string, title: string) => void\n}\n\n/** Outcome of a run request (immediate; the run settles asynchronously). */\nexport type RunRequestResult =\n | { ok: true; executionId: string; sessionId: string }\n | { ok: false; error: string }\n\n/** Whether a turn/end payload closed with an error reason. */\nfunction isErrorTurnEnd(data: unknown): { message: string } | undefined {\n if (typeof data !== 'object' || data === null) return undefined\n const reason = (data as { reason?: unknown }).reason\n if (typeof reason !== 'object' || reason === null) return undefined\n const kind = (reason as { kind?: unknown }).kind\n if (kind !== 'error') return undefined\n const error = (reason as { error?: { message?: unknown } }).error\n const detail = JSON.stringify(error) ?? ''\n const message = typeof error?.message === 'string' ? error.message : 'turn failed'\n console.error('[dsh-taskboard] turn error detail:', detail.slice(0, 2000))\n void detail\n return { message }\n}\n\n/**\n * The execution service.\n */\nexport class ExecutionService {\n /** Execution ids currently settling. */\n private readonly settling = new Map<string, () => void>()\n\n /** @param deps - store + agents + workspaces + events + clock. */\n constructor(private readonly deps: ExecutionDeps) {\n deps.events.onSessionEvent((sessionId, event) => {\n if (event.type !== 'turn/end') return\n const failure = isErrorTurnEnd(event.data)\n if (failure !== undefined) this.noteFailure(sessionId, failure.message)\n })\n }\n\n /** Record a turn failure against the running execution of that session. */\n private noteFailure(sessionId: string, message: string): void {\n void this.deps.store.mutate('execution-recorded', (ledger) => {\n for (const task of ledger.tasks) {\n for (const execution of task.executions) {\n if (execution.sessionId === sessionId && execution.outcome === 'running') {\n execution.outcome = 'failed'\n execution.error = message.slice(0, 500)\n execution.endedAt = this.deps.now()\n return [task]\n }\n }\n }\n return undefined\n })\n }\n\n /** Patch one task's execution record in the ledger. */\n private async patchExecution(executionId: string, patch: Partial<ExecutionRecord>): Promise<void> {\n await this.deps.store.mutate('execution-recorded', (ledger) => {\n for (const task of ledger.tasks) {\n const execution = task.executions.find(e => e.id === executionId)\n if (execution !== undefined) {\n Object.assign(execution, patch)\n return [task]\n }\n }\n return undefined\n })\n }\n\n /**\n * Run one task now (manual button or scheduler tick).\n * @param taskId - the task to run.\n * @param trigger - what started it.\n * @returns the immediate result; settlement lands in the ledger.\n */\n async run(taskId: string, trigger: ExecutionRecord['trigger']): Promise<RunRequestResult> {\n const task = this.deps.store.get(taskId)\n if (task === undefined || task.trashedAt !== undefined) {\n return { ok: false, error: `no task ${taskId}` }\n }\n if (task.status === 'in_progress') {\n return { ok: false, error: 'task is already in progress' }\n }\n const workspace = this.deps.workspaces.get(task.workspaceId)\n if (workspace === undefined) {\n return { ok: false, error: `unknown workspace ${task.workspaceId}` }\n }\n\n const executionId = newExecutionId()\n const sessionId = this.deps.mintSessionId?.() ?? `session-taskboard-${crypto.randomUUID()}`\n\n // 1. Open the execution record and move the card to in_progress in one write.\n await this.deps.store.mutate('execution-recorded', (ledger) => {\n const target = ledger.tasks.find(t => t.id === taskId)\n if (target === undefined) return undefined\n target.executions.push({\n id: executionId,\n trigger,\n startedAt: this.deps.now(),\n outcome: 'running',\n })\n target.status = 'in_progress'\n target.updatedAt = this.deps.now()\n target.updatedBy = { kind: 'user' }\n return [target]\n })\n\n // 2. Create the fresh agent+session inside the task's project, carrying\n // the pinned model — or the deployment default when unpinned (the\n // persona template renders {{model}}, so the session always needs one).\n let handle: Awaited<ReturnType<AgentsFace['create']>>\n try {\n const model = task.model ?? this.deps.defaultModel?.()\n handle = await this.deps.agents.create({\n sessionId,\n meta: { cwd: workspace.path },\n ...(model !== undefined ? { agentOptions: { provider: model.provider, model: model.model } } : {}),\n })\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error)\n await this.patchExecution(executionId, { outcome: 'failed', error: message.slice(0, 500), endedAt: this.deps.now() })\n await this.revertProgress(taskId)\n return { ok: false, error: message }\n }\n\n // 3. Attach the session to the workspace (GUI project session list).\n await this.deps.workspaces.attach(task.workspaceId, sessionId).catch(() => { /* cosmetic */ })\n\n // 3b. Best-effort rename: pin the session title to the task title so the\n // session list shows the task name (a user-sourced title also stops\n // automatic first-prompt retitling).\n try {\n this.deps.renameSession?.(sessionId, task.title)\n } catch { /* cosmetic */ }\n\n // 4. Record the session id (execution is really started now).\n await this.patchExecution(executionId, { sessionId })\n\n // 5. Submit the effective prompt as an ordinary user message and settle\n // on quiescence (turn/end errors were already folded by the listener).\n // Source `user` (not `plugin`) so the opening message renders as a\n // normal user bubble in the conversation, exactly like a typed prompt.\n const message = {\n id: this.deps.mintMessageId?.() ?? MessageId(`msg-taskboard-${crypto.randomUUID()}`),\n role: 'user' as const,\n content: [{ type: 'text' as const, text: this.executionPrompt(task) }],\n source: { kind: 'user' as const },\n }\n handle.agent.followup(message)\n\n // 6. Settlement watcher.\n const settle = (): void => {\n this.settling.delete(executionId)\n void this.deps.store.mutate('execution-recorded', (ledger) => {\n for (const t of ledger.tasks) {\n const execution = t.executions.find(e => e.id === executionId)\n if (execution !== undefined && execution.outcome === 'running') {\n execution.outcome = 'succeeded'\n execution.endedAt = this.deps.now()\n return [t]\n }\n }\n return undefined\n })\n }\n this.settling.set(executionId, settle)\n void handle.agent.whenIdle().then(settle, () => {\n this.noteFailure(sessionId, 'agent did not reach quiescence')\n settle()\n })\n\n return { ok: true, executionId, sessionId }\n }\n\n /** The prompt text one execution submits (task context + instructions). */\n private executionPrompt(task: TaskRecord): string {\n const state = '本任务由执行服务启动本会话并已置为 in_progress(你无需再认领,也无需移到 done)。'\n const tail = `完成后请:1) 用 taskboard_get 读取任务 ${task.id} 拿最新 version;`\n + `2) 用 taskboard_comment_add 留评论(做了什么改动、如何验证、剩余风险);`\n + `3) 用 taskboard_move 把任务 ${task.id} 移到 in_review(带 ifVersion)。`\n return `【任务】${task.title}(任务 ID: ${task.id})\\n\\n${state}\\n\\n${effectivePrompt(task)}\\n\\n${tail}`\n }\n\n /** Move a task back out of in_progress after a failed start. */\n private async revertProgress(taskId: string): Promise<void> {\n await this.deps.store.mutate('execution-recorded', (ledger) => {\n const target = ledger.tasks.find(t => t.id === taskId)\n if (target !== undefined && target.status === 'in_progress') {\n target.status = 'todo'\n target.updatedAt = this.deps.now()\n return [target]\n }\n return undefined\n })\n }\n}\n"],"mappings":"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"}
package/lib/index.js CHANGED
@@ -48,6 +48,14 @@ function apply(ctx) {
48
48
  },
49
49
  events,
50
50
  now,
51
+ renameSession: (sessionId, title) => {
52
+ try {
53
+ const sessions = agentCtx.get("sessions");
54
+ const sessionTitle = agentCtx.get("sessionTitle");
55
+ const session = sessions?.get(sessionId);
56
+ if (session !== void 0 && sessionTitle !== void 0) sessionTitle.rename(session, title);
57
+ } catch {}
58
+ },
51
59
  defaultModel: () => {
52
60
  try {
53
61
  const selection = agentCtx.get("agentDefaultModel");
package/lib/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":[],"sources":["../src/index.ts"],"sourcesContent":["/**\n * Host loader entry for dsh-taskboard.\n *\n * Wiring: the ledger store (one JSON file under the DSH home), the eight\n * `taskboard_*` agent tools, the agent workflow-protocol system-prompt\n * section, the /taskboard JSON+SSE routes (when a webServer is served),\n * the host execution service (fresh in-project sessions, pinned models), and\n * the host-side cron scheduler for scheduled tasks.\n *\n * Export shape follows the dsh-tool-todo lesson: a function/namespace plugin —\n * `name` / `inject` / `apply`, NO default export.\n *\n * @module dsh-taskboard\n */\nimport type { Context } from '@deepseek-ai/cordis'\n// Type-only module imports: they load the cordis Context augmentations\n// (ctx.tools / ctx.systemPrompt / ctx.agents) and vanish at compile time —\n// the built host half keeps ZERO runtime @deepseek-ai imports.\nimport type {} from '@deepseek-ai/dsh-tools'\nimport type {} from '@deepseek-ai/dsh-system-prompt'\nimport type {} from '@deepseek-ai/dsh-agent'\nimport { PROTOCOL_SECTION_NAME, PROTOCOL_SECTION_ORDER, TASKBOARD_PROTOCOL } from './host/protocol-text.ts'\nimport { ExecutionService, type EventsFace } from './host/execution.ts'\nimport { registerTaskboardRoutes } from './host/routes.ts'\nimport { SchedulerService } from './host/scheduler.ts'\nimport { dshHomePath } from './host/sdk.ts'\nimport { TaskStore } from './host/store.ts'\nimport { registerTaskboardTools, workspaceFace } from './host/tools.ts'\n\n/** Ledger file name under the DSH home. */\nexport const LEDGER_FILE = 'dsh-taskboard.json'\n\n/** Cordis plugin name. */\nexport const name = 'dsh-taskboard'\n\n/** Required host services (tool registry + prompt assembly). */\nexport const inject = ['tools', 'systemPrompt']\n\n/**\n * Mount the host half.\n * @param ctx - the plugin context (tools + systemPrompt injected).\n */\nexport function apply(ctx: Context): void {\n const store = new TaskStore({ file: dshHomePath(LEDGER_FILE) })\n const now = () => Date.now()\n\n // Agent workflow protocol (claim discipline, retry rules, done-gate).\n const disposeSection = ctx.systemPrompt.section({\n name: PROTOCOL_SECTION_NAME,\n order: PROTOCOL_SECTION_ORDER,\n text: TASKBOARD_PROTOCOL,\n })\n ctx.effect(() => disposeSection, 'dsh-taskboard: protocol section')\n\n // Tools, routes, execution, and the scheduler all come up with the\n // workspace registry (claim boundary + project execution need it).\n ctx.inject(['workspaceRegistry'], (wsCtx: Context) => {\n const disposers: Array<() => void> = []\n disposers.push(...registerTaskboardTools(wsCtx, {\n store,\n workspaces: workspaceFace(wsCtx.workspaceRegistry),\n now,\n }))\n\n // Settlement listener over the session event bus.\n const events: EventsFace = {\n onSessionEvent: (listener) => wsCtx.on('session/event', (session, event) => {\n listener(session.id, event as { type: string; data?: unknown })\n }),\n }\n\n wsCtx.inject(['agents'], (agentCtx: Context) => {\n const execution = new ExecutionService({\n store,\n agents: {\n create: (options): Promise<never> => agentCtx.agents.create(options as never) as Promise<never>,\n },\n workspaces: {\n get: id => workspaceFace(wsCtx.workspaceRegistry).get(id),\n attach: async (workspaceId, sessionId) => {\n const ws = wsCtx.workspaceRegistry.get(workspaceId as never)\n if (ws !== undefined) await ws.attachSession(sessionId as never)\n },\n },\n events,\n now,\n defaultModel: () => {\n try {\n const selection = agentCtx.get('agentDefaultModel') as { currentSelection?: () => { provider: string; model: string } | undefined } | undefined\n const read = selection?.currentSelection\n return read === undefined ? undefined : read.call(selection)\n } catch { return undefined }\n },\n })\n\n // /dsh-taskboard routes (the run action reaches the execution service).\n let disposeRoutes: (() => void) | undefined\n agentCtx.inject(['webServer'], (webCtx: Context) => {\n disposeRoutes = registerTaskboardRoutes(webCtx, {\n store,\n workspaces: workspaceFace(wsCtx.workspaceRegistry),\n now,\n run: (taskId: string) => execution.run(taskId, 'manual'),\n })\n return () => disposeRoutes?.()\n })\n\n // Host-side cron scheduler: due scheduled tasks execute even with no\n // browser open.\n const scheduler = new SchedulerService({ store, execution, now })\n scheduler.start()\n disposers.push(() => scheduler.dispose())\n\n return () => {\n disposeRoutes?.()\n for (const dispose of disposers.splice(0)) dispose()\n }\n })\n\n return () => {\n for (const dispose of disposers.splice(0)) dispose()\n }\n })\n}\n"],"mappings":";;;;;;;;;AA8BA,MAAa,cAAc;;AAG3B,MAAa,OAAO;;AAGpB,MAAa,SAAS,CAAC,SAAS,cAAc;;;;;AAM9C,SAAgB,MAAM,KAAoB;CACxC,MAAM,QAAQ,IAAI,UAAU,EAAE,MAAM,YAAY,WAAW,EAAE,CAAC;CAC9D,MAAM,YAAY,KAAK,IAAI;CAG3B,MAAM,iBAAiB,IAAI,aAAa,QAAQ;EAC9C,MAAM;EACN,OAAA;EACA,MAAM;CACR,CAAC;CACD,IAAI,aAAa,gBAAgB,iCAAiC;CAIlE,IAAI,OAAO,CAAC,mBAAmB,IAAI,UAAmB;EACpD,MAAM,YAA+B,CAAC;EACtC,UAAU,KAAK,GAAG,uBAAuB,OAAO;GAC9C;GACA,YAAY,cAAc,MAAM,iBAAiB;GACjD;EACF,CAAC,CAAC;EAGF,MAAM,SAAqB,EACzB,iBAAiB,aAAa,MAAM,GAAG,kBAAkB,SAAS,UAAU;GAC1E,SAAS,QAAQ,IAAI,KAAyC;EAChE,CAAC,EACH;EAEA,MAAM,OAAO,CAAC,QAAQ,IAAI,aAAsB;GAC9C,MAAM,YAAY,IAAI,iBAAiB;IACrC;IACA,QAAQ,EACN,SAAS,YAA4B,SAAS,OAAO,OAAO,OAAgB,EAC9E;IACA,YAAY;KACV,MAAK,OAAM,cAAc,MAAM,iBAAiB,CAAC,CAAC,IAAI,EAAE;KACxD,QAAQ,OAAO,aAAa,cAAc;MACxC,MAAM,KAAK,MAAM,kBAAkB,IAAI,WAAoB;MAC3D,IAAI,OAAO,KAAA,GAAW,MAAM,GAAG,cAAc,SAAkB;KACjE;IACF;IACA;IACA;IACA,oBAAoB;KAClB,IAAI;MACF,MAAM,YAAY,SAAS,IAAI,mBAAmB;MAClD,MAAM,OAAO,WAAW;MACxB,OAAO,SAAS,KAAA,IAAY,KAAA,IAAY,KAAK,KAAK,SAAS;KAC7D,QAAQ;MAAE;KAAiB;IAC7B;GACF,CAAC;GAGD,IAAI;GACJ,SAAS,OAAO,CAAC,WAAW,IAAI,WAAoB;IAClD,gBAAgB,wBAAwB,QAAQ;KAC9C;KACA,YAAY,cAAc,MAAM,iBAAiB;KACjD;KACA,MAAM,WAAmB,UAAU,IAAI,QAAQ,QAAQ;IACzD,CAAC;IACD,aAAa,gBAAgB;GAC/B,CAAC;GAID,MAAM,YAAY,IAAI,iBAAiB;IAAE;IAAO;IAAW;GAAI,CAAC;GAChE,UAAU,MAAM;GAChB,UAAU,WAAW,UAAU,QAAQ,CAAC;GAExC,aAAa;IACX,gBAAgB;IAChB,KAAK,MAAM,WAAW,UAAU,OAAO,CAAC,GAAG,QAAQ;GACrD;EACF,CAAC;EAED,aAAa;GACX,KAAK,MAAM,WAAW,UAAU,OAAO,CAAC,GAAG,QAAQ;EACrD;CACF,CAAC;AACH"}
1
+ {"version":3,"file":"index.js","names":[],"sources":["../src/index.ts"],"sourcesContent":["/**\n * Host loader entry for dsh-taskboard.\n *\n * Wiring: the ledger store (one JSON file under the DSH home), the eight\n * `taskboard_*` agent tools, the agent workflow-protocol system-prompt\n * section, the /taskboard JSON+SSE routes (when a webServer is served),\n * the host execution service (fresh in-project sessions, pinned models), and\n * the host-side cron scheduler for scheduled tasks.\n *\n * Export shape follows the dsh-tool-todo lesson: a function/namespace plugin —\n * `name` / `inject` / `apply`, NO default export.\n *\n * @module dsh-taskboard\n */\nimport type { Context } from '@deepseek-ai/cordis'\n// Type-only module imports: they load the cordis Context augmentations\n// (ctx.tools / ctx.systemPrompt / ctx.agents) and vanish at compile time —\n// the built host half keeps ZERO runtime @deepseek-ai imports.\nimport type {} from '@deepseek-ai/dsh-tools'\nimport type {} from '@deepseek-ai/dsh-system-prompt'\nimport type {} from '@deepseek-ai/dsh-agent'\nimport { PROTOCOL_SECTION_NAME, PROTOCOL_SECTION_ORDER, TASKBOARD_PROTOCOL } from './host/protocol-text.ts'\nimport { ExecutionService, type EventsFace } from './host/execution.ts'\nimport { registerTaskboardRoutes } from './host/routes.ts'\nimport { SchedulerService } from './host/scheduler.ts'\nimport { dshHomePath } from './host/sdk.ts'\nimport { TaskStore } from './host/store.ts'\nimport { registerTaskboardTools, workspaceFace } from './host/tools.ts'\n\n/** Ledger file name under the DSH home. */\nexport const LEDGER_FILE = 'dsh-taskboard.json'\n\n/** Cordis plugin name. */\nexport const name = 'dsh-taskboard'\n\n/** Required host services (tool registry + prompt assembly). */\nexport const inject = ['tools', 'systemPrompt']\n\n/**\n * Mount the host half.\n * @param ctx - the plugin context (tools + systemPrompt injected).\n */\nexport function apply(ctx: Context): void {\n const store = new TaskStore({ file: dshHomePath(LEDGER_FILE) })\n const now = () => Date.now()\n\n // Agent workflow protocol (claim discipline, retry rules, done-gate).\n const disposeSection = ctx.systemPrompt.section({\n name: PROTOCOL_SECTION_NAME,\n order: PROTOCOL_SECTION_ORDER,\n text: TASKBOARD_PROTOCOL,\n })\n ctx.effect(() => disposeSection, 'dsh-taskboard: protocol section')\n\n // Tools, routes, execution, and the scheduler all come up with the\n // workspace registry (claim boundary + project execution need it).\n ctx.inject(['workspaceRegistry'], (wsCtx: Context) => {\n const disposers: Array<() => void> = []\n disposers.push(...registerTaskboardTools(wsCtx, {\n store,\n workspaces: workspaceFace(wsCtx.workspaceRegistry),\n now,\n }))\n\n // Settlement listener over the session event bus.\n const events: EventsFace = {\n onSessionEvent: (listener) => wsCtx.on('session/event', (session, event) => {\n listener(session.id, event as { type: string; data?: unknown })\n }),\n }\n\n wsCtx.inject(['agents'], (agentCtx: Context) => {\n const execution = new ExecutionService({\n store,\n agents: {\n create: (options): Promise<never> => agentCtx.agents.create(options as never) as Promise<never>,\n },\n workspaces: {\n get: id => workspaceFace(wsCtx.workspaceRegistry).get(id),\n attach: async (workspaceId, sessionId) => {\n const ws = wsCtx.workspaceRegistry.get(workspaceId as never)\n if (ws !== undefined) await ws.attachSession(sessionId as never)\n },\n },\n events,\n now,\n renameSession: (sessionId, title) => {\n // Best-effort: pin the execution session's title to the task title\n // through the log-backed session-title service (user-sourced rename).\n try {\n const sessions = agentCtx.get('sessions') as { get(id: string): unknown } | undefined\n const sessionTitle = agentCtx.get('sessionTitle') as { rename(session: unknown, title: string): unknown } | undefined\n const session = sessions?.get(sessionId)\n if (session !== undefined && sessionTitle !== undefined) sessionTitle.rename(session, title)\n } catch { /* cosmetic */ }\n },\n defaultModel: () => {\n try {\n const selection = agentCtx.get('agentDefaultModel') as { currentSelection?: () => { provider: string; model: string } | undefined } | undefined\n const read = selection?.currentSelection\n return read === undefined ? undefined : read.call(selection)\n } catch { return undefined }\n },\n })\n\n // /dsh-taskboard routes (the run action reaches the execution service).\n let disposeRoutes: (() => void) | undefined\n agentCtx.inject(['webServer'], (webCtx: Context) => {\n disposeRoutes = registerTaskboardRoutes(webCtx, {\n store,\n workspaces: workspaceFace(wsCtx.workspaceRegistry),\n now,\n run: (taskId: string) => execution.run(taskId, 'manual'),\n })\n return () => disposeRoutes?.()\n })\n\n // Host-side cron scheduler: due scheduled tasks execute even with no\n // browser open.\n const scheduler = new SchedulerService({ store, execution, now })\n scheduler.start()\n disposers.push(() => scheduler.dispose())\n\n return () => {\n disposeRoutes?.()\n for (const dispose of disposers.splice(0)) dispose()\n }\n })\n\n return () => {\n for (const dispose of disposers.splice(0)) dispose()\n }\n })\n}\n"],"mappings":";;;;;;;;;AA8BA,MAAa,cAAc;;AAG3B,MAAa,OAAO;;AAGpB,MAAa,SAAS,CAAC,SAAS,cAAc;;;;;AAM9C,SAAgB,MAAM,KAAoB;CACxC,MAAM,QAAQ,IAAI,UAAU,EAAE,MAAM,YAAY,WAAW,EAAE,CAAC;CAC9D,MAAM,YAAY,KAAK,IAAI;CAG3B,MAAM,iBAAiB,IAAI,aAAa,QAAQ;EAC9C,MAAM;EACN,OAAA;EACA,MAAM;CACR,CAAC;CACD,IAAI,aAAa,gBAAgB,iCAAiC;CAIlE,IAAI,OAAO,CAAC,mBAAmB,IAAI,UAAmB;EACpD,MAAM,YAA+B,CAAC;EACtC,UAAU,KAAK,GAAG,uBAAuB,OAAO;GAC9C;GACA,YAAY,cAAc,MAAM,iBAAiB;GACjD;EACF,CAAC,CAAC;EAGF,MAAM,SAAqB,EACzB,iBAAiB,aAAa,MAAM,GAAG,kBAAkB,SAAS,UAAU;GAC1E,SAAS,QAAQ,IAAI,KAAyC;EAChE,CAAC,EACH;EAEA,MAAM,OAAO,CAAC,QAAQ,IAAI,aAAsB;GAC9C,MAAM,YAAY,IAAI,iBAAiB;IACrC;IACA,QAAQ,EACN,SAAS,YAA4B,SAAS,OAAO,OAAO,OAAgB,EAC9E;IACA,YAAY;KACV,MAAK,OAAM,cAAc,MAAM,iBAAiB,CAAC,CAAC,IAAI,EAAE;KACxD,QAAQ,OAAO,aAAa,cAAc;MACxC,MAAM,KAAK,MAAM,kBAAkB,IAAI,WAAoB;MAC3D,IAAI,OAAO,KAAA,GAAW,MAAM,GAAG,cAAc,SAAkB;KACjE;IACF;IACA;IACA;IACA,gBAAgB,WAAW,UAAU;KAGnC,IAAI;MACF,MAAM,WAAW,SAAS,IAAI,UAAU;MACxC,MAAM,eAAe,SAAS,IAAI,cAAc;MAChD,MAAM,UAAU,UAAU,IAAI,SAAS;MACvC,IAAI,YAAY,KAAA,KAAa,iBAAiB,KAAA,GAAW,aAAa,OAAO,SAAS,KAAK;KAC7F,QAAQ,CAAiB;IAC3B;IACA,oBAAoB;KAClB,IAAI;MACF,MAAM,YAAY,SAAS,IAAI,mBAAmB;MAClD,MAAM,OAAO,WAAW;MACxB,OAAO,SAAS,KAAA,IAAY,KAAA,IAAY,KAAK,KAAK,SAAS;KAC7D,QAAQ;MAAE;KAAiB;IAC7B;GACF,CAAC;GAGD,IAAI;GACJ,SAAS,OAAO,CAAC,WAAW,IAAI,WAAoB;IAClD,gBAAgB,wBAAwB,QAAQ;KAC9C;KACA,YAAY,cAAc,MAAM,iBAAiB;KACjD;KACA,MAAM,WAAmB,UAAU,IAAI,QAAQ,QAAQ;IACzD,CAAC;IACD,aAAa,gBAAgB;GAC/B,CAAC;GAID,MAAM,YAAY,IAAI,iBAAiB;IAAE;IAAO;IAAW;GAAI,CAAC;GAChE,UAAU,MAAM;GAChB,UAAU,WAAW,UAAU,QAAQ,CAAC;GAExC,aAAa;IACX,gBAAgB;IAChB,KAAK,MAAM,WAAW,UAAU,OAAO,CAAC,GAAG,QAAQ;GACrD;EACF,CAAC;EAED,aAAa;GACX,KAAK,MAAM,WAAW,UAAU,OAAO,CAAC,GAAG,QAAQ;EACrD;CACF,CAAC;AACH"}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-taskboard",
3
3
  "description": "Agent-first task board for the DSH web GUI: host-authoritative task ledger with taskboard_* agent tools, project (= workspace) claim boundaries, per-task model execution in fresh sessions, host-side cron scheduling, and a live SSE kanban view. Mounts via the official dsh plugin system — no DSH source changes.",
4
- "version": "0.1.1",
4
+ "version": "0.1.2",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {
@@ -0,0 +1,36 @@
1
+ /**
2
+ * A lightweight alert modal — replaces native alert() with a themed overlay
3
+ * that matches the shell design tokens.
4
+ *
5
+ * @module dsh-taskboard/client/board/AlertModal
6
+ */
7
+ import { useState, useEffect } from 'react'
8
+
9
+ /** Show a non-blocking alert modal. Returns true when opened. */
10
+ export function useAlert(): { alert: (msg: string) => void; el: React.ReactNode } {
11
+ const [msg, setMsg] = useState<string | null>(null)
12
+ const show = (m: string) => setMsg(m)
13
+ const close = () => setMsg(null)
14
+ const el = msg !== null
15
+ ? <AlertModal message={msg} onClose={close} />
16
+ : null
17
+ return { alert: show, el }
18
+ }
19
+
20
+ function AlertModal({ message, onClose }: { message: string; onClose: () => void }) {
21
+ useEffect(() => {
22
+ const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
23
+ window.addEventListener('keydown', handler)
24
+ return () => window.removeEventListener('keydown', handler)
25
+ }, [onClose])
26
+
27
+ return (
28
+ <div className="dsh-atb-alert-backdrop" onClick={onClose}>
29
+ <div className="dsh-atb-alert" onClick={e => e.stopPropagation()}>
30
+ <div className="dsh-atb-alert-icon">⛔</div>
31
+ <div className="dsh-atb-alert-msg">{message}</div>
32
+ <button type="button" className="dsh-atb-btn" data-primary="true" onClick={onClose}>知道了</button>
33
+ </div>
34
+ </div>
35
+ )
36
+ }
@@ -7,10 +7,11 @@
7
7
  import { useSyncExternalStore } from 'react'
8
8
  import type { BoardController, ControllerState } from '../controller.ts'
9
9
  import type { TaskRecord, TaskStatus, Urgency } from '../../shared/protocol.ts'
10
- import { MAIN_STATUSES } from '../../shared/protocol.ts'
10
+ import { MAIN_STATUSES, canTransition } from '../../shared/protocol.ts'
11
11
  import { DRAG_TYPE, TaskCard } from './TaskCard.tsx'
12
12
  import { TaskDetail } from './TaskDetail.tsx'
13
13
  import { TaskFormModal } from './TaskFormModal.tsx'
14
+ import { useAlert } from './AlertModal.tsx'
14
15
 
15
16
  /** Column labels. */
16
17
  const COLUMN_LABELS: Readonly<Record<TaskStatus, string>> = {
@@ -23,8 +24,6 @@ const COLUMN_LABELS: Readonly<Record<TaskStatus, string>> = {
23
24
  archived: '已归档',
24
25
  }
25
26
 
26
- /** The two columns between which cards may be dragged both ways. */
27
- const DRAGGABLE_STATUSES: ReadonlySet<TaskStatus> = new Set(['backlog', 'todo'])
28
27
 
29
28
  /** Urgency chip labels. */
30
29
  const URGENCY_LABELS: Readonly<Record<Urgency, string>> = {
@@ -59,12 +58,16 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
59
58
  )
60
59
  const live = filterTasks(state, state.ledger.tasks.filter(t => t.trashedAt === undefined))
61
60
  const selected = state.selectedId === undefined ? undefined : state.ledger.tasks.find(t => t.id === state.selectedId)
61
+ const { alert: showAlert, el: alertEl } = useAlert()
62
62
 
63
63
  return (
64
64
  <div className="dsh-atb-board">
65
65
  <div className="dsh-atb-toolbar">
66
66
  <h2 className="dsh-atb-title">Agent 任务看板</h2>
67
67
  <span className="dsh-atb-count">{live.length} 任务 · rev {state.ledger.revision}</span>
68
+ <button type="button" className="dsh-atb-btn" data-primary="true" onClick={() => controller.setComposer(true)}>
69
+ + 新建任务
70
+ </button>
68
71
  <div className="dsh-atb-spacer" />
69
72
  <select
70
73
  className="dsh-atb-select"
@@ -90,9 +93,6 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
90
93
  <button type="button" className="dsh-atb-btn" onClick={() => controller.toggleSecondary()}>
91
94
  {state.secondaryOpen ? '返回看板' : '其它任务'}
92
95
  </button>
93
- <button type="button" className="dsh-atb-btn" data-primary="true" onClick={() => controller.setComposer(true)}>
94
- + 新建任务
95
- </button>
96
96
  </div>
97
97
 
98
98
  {state.error !== undefined && <div className="dsh-atb-error">{state.error}</div>}
@@ -103,34 +103,31 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
103
103
  <div className="dsh-atb-columns">
104
104
  {MAIN_STATUSES.map(status => {
105
105
  const columnTasks = live.filter(t => t.status === status)
106
- const dropTarget = DRAGGABLE_STATUSES.has(status)
107
106
  return (
108
107
  <div
109
108
  className="dsh-atb-column"
110
109
  key={status}
111
- onDragOver={dropTarget
112
- ? (e) => {
113
- if (e.dataTransfer.types.includes(DRAG_TYPE)) {
114
- e.preventDefault()
115
- e.dataTransfer.dropEffect = 'move'
116
- e.currentTarget.dataset.dragover = 'true'
117
- }
118
- }
119
- : undefined}
120
- onDragLeave={dropTarget
121
- ? (e) => { delete e.currentTarget.dataset.dragover }
122
- : undefined}
123
- onDrop={dropTarget
124
- ? (e) => {
125
- e.preventDefault()
126
- delete e.currentTarget.dataset.dragover
127
- const id = e.dataTransfer.getData(DRAG_TYPE)
128
- if (id.length === 0) return
129
- const task = state.ledger.tasks.find(t => t.id === id)
130
- if (task === undefined || task.status === status) return
131
- void controller.move(id, task.version, status)
132
- }
133
- : undefined}
110
+ onDragOver={(e) => {
111
+ if (e.dataTransfer.types.includes(DRAG_TYPE)) {
112
+ e.preventDefault()
113
+ e.dataTransfer.dropEffect = 'move'
114
+ e.currentTarget.dataset.dragover = 'true'
115
+ }
116
+ }}
117
+ onDragLeave={(e) => { delete e.currentTarget.dataset.dragover }}
118
+ onDrop={(e) => {
119
+ e.preventDefault()
120
+ delete e.currentTarget.dataset.dragover
121
+ const id = e.dataTransfer.getData(DRAG_TYPE)
122
+ if (id.length === 0) return
123
+ const task = state.ledger.tasks.find(t => t.id === id)
124
+ if (task === undefined || task.status === status) return
125
+ if (!canTransition(task.status, status)) {
126
+ showAlert(`无法从「${COLUMN_LABELS[task.status]}」拖至「${COLUMN_LABELS[status]}」`)
127
+ return
128
+ }
129
+ void controller.move(id, task.version, status)
130
+ }}
134
131
  >
135
132
  <div className="dsh-atb-colhead">
136
133
  {COLUMN_LABELS[status]}
@@ -142,7 +139,8 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
142
139
  key={task.id}
143
140
  task={task}
144
141
  controller={controller}
145
- draggable={dropTarget}
142
+ draggable
143
+ onAlert={showAlert}
146
144
  />
147
145
  ))}
148
146
  {columnTasks.length === 0 && <div className="dsh-atb-empty">无任务</div>}
@@ -165,6 +163,8 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
165
163
  task={state.editingId === undefined ? undefined : state.ledger.tasks.find(t => t.id === state.editingId)}
166
164
  />
167
165
  )}
166
+
167
+ {alertEl}
168
168
  </div>
169
169
  )
170
170
  }
@@ -20,9 +20,10 @@ export const DRAG_TYPE = 'application/x-dsh-atb-task'
20
20
  * The card view.
21
21
  * @param task - the task record.
22
22
  * @param controller - the controller.
23
- * @param draggable - enable dragging (backlog/todo columns only).
23
+ * @param draggable - enable dragging.
24
+ * @param onAlert - show an alert message (replaces native alert).
24
25
  */
25
- export function TaskCard({ task, controller, draggable = false }: { task: TaskRecord; controller: BoardController; draggable?: boolean }) {
26
+ export function TaskCard({ task, controller, draggable = false, onAlert }: { task: TaskRecord; controller: BoardController; draggable?: boolean; onAlert?: (msg: string) => void }) {
26
27
  const last = task.executions.length > 0 ? task.executions[task.executions.length - 1] : undefined
27
28
  return (
28
29
  <button
@@ -31,6 +32,15 @@ export function TaskCard({ task, controller, draggable = false }: { task: TaskRe
31
32
  data-urgency={task.urgency}
32
33
  draggable={draggable}
33
34
  onDragStart={(e) => {
35
+ // Block drag if a session is still executing this task
36
+ const running = task.executions.find(ex => ex.outcome === 'running')
37
+ if (running !== undefined) {
38
+ e.preventDefault()
39
+ const msg = `该任务正在由【${task.title}】会话执行,不能拖动`
40
+ if (onAlert !== undefined) onAlert(msg)
41
+ else alert(msg)
42
+ return
43
+ }
34
44
  e.dataTransfer.setData(DRAG_TYPE, task.id)
35
45
  e.dataTransfer.effectAllowed = 'move'
36
46
  e.currentTarget.dataset.dragging = 'true'
@@ -84,6 +84,16 @@ export function TaskDetail({ task, controller }: { task: TaskRecord; controller:
84
84
  </div>
85
85
  <div className="dsh-atb-detail-topbtns">
86
86
  <button type="button" className="dsh-atb-detail-edit" onClick={() => controller.openEditor(task.id)}>✎ 编辑</button>
87
+ {canRun && (
88
+ <button
89
+ type="button"
90
+ className="dsh-atb-detail-run"
91
+ title={task.model !== undefined ? `新会话执行(${task.model.model})` : '新会话执行(默认模型)'}
92
+ onClick={() => void controller.run(task.id)}
93
+ >
94
+ ▶ 立即执行
95
+ </button>
96
+ )}
87
97
  <button type="button" className="dsh-atb-detail-close" aria-label="关闭" onClick={() => controller.select(undefined)}>✕</button>
88
98
  </div>
89
99
  </div>
@@ -103,11 +113,6 @@ export function TaskDetail({ task, controller }: { task: TaskRecord; controller:
103
113
  )}
104
114
 
105
115
  <div className="dsh-atb-detail-actions">
106
- {canRun && (
107
- <button type="button" className="dsh-atb-runbtn" onClick={() => void controller.run(task.id)}>
108
- ▶ 执行 · 新会话{task.model !== undefined ? `(${task.model.model})` : '(默认模型)'}
109
- </button>
110
- )}
111
116
  <div className="dsh-atb-movebtns">
112
117
  {moveTargets(task).map(to => to === 'done'
113
118
  ? (confirmDone
@@ -118,10 +123,10 @@ export function TaskDetail({ task, controller }: { task: TaskRecord; controller:
118
123
  <button type="button" className="dsh-atb-btn" onClick={() => setConfirmDone(false)}>取消</button>
119
124
  </span>
120
125
  )
121
- : <button key={to} type="button" className="dsh-atb-movebtn" data-to={to} onClick={() => setConfirmDone(true)}>✓ {MOVE_LABEL[to]}</button>)
126
+ : <button key={to} type="button" className="dsh-atb-movebtn" data-to={to} onClick={() => setConfirmDone(true)}>移至→{MOVE_LABEL[to]}</button>)
122
127
  : (
123
128
  <button key={to} type="button" className="dsh-atb-movebtn" data-to={to} onClick={() => void controller.move(task.id, task.version, to)}>
124
- {MOVE_LABEL[to]}
129
+ 移至→{MOVE_LABEL[to]}
125
130
  </button>
126
131
  ))}
127
132
  <button type="button" className="dsh-atb-movebtn" data-to="blocked" onClick={() => void controller.toggleBlocked(task)}>
@@ -94,6 +94,9 @@ export function TaskFormModal({ controller, task }: { controller: BoardControlle
94
94
  const cronBad = mode === 'scheduled' && (cronMatch === null || nextRun === null)
95
95
  const valid = title.trim().length > 0 && workspaceId !== '' && !cronBad
96
96
 
97
+ // A task already in progress cannot be run again (host rejects it).
98
+ const runBlocked = editing && task.status === 'in_progress'
99
+
97
100
  const submit = (): void => {
98
101
  if (!valid) return
99
102
  const picked = model !== '' ? (JSON.parse(model) as { provider: string; model: string }) : undefined
@@ -121,6 +124,39 @@ export function TaskFormModal({ controller, task }: { controller: BoardControlle
121
124
  }
122
125
  }
123
126
 
127
+ /** Save the form, then immediately trigger a manual run of the task. */
128
+ const submitAndRun = (): void => {
129
+ if (!valid || runBlocked) return
130
+ const picked = model !== '' ? (JSON.parse(model) as { provider: string; model: string }) : undefined
131
+ if (editing) {
132
+ void (async () => {
133
+ const saved = await controller.update(task.id, task.version, {
134
+ title,
135
+ description,
136
+ prompt,
137
+ urgency,
138
+ workspaceId,
139
+ execution: mode === 'scheduled' ? { mode, cron: cron.trim() } : { mode },
140
+ model: picked ?? null,
141
+ })
142
+ if (saved) await controller.run(task.id)
143
+ })()
144
+ } else {
145
+ void (async () => {
146
+ const id = await controller.create({
147
+ title,
148
+ workspaceId,
149
+ urgency,
150
+ description: description.length > 0 ? description : undefined,
151
+ prompt: prompt.length > 0 ? prompt : undefined,
152
+ execution: mode === 'scheduled' ? { mode, cron: cron.trim() } : { mode },
153
+ model: picked,
154
+ })
155
+ if (id !== undefined) await controller.run(id)
156
+ })()
157
+ }
158
+ }
159
+
124
160
  const hint = !valid
125
161
  ? (title.trim().length === 0 ? '请填写标题' : workspaceId === '' ? '请选择项目' : 'Cron 表达式无效(分 时 日 月 周)')
126
162
  : mode === 'scheduled' && nextRun !== null
@@ -233,6 +269,15 @@ export function TaskFormModal({ controller, task }: { controller: BoardControlle
233
269
  <span className="dsh-atb-modal-hint" data-tone={valid ? undefined : 'bad'}>{hint}</span>
234
270
  <span className="dsh-atb-modal-footbtns">
235
271
  <button type="button" className="dsh-atb-btn" onClick={() => controller.closeForm()}>取消</button>
272
+ <button
273
+ type="button"
274
+ className="dsh-atb-btn"
275
+ disabled={!valid || runBlocked}
276
+ title={runBlocked ? '任务正在执行中,不能重复发起' : '保存后立即发起执行(新会话)'}
277
+ onClick={submitAndRun}
278
+ >
279
+ ⚡ 立即执行
280
+ </button>
236
281
  <button type="button" className="dsh-atb-btn" data-primary="true" disabled={!valid} onClick={submit}>
237
282
  {editing ? '保存修改' : '创建任务'}
238
283
  </button>
@@ -247,6 +292,7 @@ export function TaskFormModal({ controller, task }: { controller: BoardControlle
247
292
  interface TaskRecordLike {
248
293
  id: string
249
294
  version: number
295
+ status?: string
250
296
  title: string
251
297
  description: string
252
298
  prompt: string
@@ -166,25 +166,29 @@ export class BoardController {
166
166
  toggleSecondary(): void { this.setState({ secondaryOpen: !this.state.secondaryOpen }) }
167
167
 
168
168
  // ---------------------------------------------------------------- writes
169
- /** Create a task (composer submit). */
170
- async create(body: Parameters<TaskboardClient['create']>[0]): Promise<void> {
169
+ /** Create a task (composer submit); returns the new task id, undefined on failure. */
170
+ async create(body: Parameters<TaskboardClient['create']>[0]): Promise<string | undefined> {
171
171
  try {
172
- await this.client.create(body)
172
+ const summary = await this.client.create(body)
173
173
  this.setState({ composerOpen: false, error: undefined })
174
174
  await this.refresh()
175
+ return summary.id
175
176
  } catch (error) {
176
177
  this.setState({ error: error instanceof Error ? error.message : String(error) })
178
+ return undefined
177
179
  }
178
180
  }
179
181
 
180
182
  /** Edit task fields (form modal submit; the GUI is the owner surface). */
181
- async update(id: string, ifVersion: number, body: Omit<UpdateTaskBody, 'ifVersion'>): Promise<void> {
183
+ async update(id: string, ifVersion: number, body: Omit<UpdateTaskBody, 'ifVersion'>): Promise<boolean> {
182
184
  try {
183
185
  await this.client.update(id, { ifVersion, ...body })
184
186
  this.setState({ composerOpen: false, editingId: undefined, error: undefined })
185
187
  await this.refresh()
188
+ return true
186
189
  } catch (error) {
187
190
  this.setState({ error: error instanceof Error ? error.message : String(error) })
191
+ return false
188
192
  }
189
193
  }
190
194
 
@@ -172,12 +172,12 @@ html[data-dsh-atb-active] .dsh-atb-view { display: flex; flex-direction: column;
172
172
  .dsh-atb-desc { white-space: pre-wrap; word-break: break-word; font-size: 13px; line-height: 1.55; }
173
173
 
174
174
  .dsh-atb-detail-actions { display: flex; flex-direction: column; gap: 8px; }
175
- .dsh-atb-runbtn {
176
- font: inherit; font-size: 13px; font-weight: 600; padding: 8px 14px; border-radius: 9px; cursor: pointer;
177
- border: 1px solid transparent; background: var(--dsw-alias-button-primary-fill, var(--dsw-alias-brand-primary, #1f2328)); color: var(--dsw-alias-label-primary-foreground, #fff); text-align: center;
175
+ .dsh-atb-detail-run {
176
+ font: inherit; font-size: 12px; font-weight: 600; padding: 4px 11px; border-radius: 7px; cursor: pointer;
177
+ border: 1px solid transparent; background: var(--dsw-alias-button-primary-fill, var(--dsw-alias-brand-primary, #1f2328)); color: var(--dsw-alias-label-primary-foreground, #fff);
178
178
  transition: filter .12s ease;
179
179
  }
180
- .dsh-atb-runbtn:hover { filter: brightness(1.1); }
180
+ .dsh-atb-detail-run:hover { filter: brightness(1.1); }
181
181
  .dsh-atb-movebtns { display: flex; gap: 6px; flex-wrap: wrap; }
182
182
  .dsh-atb-movebtn {
183
183
  font: inherit; font-size: 12px; padding: 4px 11px; border-radius: 999px; cursor: pointer;
@@ -376,6 +376,29 @@ html[data-dsh-atb-active] .dsh-atb-view { display: flex; flex-direction: column;
376
376
  .dsh-atb-secondary { flex: 1; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 8px; }
377
377
  .dsh-atb-link { color: var(--dsw-alias-state-business-primary, #3e63dd); cursor: pointer; text-decoration: none; }
378
378
  .dsh-atb-link:hover { text-decoration: underline; }
379
+
380
+ /* ---------- alert modal ---------- */
381
+ .dsh-atb-alert-backdrop {
382
+ position: fixed; inset: 0; z-index: 90;
383
+ background: var(--dsw-alias-bg-mask-drop, rgba(28,30,36,.4)); backdrop-filter: var(--dsw-mask-blur, blur(2px));
384
+ display: flex; align-items: center; justify-content: center;
385
+ animation: dsh-atb-fade .12s ease;
386
+ }
387
+ .dsh-atb-alert {
388
+ min-width: 280px; max-width: 380px; padding: 20px 24px; border-radius: 14px;
389
+ background: var(--dsw-alias-bg-overlay, #fff); color: var(--dsw-alias-label-primary, inherit);
390
+ border: 1px solid var(--dsw-alias-border-l2, rgba(128,128,128,.25));
391
+ box-shadow: var(--dsw-shadow-lv3, 0 12px 32px rgba(0,0,0,.18));
392
+ display: flex; flex-direction: column; align-items: center; gap: 14px;
393
+ animation: dsh-atb-pop .14s ease;
394
+ }
395
+ .dsh-atb-alert-icon { font-size: 28px; line-height: 1; }
396
+ .dsh-atb-alert-msg {
397
+ font-size: 13.5px; line-height: 1.55; text-align: center;
398
+ word-break: break-word; white-space: pre-wrap;
399
+ color: var(--dsw-alias-label-primary, inherit);
400
+ }
401
+ .dsh-atb-alert .dsh-atb-btn { padding: 6px 28px; font-size: 13px; }
379
402
  `
380
403
 
381
404
  let injected = false
@@ -54,6 +54,8 @@ export interface ExecutionDeps {
54
54
  mintSessionId?: () => string
55
55
  /** Mint message ids (injectable for tests). */
56
56
  mintMessageId?: () => string
57
+ /** Best-effort session rename (pins the session list title to the task title). */
58
+ renameSession?: (sessionId: string, title: string) => void
57
59
  }
58
60
 
59
61
  /** Outcome of a run request (immediate; the run settles asynchronously). */
@@ -182,16 +184,25 @@ export class ExecutionService {
182
184
  // 3. Attach the session to the workspace (GUI project session list).
183
185
  await this.deps.workspaces.attach(task.workspaceId, sessionId).catch(() => { /* cosmetic */ })
184
186
 
187
+ // 3b. Best-effort rename: pin the session title to the task title so the
188
+ // session list shows the task name (a user-sourced title also stops
189
+ // automatic first-prompt retitling).
190
+ try {
191
+ this.deps.renameSession?.(sessionId, task.title)
192
+ } catch { /* cosmetic */ }
193
+
185
194
  // 4. Record the session id (execution is really started now).
186
195
  await this.patchExecution(executionId, { sessionId })
187
196
 
188
197
  // 5. Submit the effective prompt as an ordinary user message and settle
189
198
  // on quiescence (turn/end errors were already folded by the listener).
199
+ // Source `user` (not `plugin`) so the opening message renders as a
200
+ // normal user bubble in the conversation, exactly like a typed prompt.
190
201
  const message = {
191
202
  id: this.deps.mintMessageId?.() ?? MessageId(`msg-taskboard-${crypto.randomUUID()}`),
192
203
  role: 'user' as const,
193
204
  content: [{ type: 'text' as const, text: this.executionPrompt(task) }],
194
- source: { kind: 'plugin' as const, plugin: 'dsh-taskboard' },
205
+ source: { kind: 'user' as const },
195
206
  }
196
207
  handle.agent.followup(message)
197
208
 
@@ -221,12 +232,11 @@ export class ExecutionService {
221
232
 
222
233
  /** The prompt text one execution submits (task context + instructions). */
223
234
  private executionPrompt(task: TaskRecord): string {
224
- const head = `【任务看板执行】${task.title}(任务 ID: ${task.id})`
225
235
  const state = '本任务由执行服务启动本会话并已置为 in_progress(你无需再认领,也无需移到 done)。'
226
236
  const tail = `完成后请:1) 用 taskboard_get 读取任务 ${task.id} 拿最新 version;`
227
237
  + `2) 用 taskboard_comment_add 留评论(做了什么改动、如何验证、剩余风险);`
228
238
  + `3) 用 taskboard_move 把任务 ${task.id} 移到 in_review(带 ifVersion)。`
229
- return `${head}\n\n${state}\n\n${effectivePrompt(task)}\n\n${tail}`
239
+ return `【任务】${task.title}(任务 ID: ${task.id})\n\n${state}\n\n${effectivePrompt(task)}\n\n${tail}`
230
240
  }
231
241
 
232
242
  /** Move a task back out of in_progress after a failed start. */
package/src/index.ts CHANGED
@@ -84,6 +84,16 @@ export function apply(ctx: Context): void {
84
84
  },
85
85
  events,
86
86
  now,
87
+ renameSession: (sessionId, title) => {
88
+ // Best-effort: pin the execution session's title to the task title
89
+ // through the log-backed session-title service (user-sourced rename).
90
+ try {
91
+ const sessions = agentCtx.get('sessions') as { get(id: string): unknown } | undefined
92
+ const sessionTitle = agentCtx.get('sessionTitle') as { rename(session: unknown, title: string): unknown } | undefined
93
+ const session = sessions?.get(sessionId)
94
+ if (session !== undefined && sessionTitle !== undefined) sessionTitle.rename(session, title)
95
+ } catch { /* cosmetic */ }
96
+ },
87
97
  defaultModel: () => {
88
98
  try {
89
99
  const selection = agentCtx.get('agentDefaultModel') as { currentSelection?: () => { provider: string; model: string } | undefined } | undefined