dsh-tree-task-flow 0.3.13 → 0.3.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client.js CHANGED
@@ -1,30 +1,29 @@
1
1
  /**
2
- * dsh-tree-task-flow 的客户端:输入框上方的树形任务流条目。
2
+ * dsh-tree-task-flow 的客户端:输入框上方的需求条。
3
3
  *
4
- * 这是**手写**的模块包装格式(和 dsh-purge 的 client.js 一样),不经过打包,
5
- * 所以用 `h()` 而不是 JSX。
4
+ * 这是**手写**的模块包装格式,不经过打包,所以用 `h()` 而不是 JSX。
6
5
  *
7
6
  * 挂载点是 `conversation.input.dock`——输入框上方的全宽条目区,内置的任务清单
8
- * (`todo`)与目标(`goal`)就在那里。这是一个 `list` 槽:**用自己的 id 注册就
9
- * 会并列加在它们旁边**,不会替换任何一个;用内置的 id 才会顶掉它。
7
+ * (`todo`)与目标(`goal`)就在那里。这是一个 `list` 槽:用自己的 id 注册就会
8
+ * 并列加在它们旁边,不会替换任何一个。
10
9
  *
11
- * 外观照内置条目来:收起时是目标条(GoalBar)那样的 36px 单行条,展开后是待办
12
- * 面板(TodoPanel)那样的卡片。图标与 Tooltip 直接用外壳播种的 UI 基座
13
- * `@deepseek-ai/dsh-client-ui-primitives`,尺寸与颜色变量也都取它那一套,
14
- * 这样并排放在输入框上方时看起来是一家。
10
+ * 这一条只管三件事(见 `docs/09-需求与任务模型.md` §5):
15
11
  *
16
- * 没有计划的会话**一个像素都不占**:读不到计划就返回 null,免得平白挤掉内置条目。
12
+ * 1. **看**:需求两个列表(活跃 / 已归档)+ 待确认的草稿,当前进行中的那条就地
13
+ * 展开成任务与步骤;
14
+ * 2. **改**:用户改需求正文(标题 / 简介 / 详情)→ 点「确认变更」→ 落成**暂定版本**,
15
+ * 等 AI 确认。改的时候**不动**正在生效的正文;
16
+ * 3. **开始**:点某条需求的「开始」——这是能叫醒 AI 的三种动作之一(另外两种是
17
+ * 点「继续」与直接发消息)。
17
18
  *
18
- * 数据全部来自 host 端的 `/dsh-tree-task-flow/*` 接口;动作(暂停 / 继续 / 完成 /
19
- * 丢弃 / 清空)都是 POST 回 host,由 host 改真正的文件和 agent 状态。
19
+ * **归档按钮有一个刻意的延迟**:点了之后这一行**不动**,按钮变成「取消归档」,
20
+ * 反悔点一下就回去了;下次打开面板才按新状态重新分列表。防误点优先于即时一致。
20
21
  *
21
- * 「暂停」不是只停掉自动续行,而是**真的把会话按住**:host 侧在模型发起下一次
22
- * 请求之前等一个闸门,所以当前正在执行的工具会正常跑完,之后什么都不干;点
23
- * 「继续」就放行,模型从原处接着跑,整个过程不往会话里插任何消息。
22
+ * 没有需求的会话**一个像素都不占**:读不到就返回 null,免得平白挤掉内置条目。
24
23
  *
25
- * 「完成」必须带结果,所以它不是一步按钮:先在节点下展开一个输入框,写清产出了
26
- * 什么,再提交。结果一旦提交,这个节点整段执行过程就会被它顶替,所以输入框的
27
- * 提示语要把这一点讲明白。
24
+ * 另外挂在插件管理的**本插件自己的配置页** `plugins.bundle.config`(按 bundle 的
25
+ * 包名 `dsh-tree-task-flow` 作 key)——不是 DSH 设置面板里的一页标签:设置属于
26
+ * 这个插件,就该长在插件自己身上,从「插件」页点进这个 bundle 才看得到。
28
27
  */
29
28
  window.__ModuleLoader__.load({
30
29
  id: "dsh-tree-task-flow",
@@ -42,6 +41,11 @@ window.__ModuleLoader__.load({
42
41
  const NS = "dsh-tree-task-flow";
43
42
  /** 条目 id。用自己的 id,所以只会加在内置条目旁边。 */
44
43
  const ENTRY_ID = "dsh-tree-task-flow";
44
+ /**
45
+ * 本插件作为 bundle 的包名——`plugins.bundle.config` 的 key 用它,
46
+ * 跟 locale 命名空间 `NS` 是两个不同的字符串(那是文案的命名空间)。
47
+ */
48
+ const BUNDLE_PACKAGE = "dsh-tree-task-flow";
45
49
 
46
50
  let translate = (key) => key;
47
51
  const useT = () => translate;
@@ -49,10 +53,8 @@ window.__ModuleLoader__.load({
49
53
  // ------------------------------------------------------------ 内置基座
50
54
 
51
55
  /**
52
- * 内置 UI 基座。外壳启动时把这张表播种进模块表(react、cordis、store、
53
- * slots、primitives、dockkit),所以这里的 require 正常一定成功;
54
- * 仍然兜一层 try——万一这版外壳没有 primitives,条目该降级成文字,
55
- * 而不是整块白掉。
56
+ * 内置 UI 基座。外壳启动时把这张表播种进模块表,所以这里的 require 正常一定
57
+ * 成功;仍然兜一层 try——万一这版外壳没有 primitives,条目该降级成文字。
56
58
  */
57
59
  let primitives = {};
58
60
  try {
@@ -61,14 +63,6 @@ window.__ModuleLoader__.load({
61
63
  primitives = {};
62
64
  }
63
65
 
64
- /**
65
- * 内置图标名有两套写法,这里两套都认。
66
- *
67
- * 旧外壳把尺寸写进名字:`IconCheckOutline16`、`IconChevronDownOutline14`;
68
- * 升级后的外壳把名字里的尺寸换成笔画粗细:`IconCheckOutlineRegular`、
69
- * `IconCheckOutlineMedium`,尺寸改由 `size` 属性给。按「尺寸后缀 → 粗细后缀
70
- * → 裸名」的顺序找,两套外壳都取得回图标,不会平白降级成文字。
71
- */
72
66
  function findIcon(base, size) {
73
67
  const candidates = [base + size, base + "14", base + "16", base + "Regular", base + "Medium", base];
74
68
  for (const candidate of candidates) {
@@ -78,26 +72,18 @@ window.__ModuleLoader__.load({
78
72
  return null;
79
73
  }
80
74
 
81
- /** 取一个内置图标;这版外壳没有就返回 null,由调用方决定怎么退。 */
82
75
  function icon(base, size) {
83
76
  const component = findIcon(base, size);
84
77
  if (component === null) return null;
85
78
  return h(component, { size });
86
79
  }
87
80
 
88
- /** 用内置 Tooltip 包一层;没有 Tooltip 就原样返回。 */
89
81
  function withTooltip(label, node) {
90
82
  const Tooltip = primitives.Tooltip;
91
83
  if (typeof Tooltip !== "function") return node;
92
84
  return h(Tooltip, { label, side: "bottom", delayMs: 500 }, node);
93
85
  }
94
86
 
95
- /**
96
- * 图标按钮:有图标用图标,没图标退回短文字,保证永远点得到。
97
- *
98
- * 退回文字时换成 `dsh-ttf-iconBtnText`:按钮按文字宽度自己撑开、文字长了
99
- * 用省略号收住,所以并排的几个按钮不会挤成一团互相压。
100
- */
101
87
  function IconButton({ iconBase, label, size, disabled, onClick }) {
102
88
  const glyph = icon(iconBase, typeof size === "number" ? size : 16);
103
89
  return withTooltip(
@@ -136,23 +122,17 @@ window.__ModuleLoader__.load({
136
122
  try {
137
123
  data = text ? JSON.parse(text) : null;
138
124
  } catch {
139
- throw new Error(
140
- response.status + " 返回了非 JSON:" + String(text).slice(0, 120),
141
- );
125
+ throw new Error(response.status + " 返回了非 JSON:" + String(text).slice(0, 120));
142
126
  }
143
127
  if (!response.ok) {
144
128
  const error = new Error((data && data.error) || response.status + " " + response.statusText);
145
- // 调用方要能区分「这个会话没有计划」(正常)与真出错。
146
129
  error.status = response.status;
147
130
  throw error;
148
131
  }
149
132
  return data;
150
133
  }
151
134
 
152
- /**
153
- * 写操作。host 用 HTTP 200 携带 `{ ok: false, error }` 表示业务拒绝
154
- * (比如完成时没给结果),所以这里要把 `ok` 也当错误判一次。
155
- */
135
+ /** 写操作。host 用 HTTP 200 携带 `{ ok: false, error }` 表示业务拒绝,所以要把 ok 也判一次。 */
156
136
  async function post(body) {
157
137
  const data = await api("/dsh-tree-task-flow/action", {
158
138
  method: "POST",
@@ -163,24 +143,28 @@ window.__ModuleLoader__.load({
163
143
  return data;
164
144
  }
165
145
 
166
- function formatTime(at) {
167
- const n = Number(at);
168
- if (!n) return "";
169
- try {
170
- return new Date(n).toLocaleString();
171
- } catch {
172
- return "";
173
- }
146
+ // ---------------------------------------------------------------- 数据
147
+
148
+ /** 面板要的三组数据;接口缺字段时一律当空,不让界面炸。 */
149
+ function listsOf(data) {
150
+ const lists = (data && data.requirements) || {};
151
+ return {
152
+ active: Array.isArray(lists.active) ? lists.active : [],
153
+ archived: Array.isArray(lists.archived) ? lists.archived : [],
154
+ drafts: Array.isArray(lists.drafts) ? lists.drafts : [],
155
+ };
174
156
  }
175
157
 
176
- /** 三层节点总数与已完成数,给单行条上的进度用。 */
177
- function countNodes(goal) {
178
- let total = 1;
179
- let done = goal.status === "done" ? 1 : 0;
180
- for (const task of goal.tasks || []) {
158
+ /** 一条需求的进度分数:任务与步骤都算,放弃的两边都不算。 */
159
+ function countOf(row) {
160
+ let total = 0;
161
+ let done = 0;
162
+ for (const task of row.tasks || []) {
163
+ if (task.status === "dropped") continue;
181
164
  total += 1;
182
165
  if (task.status === "done") done += 1;
183
166
  for (const step of task.steps || []) {
167
+ if (step.status === "dropped") continue;
184
168
  total += 1;
185
169
  if (step.status === "done") done += 1;
186
170
  }
@@ -188,60 +172,12 @@ window.__ModuleLoader__.load({
188
172
  return { total, done };
189
173
  }
190
174
 
191
- /** 计划里的全部目标。一个会话可以有多个目标,它们是最外层的兄弟。 */
192
- function goalsOf(data) {
193
- return data?.plan?.goals || [];
194
- }
195
-
196
- /** 当前该盯着哪个目标:活动路径上的那个;没有就取第一个还没结束的,再退到最后一个。 */
197
- function focusGoal(data) {
198
- const goals = goalsOf(data);
199
- if (goals.length === 0) return null;
200
- const here = goals.find((each) => each.id === data?.active?.goalId);
201
- if (here) return here;
202
- return (
203
- goals.find((each) => each.status !== "done" && each.status !== "dropped") ||
204
- goals[goals.length - 1]
205
- );
206
- }
207
-
208
- /** 当前活动节点的标题:子任务优先,其次任务,最后目标。 */
209
- function currentLabel(data) {
210
- const active = data.active;
211
- if (!active) return "";
212
- const goal = focusGoal(data);
213
- if (!goal) return "";
214
- const task = (goal.tasks || []).find((each) => each.id === active.taskId);
215
- if (!task) return goal.title;
216
- const step = (task.steps || []).find((each) => each.id === active.stepId);
217
- return step ? step.title : task.title;
218
- }
219
-
220
- /** 活动路径上三层各自的 id。节点 id 全局唯一,所以一个集合就够判"是不是当前节点"。 */
221
- function currentIds(data) {
222
- const active = data.active;
223
- return new Set(
224
- [active?.goalId, active?.taskId, active?.stepId].filter(
225
- (each) => typeof each === "string" && each !== "",
226
- ),
227
- );
228
- }
229
-
230
- /**
231
- * 面板一打开该摊开哪些节点:活动路径上的三层,加上检查点节点以及它的祖先。
232
- *
233
- * 其余节点一律收起——正在走的那条路径本来就窄,摊平整棵树只会让人翻半天。
234
- * 检查点落在某个任务上时,目标也得一起摊开,否则连那一行都看不见。
235
- */
236
- function defaultOpenIds(data) {
237
- const ids = currentIds(data);
238
- const checkpoint = data.checkpoint;
239
- if (checkpoint !== null && checkpoint !== undefined && typeof checkpoint.id === "string") {
240
- ids.add(checkpoint.id);
241
- const goalId = focusGoal(data)?.id;
242
- if (checkpoint.level !== "goal" && typeof goalId === "string") ids.add(goalId);
243
- }
244
- return ids;
175
+ /** 状态的显示名。 */
176
+ function statusKey(status) {
177
+ if (status === "draft") return "status.draft";
178
+ if (status === "done") return "status.done";
179
+ if (status === "dropped") return "status.dropped";
180
+ return "status.open";
245
181
  }
246
182
 
247
183
  // ---------------------------------------------------------------- 文案
@@ -250,81 +186,114 @@ window.__ModuleLoader__.load({
250
186
  nav: "树形任务流",
251
187
  "state.running": "进行中",
252
188
  "state.paused": "已暂停",
253
- "state.done": "已全部完成",
254
- "state.checkpoint": "检查点",
255
- "checkpoint.note": "「{title}」的子节点已全部结束。要么继续给它拆子节点,要么提交结果并完成它。",
256
- "note.paused": "已暂停:正在跑的工具会跑完,之后不再往下走。点「继续」从原处接着跑,不会往会话里插消息。",
189
+ "state.idle": "待开始",
190
+ "status.open": "未完成",
191
+ "status.done": "已完成",
192
+ "status.dropped": "已放弃",
193
+ "status.draft": "草稿",
194
+ "label.requirements": "{count} 条需求",
195
+ "label.archived": "已归档",
196
+ "label.drafts": "待确认",
197
+ "label.current": "当前",
198
+ "label.missing": "缺前置",
199
+ "label.pending": "待确认变动",
200
+ "label.detail": "详情",
201
+ "label.tasks": "任务",
202
+ "label.summary": "总结",
257
203
  "action.pause": "暂停",
258
204
  "action.resume": "继续",
259
- "action.reset": "清空计划",
260
205
  "action.expand": "展开",
261
206
  "action.collapse": "收起",
262
- "action.done": "完成…",
263
- "action.drop": "丢弃",
264
- "action.submit": "提交结果",
265
- "action.expandResult": "展开看全文",
266
- "action.collapseResult": "收起",
207
+ "action.start": "开始这条需求",
208
+ "action.archive": "归档",
209
+ "action.unarchive": "取消归档",
210
+ "action.open": "看详情",
211
+ "action.edit": "改需求",
212
+ "action.submitChange": "确认变更",
213
+ "action.newRequirement": "新建需求",
214
+ "action.add": "记下来",
267
215
  "action.cancel": "取消",
268
- "label.result": "结果",
269
- "label.noResult": "(还没提交结果)",
270
- "label.current": "当前",
271
- "label.checkpointHere": "检查点",
272
- "label.goals": "{count} 个目标",
273
- "placeholder.result":
274
- "写清这个节点产出了什么。它整段执行过程会被这条结果顶替,所以要能独立看懂。",
275
- "hint.noChildren": "(还没有子节点 → 让模型调用 tree_task_plan)",
276
- "confirm.reset": "确定要清空这个会话的计划树吗?",
277
- "confirm.drop": "确定要丢弃「{title}」吗?",
216
+ "note.paused": "已暂停:正在跑的工具会跑完,之后不再往下走。点「继续」从原处接着跑。",
217
+ "note.archiveDeferred": "点了不会立刻移动:这一行留在原地,下次打开面板才按新状态分开。反悔就再点一下。",
218
+ "note.draft": "草稿还不算真需求。跟 AI 把「要什么」说清楚,它确认之后才会转正。",
219
+ "note.pending": "这条需求有用户提交的改动,等 AI 确认。确认之前仍按旧版本走。",
220
+ "note.edit": "改完点「确认变更」提交成暂定版本,AI 确认之后才生效。",
221
+ "note.noTasks": "还没有任务,让 AI 拆。",
222
+ "placeholder.title": "一句话说清要做什么(不超过 50 字)",
223
+ "placeholder.intro": "简介(不超过 200 字)",
224
+ "placeholder.detail": "详情,可以很长",
225
+ "settings.loading": "正在读设置…",
226
+ "settings.unavailable": "读不到设置:宿主那一半没在跑,或者接口不通。",
227
+ "settings.off": "插件在 profile 配置里被关掉了(enabled: false),这两个开关现在不起作用。",
228
+ "setting.note": "改完立刻生效,也会写进状态目录,重启 dsh web 之后还在。",
229
+ "setting.autoContinue": "任务没做完、模型自己停下时,自动把它叫起来接着跑",
230
+ "setting.autoContinueNote": "会往会话里推一条消息。默认关。",
231
+ "setting.interruptRecovery": "工具出错或网络断了,自动重试把会话拉起来",
232
+ "setting.interruptRecoveryNote": "按 1 / 5 / 10 / 30 分钟各拉一次,一直拉不起来就停手。你按过停止的绝不拉起。",
278
233
  };
279
234
 
280
235
  const en = {
281
- nav: "Tree Task Flow",
236
+ nav: "Task tree",
282
237
  "state.running": "In progress",
283
238
  "state.paused": "Paused",
284
- "state.done": "All done",
285
- "state.checkpoint": "Checkpoint",
286
- "checkpoint.note":
287
- "Every child of “{title}” has finished. Either break out more children, or submit its result and complete it.",
288
- "note.paused":
289
- "Paused: the running tool finishes, then nothing else happens. Resume picks up exactly where it stopped, without inserting any message.",
239
+ "state.idle": "Not started",
240
+ "status.open": "Open",
241
+ "status.done": "Done",
242
+ "status.dropped": "Dropped",
243
+ "status.draft": "Draft",
244
+ "label.requirements": "{count} requirements",
245
+ "label.archived": "Archived",
246
+ "label.drafts": "Waiting for you",
247
+ "label.current": "current",
248
+ "label.missing": "waiting on",
249
+ "label.pending": "pending change",
250
+ "label.detail": "Detail",
251
+ "label.tasks": "Tasks",
252
+ "label.summary": "Summary",
290
253
  "action.pause": "Pause",
291
254
  "action.resume": "Resume",
292
- "action.reset": "Clear plan",
293
255
  "action.expand": "Expand",
294
256
  "action.collapse": "Collapse",
295
- "action.done": "Complete…",
296
- "action.drop": "Drop",
297
- "action.submit": "Submit result",
298
- "action.expandResult": "Show the full result",
299
- "action.collapseResult": "Collapse",
257
+ "action.start": "Start this requirement",
258
+ "action.archive": "Archive",
259
+ "action.unarchive": "Unarchive",
260
+ "action.open": "Show detail",
261
+ "action.edit": "Edit",
262
+ "action.submitChange": "Submit change",
263
+ "action.newRequirement": "New requirement",
264
+ "action.add": "Save",
300
265
  "action.cancel": "Cancel",
301
- "label.result": "Result",
302
- "label.noResult": "(no result submitted yet)",
303
- "label.current": "current",
304
- "label.checkpointHere": "checkpoint",
305
- "label.goals": "{count} goals",
306
- "placeholder.result":
307
- "State what this node produced. Its whole execution range is replaced by this result, so it must stand on its own.",
308
- "hint.noChildren": "(no children yet → ask the model to call tree_task_plan)",
309
- "confirm.reset": "Clear this session's plan tree?",
310
- "confirm.drop": "Drop “{title}”?",
266
+ "note.paused": "Paused: the running tool finishes, then nothing else happens. Resume picks up exactly where it stopped.",
267
+ "note.archiveDeferred": "Nothing moves right away: the row stays put and the next time you open the panel it shows up in the other list. Click again to undo.",
268
+ "note.draft": "A draft is not a requirement yet. Describe what you want to the model; it becomes real once confirmed.",
269
+ "note.pending": "This requirement has a change submitted by you, waiting for the model to confirm. The old version is still in force.",
270
+ "note.edit": "Submit the change and it becomes a provisional version; it takes effect once the model confirms it.",
271
+ "note.noTasks": "No tasks yet — the model breaks it down.",
272
+ "placeholder.title": "One line: what should be done (<= 50 chars)",
273
+ "placeholder.intro": "Short intro (<= 200 chars)",
274
+ "placeholder.detail": "Detail, may be long",
275
+ "settings.loading": "Reading settings…",
276
+ "settings.unavailable": "Settings are unreachable: the host half is not running, or the endpoint is blocked.",
277
+ "settings.off": "The plugin is switched off in the profile config (enabled: false), so these switches do nothing right now.",
278
+ "setting.note": "Takes effect right away and is written to the state directory, so it survives a restart.",
279
+ "setting.autoContinue": "When the plan is unfinished and the model stops, wake it up to carry on",
280
+ "setting.autoContinueNote": "This pushes a message into the session. Off by default.",
281
+ "setting.interruptRecovery": "After a tool error or a dropped connection, retry to get the session going",
282
+ "setting.interruptRecoveryNote": "Retries at 1 / 5 / 10 / 30 minutes, then gives up. A session you stopped is never revived.",
311
283
  };
312
284
 
313
- // ---------------------------------------------------------------- 样式
285
+ /** 插件配置页上的两个开关。 */
286
+ const SETTING_ROWS = [
287
+ { key: "autoContinue", note: "setting.autoContinueNote" },
288
+ { key: "interruptRecovery", note: "setting.interruptRecoveryNote" },
289
+ ];
314
290
 
315
- /**
316
- * 样式照内置的 dock 条目写:宽高、间距、圆角、颜色变量都取
317
- * `dsh-client-ui-goal` 的 GoalBar 与内置待办面板 TodoPanel 的同一套值。
318
- *
319
- * 注入方式也照它们:一个带 `data-plugin-css` 的 <style> 标签——
320
- * 客户端 HMR 就是靠这个属性在插件卸载时收走样式的。
321
- */
322
- const CSS_TAG = "dsh-tree-task-flow/Dock.module.css";
291
+ // ---------------------------------------------------------------- 样式
323
292
 
324
293
  const CSS = [
325
- // 条目外壳:宽度算法与内置 goal / todo 条目完全一致,否则会和消息正文对不齐。
294
+ // 外壳与宽度:**必须照抄内置条目的宽度公式**。漏掉它,这一条会按父容器的
295
+ // 宽度铺满整个输入区(实测就是这样占满全屏的)。
326
296
  ".dsh-ttf-dock{box-sizing:border-box;width:calc(100% - var(--dsh-composer-side-clearance) - var(--dsh-composer-side-clearance) - var(--dsh-composer-dock-inset) - var(--dsh-composer-dock-inset) - var(--dsh-composer-dock-inset) - var(--dsh-composer-dock-inset));max-width:calc(var(--dsh-composer-card-max-width) - 4 * var(--dsh-composer-dock-inset));margin:0 auto;flex:none;--dsh-scrollbar-thumb:var(--dsw-alias-scrollbar-bg-l2);--dsh-scrollbar-thumb-hover:var(--dsw-alias-scrollbar-hover-l2)}",
327
- // 卡片:背景与圆角同内置条目,描边用 ::after 画(.5px 在缩放屏上才不糊)。
328
297
  ".dsh-ttf-panel{background:var(--dsw-specific-tip);border-radius:12px;position:relative;overflow:hidden}",
329
298
  '.dsh-ttf-panel:after{border:.5px solid var(--dsw-alias-border-l1);border-radius:inherit;content:"";pointer-events:none;position:absolute;inset:0}',
330
299
  ".dsh-ttf-bar{box-sizing:border-box;width:100%;height:36px;display:flex;align-items:center;gap:10px;padding:4px 5px 4px 12px}",
@@ -335,347 +304,308 @@ window.__ModuleLoader__.load({
335
304
  ".dsh-ttf-title{min-width:0;flex:1;color:var(--dsw-alias-label-primary-dimmed);font-size:13px;line-height:20px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
336
305
  ".dsh-ttf-progress{flex:none;max-width:45%;min-width:0;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:20px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
337
306
  ".dsh-ttf-actions{flex:none;display:flex;align-items:center;gap:2px}",
338
- ".dsh-ttf-iconBtn{corner-shape:round;width:28px;height:28px;border:none;background:0 0;border-radius:999px;color:var(--dsw-alias-label-tertiary);cursor:pointer;display:inline-flex;align-items:center;justify-content:center;padding:0;font:inherit;flex:none}",
339
- ".dsh-ttf-iconBtn:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary)}",
307
+ ".dsh-ttf-iconBtn{width:28px;height:28px;border:none;background:0 0;border-radius:999px;color:var(--dsw-alias-label-tertiary);cursor:pointer;display:inline-flex;align-items:center;justify-content:center;padding:0;font:inherit;flex:none}",
308
+ ".dsh-ttf-iconBtn:hover:enabled{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary)}",
340
309
  ".dsh-ttf-iconBtn:disabled{opacity:.4;cursor:default}",
341
- ".dsh-ttf-iconBtn:disabled:hover{background:0 0;color:var(--dsw-alias-label-tertiary)}",
342
- // 取不到内置图标时退回文字:固定 28px 宽装不下几个字,文字会溢出到相邻
343
- // 按钮上,看着就是几个按钮叠在一起。这里按文字宽度撑开、留出边距,
344
- // 并给一个上限,长了用省略号收住——几个按钮始终各占各的位置。
345
310
  ".dsh-ttf-iconBtnText{width:auto;min-width:28px;max-width:112px;height:24px;padding:0 8px}",
346
311
  ".dsh-ttf-iconText{min-width:0;max-width:100%;overflow:hidden;text-overflow:ellipsis;font-size:12px;line-height:16px;white-space:nowrap}",
347
- // 展开体:限高滚动——这是输入框旁边的位置,不能让它把会话挤没了。
348
312
  ".dsh-ttf-body{max-height:264px;overflow-y:auto;padding:2px 8px 8px}",
349
313
  ".dsh-ttf-note{margin:4px 4px 6px;padding:6px 8px;border-radius:8px;font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-module-platform)}",
350
- // 节点行:行高与 hover 背景照内置待办面板。
351
- // 一个节点。行本身跟着标题长,标记与按钮对齐第一行。
352
- ".dsh-ttf-node{position:relative}",
353
- // 同一层的相邻节点之间画一条分界线——任务与任务的分界就靠它。
354
- ".dsh-ttf-node+.dsh-ttf-node{border-top:1px solid var(--dsw-alias-border-l1);margin-top:2px;padding-top:2px}",
355
- ".dsh-ttf-row{position:relative;box-sizing:border-box;display:flex;align-items:flex-start;gap:8px;padding:6px 4px;border-radius:8px;color:var(--dsw-alias-label-primary);font-size:13px;line-height:20px}",
356
- ".dsh-ttf-row:hover{background:var(--dsw-alias-interactive-bg-hover)}",
314
+ ".dsh-ttf-section{margin:8px 4px 2px;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}",
315
+ ".dsh-ttf-row{position:relative;box-sizing:border-box;display:flex;flex-direction:column;gap:2px;padding:6px 4px;border-radius:8px;color:var(--dsw-alias-label-primary);font-size:13px;line-height:20px}",
357
316
  ".dsh-ttf-row:hover{background:var(--dsw-alias-interactive-bg-hover)}",
358
- // 标题不缩略:有多长写多长,多了换行。
359
- ".dsh-ttf-rowTitle{min-width:0;flex:1;white-space:pre-wrap;word-break:break-word}",
360
- ".dsh-ttf-rowActions{flex:none;display:flex;align-items:center;gap:2px;margin-top:-4px}",
361
- ".dsh-ttf-markCell{flex:none;display:inline-flex;align-items:center;margin-top:3px;color:var(--dsw-alias-label-tertiary)}",
362
- ".dsh-ttf-markDone{flex:none;display:inline-flex;align-items:center;margin-top:3px;color:var(--dsw-alias-state-success-primary,#1a7f37)}",
363
- ".dsh-ttf-markMuted{flex:none;display:inline-flex;align-items:center;margin-top:3px;color:var(--dsw-alias-label-caption,#00000066)}",
364
- // 正在运行的标记:就是原来那个虚线圆,让它匀速转起来。1s 一圈、线性、
365
- // 无限,与内置待办面板 in_progress 的节奏一致。
366
- "@keyframes dsh-ttf-spin{to{transform:rotate(360deg)}}",
367
- ".dsh-ttf-markSpin{animation:dsh-ttf-spin 1s linear infinite;transform-origin:50% 50%}",
368
- ".dsh-ttf-done{color:var(--dsw-alias-label-tertiary);text-decoration:line-through}",
369
- // 三层缩进。
370
- // 子节点:一条竖线 + 每个子节点一个拐角,把三层画成看得见的树。
371
- ".dsh-ttf-depth1,.dsh-ttf-depth2{margin-left:11px;padding-left:9px;border-left:1px solid var(--dsw-alias-border-l2)}",
372
- '.dsh-ttf-depth1>.dsh-ttf-row:before,.dsh-ttf-depth2>.dsh-ttf-row:before{content:"";position:absolute;left:-9px;top:16px;width:8px;height:1px;background:var(--dsw-alias-border-l2)}',
373
- // 详情里的每一块(说明 / 结果 / 表单)自成一块,块间留白 + 描边,分得开。
374
- ".dsh-ttf-detail>*+*{border-top:.5px solid var(--dsw-alias-border-l1);padding-top:6px}",
375
- ".dsh-ttf-tag{flex:none;margin-top:1px;border-radius:999px;padding:0 8px;font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-module-platform)}",
317
+ ".dsh-ttf-rowHead{display:flex;align-items:center;gap:8px;min-width:0}",
318
+ ".dsh-ttf-rowTitle{min-width:0;flex:1;white-space:pre-wrap;word-break:break-word;cursor:pointer}",
319
+ ".dsh-ttf-rowIntro{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px;white-space:pre-wrap;word-break:break-word}",
320
+ ".dsh-ttf-pill{flex:none;border:.5px solid var(--dsw-alias-border-l4);border-radius:999px;padding:0 8px;font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary)}",
321
+ ".dsh-ttf-pillDone{border-color:var(--dsw-alias-state-success-primary,#1a7f37);color:var(--dsw-alias-state-success-primary,#1a7f37)}",
322
+ ".dsh-ttf-pillDropped{opacity:.6;text-decoration:line-through}",
323
+ ".dsh-ttf-pillDraft{color:var(--dsw-alias-state-warning-primary,#a06800);border-color:var(--dsw-alias-state-warning-primary,#a06800)}",
324
+ ".dsh-ttf-tag{flex:none;border-radius:999px;padding:0 8px;font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-module-platform)}",
376
325
  ".dsh-ttf-tagWarn{color:var(--dsw-alias-state-warning-primary,#a06800);background:var(--dsw-alias-state-warning-bg,#ffcc002e)}",
377
- // 展开详情:说明、结果、完成表单。
378
326
  ".dsh-ttf-detail{display:flex;flex-direction:column;gap:6px;padding:2px 4px 4px}",
379
- ".dsh-ttf-detailLabel{margin-top:2px;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}",
327
+ ".dsh-ttf-detail>*+*{border-top:.5px solid var(--dsw-alias-border-l1);padding-top:6px}",
380
328
  ".dsh-ttf-detailText{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px;white-space:pre-wrap;word-break:break-word}",
381
- // 提交的结果:默认只占一行,点一下看全文。
382
- ".dsh-ttf-result{margin-top:2px;padding:4px 8px;border-radius:6px;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-primary);font-size:12px;line-height:18px;white-space:pre-wrap;word-break:break-word;overflow:hidden;display:-webkit-box;-webkit-line-clamp:1;-webkit-box-orient:vertical}",
383
- ".dsh-ttf-resultClick{cursor:pointer}",
384
- ".dsh-ttf-resultOpen{display:block;max-height:220px;overflow-y:auto}",
385
- ".dsh-ttf-form{margin:6px 0 2px}",
386
- ".dsh-ttf-input{box-sizing:border-box;width:100%;min-height:64px;padding:6px 8px;border:.5px solid var(--dsw-alias-border-l4);border-radius:8px;background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);font-family:ui-monospace,monospace;font-size:12px;line-height:18px;resize:vertical;outline:none}",
387
- ".dsh-ttf-input:focus{border-color:var(--dsw-alias-state-business-primary,#4d6bfe)}",
388
- ".dsh-ttf-input::placeholder{color:var(--dsw-alias-label-caption,#00000066)}",
389
- ".dsh-ttf-formBar{margin-top:6px;display:flex;align-items:center;gap:6px}",
390
- ".dsh-ttf-btn{border:.5px solid var(--dsw-alias-border-l4);background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);border-radius:6px;padding:2px 10px;font-family:inherit;font-size:12px;line-height:20px;cursor:pointer}",
391
- ".dsh-ttf-btn:hover{background:var(--dsw-alias-interactive-bg-hover)}",
329
+ ".dsh-ttf-list{margin:2px 0;padding-left:18px;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}",
330
+ ".dsh-ttf-btn{box-sizing:border-box;height:26px;padding:0 10px;border:.5px solid var(--dsw-alias-border-l4);border-radius:8px;background:0 0;color:var(--dsw-alias-label-primary);font:inherit;font-size:12px;cursor:pointer;flex:none}",
331
+ ".dsh-ttf-btn:hover:enabled{background:var(--dsw-alias-interactive-bg-hover)}",
392
332
  ".dsh-ttf-btn:disabled{opacity:.4;cursor:default}",
393
- ".dsh-ttf-btn:disabled:hover{background:var(--dsw-alias-bg-base)}",
394
- ".dsh-ttf-error{color:var(--dsw-alias-state-error-primary,#c00);font-size:12px;line-height:18px;white-space:pre-wrap}",
395
- ".dsh-ttf-hint{padding:4px 4px 6px 24px;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}",
396
- ].join("");
397
-
398
- if (
399
- typeof document !== "undefined" &&
400
- document.querySelector("style[data-plugin-css=" + JSON.stringify(CSS_TAG) + "]") === null
401
- ) {
402
- const tag = document.createElement("style");
403
- tag.dataset.plugin = "dsh-tree-task-flow";
404
- tag.dataset.pluginCss = CSS_TAG;
405
- tag.textContent = CSS;
406
- document.head.appendChild(tag);
407
- }
408
-
409
- // ---------------------------------------------------------------- 零件
410
-
411
- /**
412
- * 节点状态标记:完成打勾、丢弃划掉、未完成画圈(当前节点用虚线圈,和内置待办一致)。
413
- *
414
- * 正在运行的那个圈还会匀速转起来——虚线一转就能看出这条路径还在推进。
415
- */
416
- function StatusMark({ node, isCurrent }) {
417
- if (node.status === "done") {
418
- const check = icon("IconCheckOutline", 14);
419
- return h("span", { className: "dsh-ttf-markDone" }, check === null ? "✓" : check);
420
- }
421
- if (node.status === "dropped") {
422
- const close = icon("IconCloseOutline", 14);
423
- return h("span", { className: "dsh-ttf-markMuted" }, close === null ? "×" : close);
333
+ ".dsh-ttf-form{display:flex;flex-direction:column;gap:6px;margin:6px 0 2px}",
334
+ ".dsh-ttf-input{box-sizing:border-box;width:100%;height:28px;padding:0 8px;border:.5px solid var(--dsw-alias-border-l4);border-radius:8px;background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);font:inherit;font-size:12px;outline:none}",
335
+ ".dsh-ttf-textarea{box-sizing:border-box;width:100%;min-height:64px;padding:6px 8px;border:.5px solid var(--dsw-alias-border-l4);border-radius:8px;background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);font-family:ui-monospace,monospace;font-size:12px;line-height:18px;resize:vertical;outline:none}",
336
+ ".dsh-ttf-input:focus,.dsh-ttf-textarea:focus{border-color:var(--dsw-alias-state-business-primary,#4d6bfe)}",
337
+ ".dsh-ttf-input::placeholder,.dsh-ttf-textarea::placeholder{color:var(--dsw-alias-label-caption,#00000066)}",
338
+ ".dsh-ttf-formActions{margin-top:2px;display:flex;align-items:center;justify-content:flex-end;gap:6px}",
339
+ ".dsh-ttf-settingsPage{display:flex;flex-direction:column;gap:10px;padding:4px 2px;max-width:640px}",
340
+ ".dsh-ttf-settings{display:flex;flex-direction:column;gap:12px}",
341
+ ".dsh-ttf-setting{display:flex;align-items:flex-start;gap:10px}",
342
+ ".dsh-ttf-settingText{display:flex;flex-direction:column;gap:2px;min-width:0}",
343
+ ".dsh-ttf-settingName{color:var(--dsw-alias-label-primary);font-size:13px;line-height:20px}",
344
+ ".dsh-ttf-settingNote{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}",
345
+ ".dsh-ttf-switch{box-sizing:border-box;flex:none;position:relative;width:32px;height:18px;margin-top:1px;padding:0;border:.5px solid var(--dsw-alias-border-l4);border-radius:999px;background:var(--dsw-alias-bg-module-platform);cursor:pointer}",
346
+ ".dsh-ttf-switchOn{background:var(--dsw-alias-state-business-primary,#4d6bfe);border-color:transparent}",
347
+ ".dsh-ttf-switchKnob{position:absolute;top:1px;left:1px;width:14px;height:14px;border-radius:999px;background:#fff;transition:left .12s}",
348
+ ".dsh-ttf-switchOn .dsh-ttf-switchKnob{left:15px}",
349
+ ].join("\n");
350
+
351
+ function ensureStyles() {
352
+ const id = "dsh-tree-task-flow:css";
353
+ if (typeof document === "undefined" || document === null) return;
354
+ if (typeof document.createElement !== "function") return;
355
+ try {
356
+ // 自检里的假 document 只有 createElement:取不到已有节点就当作没有。
357
+ if (typeof document.getElementById === "function" && document.getElementById(id) !== null) return;
358
+ const style = document.createElement("style");
359
+ style.id = id;
360
+ style.textContent = CSS;
361
+ if (document.head && typeof document.head.appendChild === "function") {
362
+ document.head.appendChild(style);
363
+ }
364
+ } catch {
365
+ // 样式注入失败不该拖垮条目:没有样式也还能用。
424
366
  }
425
- const circle = { cx: 7, cy: 7, r: 5.2, stroke: "currentColor", strokeWidth: 1.2 };
426
- if (isCurrent) circle.strokeDasharray = "2.4 2.4";
427
- return h(
428
- "span",
429
- { className: "dsh-ttf-markCell" },
430
- h(
431
- "svg",
432
- {
433
- width: 14,
434
- height: 14,
435
- viewBox: "0 0 14 14",
436
- fill: "none",
437
- "aria-hidden": true,
438
- className: isCurrent ? "dsh-ttf-markSpin" : undefined,
439
- },
440
- h("circle", circle),
441
- ),
442
- );
443
367
  }
444
368
 
445
- /**
446
- * 提交的结果。默认只占一行,点一下摊开看全文。
447
- *
448
- * 短到一行放得下的结果不给展开——点了也没变化,只是徒增噪音。
449
- */
450
- function ResultText({ text, t }) {
451
- const [open, setOpen] = useState(false);
452
- const body = String(text ?? "");
453
- const expandable = body.includes("\n") || body.length > 60;
454
- return h(
455
- "div",
456
- {
457
- className:
458
- "dsh-ttf-result" +
459
- (expandable ? " dsh-ttf-resultClick" : "") +
460
- (open ? " dsh-ttf-resultOpen" : ""),
461
- role: expandable ? "button" : undefined,
462
- tabIndex: expandable ? 0 : undefined,
463
- title: expandable ? t(open ? "action.collapseResult" : "action.expandResult") : undefined,
464
- onClick: expandable
465
- ? () => setOpen(!open)
466
- : undefined,
467
- },
468
- body,
469
- );
369
+ // ---------------------------------------------------------------- 组件
370
+
371
+ /** 状态那枚小药丸。 */
372
+ function StatusPill({ row, t }) {
373
+ const key = statusKey(row.status);
374
+ const extra =
375
+ row.status === "done"
376
+ ? " dsh-ttf-pillDone"
377
+ : row.status === "dropped"
378
+ ? " dsh-ttf-pillDropped"
379
+ : "";
380
+ return h("span", { className: "dsh-ttf-pill" + extra }, t(key));
470
381
  }
471
382
 
472
- /** 提交结果的输入框。空文本提交不出去——host 也会拒绝,这里先挡住。 */
473
- function CompleteForm({ onSubmit, onCancel, busy, error, t }) {
474
- const [text, setText] = useState("");
383
+ /** 编辑态:标题 / 简介 / 详情 + 「确认变更」。 */
384
+ function EditForm({ row, onSubmit, onCancel, busy, t }) {
385
+ const [title, setTitle] = useState(row.title || "");
386
+ const [intro, setIntro] = useState(row.intro || "");
387
+ const [detail, setDetail] = useState(row.detail || "");
388
+ const [change, setChange] = useState("");
475
389
  return h(
476
390
  "div",
477
391
  { className: "dsh-ttf-form" },
392
+ h("input", {
393
+ className: "dsh-ttf-input",
394
+ value: title,
395
+ maxLength: 80,
396
+ placeholder: t("placeholder.title"),
397
+ onChange: (event) => setTitle(event.target.value),
398
+ }),
399
+ h("input", {
400
+ className: "dsh-ttf-input",
401
+ value: intro,
402
+ maxLength: 240,
403
+ placeholder: t("placeholder.intro"),
404
+ onChange: (event) => setIntro(event.target.value),
405
+ }),
478
406
  h("textarea", {
407
+ className: "dsh-ttf-textarea",
408
+ value: detail,
409
+ placeholder: t("placeholder.detail"),
410
+ onChange: (event) => setDetail(event.target.value),
411
+ }),
412
+ h("input", {
479
413
  className: "dsh-ttf-input",
480
- value: text,
481
- rows: 4,
482
- placeholder: t("placeholder.result"),
483
- onChange: (event) => setText(event.target.value),
414
+ value: change,
415
+ placeholder: "这次改了什么(会写进版本历史)",
416
+ onChange: (event) => setChange(event.target.value),
484
417
  }),
485
- error ? h("div", { className: "dsh-ttf-error" }, error) : null,
418
+ h("div", { className: "dsh-ttf-note" }, t("note.edit")),
486
419
  h(
487
420
  "div",
488
- { className: "dsh-ttf-formBar" },
421
+ { className: "dsh-ttf-formActions" },
422
+ h("button", { type: "button", className: "dsh-ttf-btn", disabled: busy, onClick: onCancel }, t("action.cancel")),
489
423
  h(
490
424
  "button",
491
425
  {
492
426
  type: "button",
493
427
  className: "dsh-ttf-btn",
494
- disabled: busy || text.trim() === "",
495
- onClick: () => onSubmit(text),
428
+ disabled: busy,
429
+ onClick: () =>
430
+ onSubmit({ title, intro, detail, change }),
496
431
  },
497
- t("action.submit"),
432
+ t("action.submitChange"),
498
433
  ),
499
- h("button", { type: "button", className: "dsh-ttf-btn", disabled: busy, onClick: onCancel }, t("action.cancel")),
500
434
  ),
501
435
  );
502
436
  }
503
437
 
504
- /**
505
- * 一个节点:一行标题 + 可展开的详情。
506
- *
507
- * 三层共用同一个组件——它们的字段与动作本来就一样,差别只有缩进,
508
- * 以及"这一层要不要给丢弃按钮"(目标、任务、子任务都能丢)。
509
- */
510
- function NodeRow({ node, depth, activeIds, openIds, checkpointId, canDrop, sessionId, reload, t }) {
511
- const children = node.tasks || node.steps || [];
512
- const canAct = node.status === "pending";
513
- const isCurrent = activeIds.has(node.id);
514
- const isCheckpoint = checkpointId === node.id;
515
- const shouldOpen = openIds.has(node.id);
516
- const expandable = children.length > 0 || node.detail !== undefined || node.result !== undefined;
517
-
518
- // 默认只摊开"正在走的那条路径"(见 defaultOpenIds):目标一路展开到当前子任务,
519
- // 其余节点收起。否则一打开面板就是一整棵摊平的树,想找正在跑的那个得翻半天。
520
- const [open, setOpen] = useState(shouldOpen);
521
- const [composing, setComposing] = useState(false);
522
- const [busy, setBusy] = useState(false);
523
- const [error, setError] = useState(null);
524
-
525
- // 活动节点在会话里往前走了,就把它摊开:当前任务不该埋在收起里。
526
- useEffect(() => {
527
- if (shouldOpen) setOpen(true);
528
- }, [shouldOpen]);
529
-
530
- const act = useCallback(
531
- async (body) => {
532
- setBusy(true);
533
- setError(null);
534
- try {
535
- await post(Object.assign({ sessionId }, body));
536
- setComposing(false);
537
- await reload();
538
- } catch (failure) {
539
- setError(String((failure && failure.message) || failure));
540
- } finally {
541
- setBusy(false);
542
- }
543
- },
544
- [sessionId, reload],
438
+ /** 一条需求的详情(只读)。 */
439
+ function Detail({ row, t }) {
440
+ const tasks = row.tasks || [];
441
+ return h(
442
+ "div",
443
+ { className: "dsh-ttf-detail" },
444
+ row.intro ? h("div", { className: "dsh-ttf-detailText" }, row.intro) : null,
445
+ row.detail
446
+ ? h("div", null, h("div", { className: "dsh-ttf-section" }, t("label.detail")), h("div", { className: "dsh-ttf-detailText" }, row.detail))
447
+ : null,
448
+ row.missing && row.missing.length > 0
449
+ ? h("div", { className: "dsh-ttf-note" }, t("label.missing") + ":" + row.missing.map((each) => each.id).join("、"))
450
+ : null,
451
+ row.pending
452
+ ? h("div", { className: "dsh-ttf-note" }, t("label.pending") + ":" + (row.pending.change || "(未说明改了什么)"))
453
+ : null,
454
+ tasks.length > 0
455
+ ? h(
456
+ "div",
457
+ null,
458
+ h("div", { className: "dsh-ttf-section" }, t("label.tasks")),
459
+ h(
460
+ "ul",
461
+ { className: "dsh-ttf-list" },
462
+ tasks.map((task) =>
463
+ h(
464
+ "li",
465
+ { key: task.id },
466
+ (task.status === "done" ? "✓ " : task.status === "dropped" ? "✗ " : "· ") + task.title,
467
+ (task.steps || []).length > 0
468
+ ? h(
469
+ "ul",
470
+ { className: "dsh-ttf-list" },
471
+ task.steps.map((step) =>
472
+ h(
473
+ "li",
474
+ { key: step.id },
475
+ (step.status === "done" ? "✓ " : step.status === "dropped" ? "✗ " : "· ") + step.title,
476
+ ),
477
+ ),
478
+ )
479
+ : null,
480
+ ),
481
+ ),
482
+ ),
483
+ )
484
+ : h("div", { className: "dsh-ttf-note" }, t("note.noTasks")),
485
+ row.result
486
+ ? h("div", null, h("div", { className: "dsh-ttf-section" }, t("label.summary")), h("div", { className: "dsh-ttf-detailText" }, row.result))
487
+ : null,
545
488
  );
489
+ }
546
490
 
491
+ /** 一条需求。 */
492
+ function RequirementRow({ row, isCurrent, expanded, busy, onToggle, onStart, onArchive, onEdit, t }) {
493
+ const counts = countOf(row);
547
494
  return h(
548
495
  "div",
549
- { className: "dsh-ttf-node dsh-ttf-depth" + depth },
496
+ { className: "dsh-ttf-row" },
550
497
  h(
551
498
  "div",
552
- { className: "dsh-ttf-row" },
553
- h(StatusMark, { node, isCurrent }),
554
- h(
555
- "span",
556
- { className: "dsh-ttf-rowTitle" + (node.status === "done" ? " dsh-ttf-done" : "") },
557
- node.title,
558
- ),
559
- isCurrent ? h("span", { className: "dsh-ttf-tag" }, t("label.current")) : null,
560
- isCheckpoint ? h("span", { className: "dsh-ttf-tag dsh-ttf-tagWarn" }, t("label.checkpointHere")) : null,
499
+ { className: "dsh-ttf-rowHead" },
500
+ h(StatusPill, { row, t }),
501
+ h("span", { className: "dsh-ttf-tag" }, row.id),
561
502
  h(
562
503
  "span",
563
- { className: "dsh-ttf-rowActions" },
564
- canAct
565
- ? h(IconButton, {
566
- iconBase: "IconCheckOutline",
567
- label: t("action.done"),
568
- disabled: busy,
569
- onClick: () => {
570
- setComposing(true);
571
- setOpen(true);
572
- },
573
- })
574
- : null,
575
- canAct && canDrop
576
- ? h(IconButton, {
577
- iconBase: "IconTrashOutline",
578
- label: t("action.drop"),
579
- disabled: busy,
580
- onClick: () => {
581
- if (window.confirm(t("confirm.drop", { title: node.title }))) {
582
- act({ action: "drop", id: node.id });
583
- }
584
- },
585
- })
586
- : null,
587
- expandable
588
- ? h(IconButton, {
589
- iconBase: open ? "IconChevronDownOutline" : "IconChevronRightOutline",
590
- label: open ? t("action.collapse") : t("action.expand"),
591
- onClick: () => setOpen(!open),
592
- })
593
- : null,
504
+ { className: "dsh-ttf-rowTitle", onClick: onToggle, title: row.title },
505
+ row.title || "(还没有标题)",
594
506
  ),
507
+ isCurrent ? h("span", { className: "dsh-ttf-tag dsh-ttf-tagWarn" }, t("label.current")) : null,
508
+ counts.total > 0
509
+ ? h("span", { className: "dsh-ttf-progress" }, counts.done + "/" + counts.total)
510
+ : null,
511
+ row.status === "pending" && !row.pending
512
+ ? h(IconButton, { iconBase: "IconPlayOutline", label: t("action.start"), disabled: busy, onClick: onStart })
513
+ : null,
514
+ row.pending
515
+ ? null
516
+ : h(IconButton, {
517
+ iconBase: row.archived ? "IconUndoOutline" : "IconArchiveOutline",
518
+ label: row.archived ? t("action.unarchive") : t("action.archive"),
519
+ disabled: busy,
520
+ onClick: onArchive,
521
+ }),
522
+ h(IconButton, {
523
+ iconBase: expanded ? "IconChevronUpOutline" : "IconChevronDownOutline",
524
+ label: expanded ? t("action.collapse") : t("action.open"),
525
+ onClick: onToggle,
526
+ }),
595
527
  ),
596
- open
528
+ row.intro ? h("div", { className: "dsh-ttf-rowIntro" }, row.intro) : null,
529
+ expanded
597
530
  ? h(
598
531
  "div",
599
- { className: "dsh-ttf-detail" },
600
- node.detail ? h("div", { className: "dsh-ttf-detailText" }, node.detail) : null,
601
- node.result
602
- ? h(
603
- "div",
604
- null,
605
- h(
606
- "div",
607
- { className: "dsh-ttf-detailLabel" },
608
- t("label.result") + "(" + formatTime(node.result.at) + ")",
609
- ),
610
- h(ResultText, { text: node.result.text, t }),
611
- )
532
+ null,
533
+ h(Detail, { row, t }),
534
+ row.pending
535
+ ? null
612
536
  : h(
613
537
  "div",
614
- { className: "dsh-ttf-detailLabel" },
615
- t("label.result") + ":" + t("label.noResult"),
538
+ { className: "dsh-ttf-formActions" },
539
+ h("button", { type: "button", className: "dsh-ttf-btn", disabled: busy, onClick: onEdit }, t("action.edit")),
616
540
  ),
617
- composing
618
- ? h(CompleteForm, {
619
- t,
620
- busy,
621
- error,
622
- onSubmit: (text) => act({ action: "done", id: node.id, result: text }),
623
- onCancel: () => {
624
- setComposing(false);
625
- setError(null);
626
- },
627
- })
628
- : null,
629
- children.map((child) =>
630
- h(NodeRow, {
631
- key: child.id,
632
- node: child,
633
- depth: depth + 1,
634
- activeIds,
635
- openIds,
636
- checkpointId,
637
- canDrop: true,
638
- sessionId,
639
- reload,
640
- t,
641
- }),
642
- ),
643
- children.length === 0 && depth < 2
644
- ? h("div", { className: "dsh-ttf-hint" }, t("hint.noChildren"))
645
- : null,
646
541
  )
647
542
  : null,
648
543
  );
649
544
  }
650
545
 
651
- // ---------------------------------------------------------------- 条目
546
+ /** 新建需求:只产出草稿。 */
547
+ function DraftForm({ onSubmit, onCancel, busy, t }) {
548
+ const [title, setTitle] = useState("");
549
+ const [intro, setIntro] = useState("");
550
+ return h(
551
+ "div",
552
+ { className: "dsh-ttf-form" },
553
+ h("input", {
554
+ className: "dsh-ttf-input",
555
+ value: title,
556
+ placeholder: t("placeholder.title"),
557
+ onChange: (event) => setTitle(event.target.value),
558
+ }),
559
+ h("input", {
560
+ className: "dsh-ttf-input",
561
+ value: intro,
562
+ placeholder: t("placeholder.intro"),
563
+ onChange: (event) => setIntro(event.target.value),
564
+ }),
565
+ h("div", { className: "dsh-ttf-note" }, t("note.draft")),
566
+ h(
567
+ "div",
568
+ { className: "dsh-ttf-formActions" },
569
+ h("button", { type: "button", className: "dsh-ttf-btn", disabled: busy, onClick: onCancel }, t("action.cancel")),
570
+ h(
571
+ "button",
572
+ {
573
+ type: "button",
574
+ className: "dsh-ttf-btn",
575
+ disabled: busy || title.trim() === "",
576
+ onClick: () => onSubmit({ title, intro }),
577
+ },
578
+ t("action.add"),
579
+ ),
580
+ ),
581
+ );
582
+ }
652
583
 
653
- /**
654
- * 输入框上方的全宽条目。
655
- *
656
- * 收起时只有一行:图标 + 阶段标签 + 目标标题 + 进度,动作全在右侧的图标
657
- * 按钮里(暂停 / 继续、清空、展开)。展开才画整棵树,并限高滚动。
658
- *
659
- * 会话没有计划时返回 null:内置的 todo / goal 条目不该因为我而挪位。
660
- */
584
+ /** 输入框上方的全宽条目。 */
661
585
  function TaskTreeDock(props) {
662
586
  const t = useT();
663
587
  const sessionId = props.sessionId;
664
- const [state, setState] = useState({ loading: true });
588
+ const [state, setState] = useState({ loading: true, data: null });
665
589
  const [open, setOpen] = useState(false);
666
590
  const [busy, setBusy] = useState(false);
591
+ const [note, setNote] = useState(null);
592
+ // 归档的**延迟渲染**:这里记的是"这一次打开面板期间点过的开关",
593
+ // 它只改按钮文案,不改行所在的列表。下次打开面板时清空,行才真的搬家。
594
+ const [overrides, setOverrides] = useState({});
595
+ const [expandedId, setExpandedId] = useState(null);
596
+ const [editingId, setEditingId] = useState(null);
597
+ const [drafting, setDrafting] = useState(false);
667
598
 
668
599
  const reload = useCallback(async () => {
669
600
  if (!sessionId) {
670
- setState({ loading: false, empty: true });
601
+ setState({ loading: false, data: null });
671
602
  return;
672
603
  }
673
604
  try {
674
605
  const data = await api("/dsh-tree-task-flow/tree?sessionId=" + encodeURIComponent(sessionId));
675
606
  setState({ loading: false, data });
676
607
  } catch {
677
- // 没有计划(404)是正常状态;真出错也不该占位。两种都当作"没有"。
678
- setState({ loading: false, empty: true });
608
+ setState({ loading: false, data: null });
679
609
  }
680
610
  }, [sessionId]);
681
611
 
@@ -683,25 +613,25 @@ window.__ModuleLoader__.load({
683
613
  reload();
684
614
  }, [reload]);
685
615
 
686
- // 模型随时会改计划,而条目没有推送通道,所以自己轮询。
687
- // 4 秒一次,读的是插件自己的一个小 JSON。
688
616
  useEffect(() => {
689
617
  if (!sessionId) return undefined;
690
- const timer = setInterval(() => {
691
- reload();
692
- }, 4000);
618
+ const timer = setInterval(() => reload(), 4000);
693
619
  return () => clearInterval(timer);
694
620
  }, [sessionId, reload]);
695
621
 
696
- // 这次操作的结果说明(已放行 / 已唤醒 / 为什么没动):恢复失败时用户最需要
697
- // 的就是这句话,不能只留在接口的返回里。
698
- const [note, setNote] = useState(null);
699
622
  useEffect(() => {
700
623
  if (!note) return undefined;
701
624
  const timer = setTimeout(() => setNote(null), 8000);
702
625
  return () => clearTimeout(timer);
703
626
  }, [note]);
704
627
 
628
+ // 打开面板 = 重新读一次,并把「延迟的归档」结算掉。
629
+ useEffect(() => {
630
+ if (!open) return;
631
+ setOverrides({});
632
+ reload();
633
+ }, [open, reload]);
634
+
705
635
  const act = useCallback(
706
636
  async (body) => {
707
637
  setBusy(true);
@@ -712,6 +642,8 @@ window.__ModuleLoader__.load({
712
642
  setNote(result.message);
713
643
  }
714
644
  await reload();
645
+ } catch (failure) {
646
+ setNote(String((failure && failure.message) || failure));
715
647
  } finally {
716
648
  setBusy(false);
717
649
  }
@@ -719,36 +651,72 @@ window.__ModuleLoader__.load({
719
651
  [sessionId, reload],
720
652
  );
721
653
 
722
- if (state.loading || state.empty || !state.data) return null;
654
+ if (state.loading || !state.data) return null;
723
655
 
724
656
  const data = state.data;
725
- const goals = goalsOf(data);
726
- if (goals.length === 0) return null;
727
- // 一个会话可能有多个目标:条上给总数与合计进度,正文里把它们都列出来,
728
- // 展开状态仍旧只摊开正在走的那条路径。
729
- const stats = goals.reduce(
730
- (acc, each) => {
731
- const one = countNodes(each);
732
- return { total: acc.total + one.total, done: acc.done + one.done };
733
- },
734
- { total: 0, done: 0 },
735
- );
736
- const heading = goals.length === 1 ? goals[0].title : t("label.goals", { count: goals.length });
737
- const current = currentLabel(data);
738
- // 「被按住」有两条来源:插件闸门(paused)与人按过界面停止(stopped)。
739
- // 对用户来说都是"停着呢,点继续就能接着跑",所以合成一个状态展示。
657
+ const lists = listsOf(data);
658
+ const total = lists.active.length + lists.archived.length + lists.drafts.length;
659
+ // 一条需求都没有的会话,一个像素都不占。
660
+ if (total === 0) return null;
661
+
740
662
  const held = data.paused === true || data.stopped === true;
663
+ const currentId = data.current || null;
664
+ const currentRow = lists.active.find((row) => row.id === currentId) || null;
665
+ // 没有进行中的需求时,条上照**最近的那一条**显,状态也照它自己的来。
666
+ // 以前这里直接退回「待开始」,于是一条已经做完的需求在条上写着待开始、
667
+ // 点开明明是已完成——两处对不上(用户报的就是这个)。
668
+ const shownRow =
669
+ currentRow !== null ? currentRow : lists.active.length > 0 ? lists.active[lists.active.length - 1] : null;
670
+ const heading =
671
+ shownRow !== null
672
+ ? shownRow.title
673
+ : lists.archived.length > 0
674
+ ? t("label.archived")
675
+ : t("label.drafts");
741
676
  const stateLabel = held
742
677
  ? t("state.paused")
743
- : data.checkpoint
744
- ? t("state.checkpoint")
745
- : data.active === null
746
- ? t("state.done")
747
- : t("state.running");
748
- // 条首的清单图标;外壳连这个图标都没有时退回一个同类字符,别留空白。
749
- const listIcon = icon("IconChecklistOutline", 14);
678
+ : currentRow !== null
679
+ ? t("state.running")
680
+ : shownRow !== null
681
+ ? t(statusKey(shownRow.status))
682
+ : t("state.idle");
683
+ const counts = currentRow === null ? null : countOf(currentRow);
750
684
  const listGlyph =
751
- listIcon === null ? h("span", { className: "dsh-ttf-glyphFallback" }, "☰") : listIcon;
685
+ icon("IconChecklistOutline", 14) === null
686
+ ? h("span", { className: "dsh-ttf-glyphFallback" }, "☰")
687
+ : icon("IconChecklistOutline", 14);
688
+
689
+ const rowOf = (row) => {
690
+ const shown = overrides[row.id];
691
+ return Object.assign({}, row, {
692
+ archived: typeof shown === "boolean" ? shown : row.archived === true,
693
+ });
694
+ };
695
+
696
+ const renderRow = (row) => {
697
+ const view = rowOf(row);
698
+ const expanded = expandedId === row.id || editingId === row.id;
699
+ return h(RequirementRow, {
700
+ key: row.id,
701
+ row: view,
702
+ isCurrent: row.id === currentId,
703
+ expanded,
704
+ busy,
705
+ t,
706
+ onToggle: () => {
707
+ setEditingId(null);
708
+ setExpandedId(expandedId === row.id ? null : row.id);
709
+ },
710
+ onStart: () => act({ action: "start", id: row.id }),
711
+ onArchive: () => {
712
+ const next = !(typeof overrides[row.id] === "boolean" ? overrides[row.id] : row.archived === true);
713
+ setOverrides(Object.assign({}, overrides, { [row.id]: next }));
714
+ setNote(t("note.archiveDeferred"));
715
+ act({ action: next ? "archive" : "unarchive", id: row.id });
716
+ },
717
+ onEdit: () => setEditingId(row.id),
718
+ });
719
+ };
752
720
 
753
721
  return h(
754
722
  "div",
@@ -762,11 +730,9 @@ window.__ModuleLoader__.load({
762
730
  h("span", { className: "dsh-ttf-glyph", "aria-hidden": true }, listGlyph),
763
731
  h("span", { className: "dsh-ttf-label" + (held ? " dsh-ttf-labelHeld" : "") }, stateLabel),
764
732
  h("span", { className: "dsh-ttf-title", title: heading }, heading),
765
- h(
766
- "span",
767
- { className: "dsh-ttf-progress" },
768
- stats.done + "/" + stats.total + (current ? " · " + current : ""),
769
- ),
733
+ counts !== null && counts.total > 0
734
+ ? h("span", { className: "dsh-ttf-progress" }, counts.done + "/" + counts.total)
735
+ : null,
770
736
  h(
771
737
  "div",
772
738
  { className: "dsh-ttf-actions" },
@@ -776,14 +742,6 @@ window.__ModuleLoader__.load({
776
742
  disabled: busy,
777
743
  onClick: () => act({ action: held ? "resume" : "pause" }),
778
744
  }),
779
- h(IconButton, {
780
- iconBase: "IconTrashOutline",
781
- label: t("action.reset"),
782
- disabled: busy,
783
- onClick: () => {
784
- if (window.confirm(t("confirm.reset"))) act({ action: "reset" });
785
- },
786
- }),
787
745
  h(IconButton, {
788
746
  iconBase: open ? "IconChevronDownOutline" : "IconChevronUpOutline",
789
747
  label: open ? t("action.collapse") : t("action.expand"),
@@ -791,55 +749,199 @@ window.__ModuleLoader__.load({
791
749
  }),
792
750
  ),
793
751
  ),
794
- // 这次操作的结果与原因(已放行 / 已唤醒 / 为什么没动)。
795
752
  note ? h("div", { className: "dsh-ttf-note" }, note) : null,
796
753
  open
797
754
  ? h(
798
755
  "div",
799
756
  { className: "dsh-ttf-body" },
800
- data.checkpoint
757
+ held ? h("div", { className: "dsh-ttf-note" }, t("note.paused")) : null,
758
+
759
+ lists.active.length > 0
801
760
  ? h(
802
761
  "div",
803
- { className: "dsh-ttf-note" },
804
- t("state.checkpoint") +
805
- ":" +
806
- data.checkpoint.label +
807
- "「" +
808
- data.checkpoint.title +
809
- "」",
810
- h("div", null, t("checkpoint.note", { title: data.checkpoint.title })),
762
+ null,
763
+ h("div", { className: "dsh-ttf-section" }, t("label.requirements", { count: lists.active.length })),
764
+ lists.active.map((row) =>
765
+ editingId === row.id
766
+ ? h(EditForm, {
767
+ key: row.id,
768
+ row,
769
+ busy,
770
+ t,
771
+ onCancel: () => setEditingId(null),
772
+ onSubmit: async (patch) => {
773
+ await act(Object.assign({ action: "pending", id: row.id }, patch));
774
+ setEditingId(null);
775
+ },
776
+ })
777
+ : renderRow(row),
778
+ ),
811
779
  )
812
780
  : null,
813
- held ? h("div", { className: "dsh-ttf-note" }, t("note.paused")) : null,
814
- goals.map((each) =>
815
- h(NodeRow, {
816
- key: each.id,
817
- node: each,
818
- depth: 0,
819
- activeIds: currentIds(data),
820
- openIds: defaultOpenIds(data),
821
- checkpointId: data.checkpoint ? data.checkpoint.id : null,
822
- // 目标也能丢:丢弃会连同它下面的任务与子任务一起走。
823
- canDrop: true,
824
- sessionId,
825
- reload,
826
- t,
827
- }),
828
- ),
781
+
782
+ lists.drafts.length > 0
783
+ ? h(
784
+ "div",
785
+ null,
786
+ h("div", { className: "dsh-ttf-section" }, t("label.drafts") + " · " + lists.drafts.length),
787
+ lists.drafts.map((row) =>
788
+ h(
789
+ "div",
790
+ { className: "dsh-ttf-row", key: row.id },
791
+ h(
792
+ "div",
793
+ { className: "dsh-ttf-rowHead" },
794
+ h(StatusPill, { row, t }),
795
+ h("span", { className: "dsh-ttf-tag" }, row.id),
796
+ h("span", { className: "dsh-ttf-rowTitle" }, row.title || "(还没有标题)"),
797
+ ),
798
+ h("div", { className: "dsh-ttf-note" }, t("note.draft")),
799
+ ),
800
+ ),
801
+ )
802
+ : null,
803
+
804
+ lists.archived.length > 0
805
+ ? h(
806
+ "div",
807
+ null,
808
+ h("div", { className: "dsh-ttf-section" }, t("label.archived") + " · " + lists.archived.length),
809
+ lists.archived.map((row) => renderRow(row)),
810
+ )
811
+ : null,
812
+
813
+ drafting
814
+ ? h(DraftForm, {
815
+ busy,
816
+ t,
817
+ onCancel: () => setDrafting(false),
818
+ onSubmit: async (patch) => {
819
+ await act(Object.assign({ action: "draft" }, patch));
820
+ setDrafting(false);
821
+ },
822
+ })
823
+ : h(
824
+ "div",
825
+ { className: "dsh-ttf-formActions" },
826
+ h(
827
+ "button",
828
+ { type: "button", className: "dsh-ttf-btn", disabled: busy, onClick: () => setDrafting(true) },
829
+ t("action.newRequirement"),
830
+ ),
831
+ ),
829
832
  )
830
833
  : null,
831
834
  ),
832
835
  );
833
836
  }
834
837
 
838
+ /**
839
+ * 插件管理里本插件自己那一页(`plugins.bundle.config`)。
840
+ *
841
+ * 宿主给两类视图:`page` 是页面主体,`summary` 是这个 bundle 在列表里的
842
+ * 一句话。bundle 的配置宿主只渲染 `page`,`summary` 一律返回 null——标题与
843
+ * 描述由宿主从包的元数据里取,这里不重复一遍。
844
+ *
845
+ * 判断视图与用 hook 的两件事分在两个组件里:同一个组件里先按 props 早退、
846
+ * 再调 hook,视图一变 hook 数量就变,React 会直接抛错。
847
+ */
848
+ function TaskTreeConfigEntry(ownerProps) {
849
+ if (ownerProps && ownerProps.view === "summary") return null;
850
+ return h(TaskTreeSettings);
851
+ }
852
+
853
+ /** 配置页的正文:两个开关 + 读不到设置时的说明。 */
854
+ function TaskTreeSettings() {
855
+ const t = useT();
856
+ const [state, setState] = useState({ loading: true, data: null });
857
+ const [busy, setBusy] = useState(false);
858
+ const [note, setNote] = useState("");
859
+
860
+ useEffect(() => {
861
+ let alive = true;
862
+ (async () => {
863
+ try {
864
+ const data = await api("/dsh-tree-task-flow/settings");
865
+ if (alive) setState({ loading: false, data });
866
+ } catch {
867
+ if (alive) setState({ loading: false, data: null });
868
+ }
869
+ })();
870
+ return () => {
871
+ alive = false;
872
+ };
873
+ }, []);
874
+
875
+ if (state.loading) {
876
+ return h("div", { className: "dsh-ttf-settingsPage" }, h("div", { className: "dsh-ttf-settingNote" }, t("settings.loading")));
877
+ }
878
+ if (!state.data) {
879
+ return h("div", { className: "dsh-ttf-settingsPage" }, h("div", { className: "dsh-ttf-settingNote" }, t("settings.unavailable")));
880
+ }
881
+
882
+ const settings = state.data.settings ?? {};
883
+
884
+ async function toggle(key, value) {
885
+ setBusy(true);
886
+ setNote("");
887
+ try {
888
+ const result = await post({ action: "setting", key, value });
889
+ if (result && result.settings) {
890
+ setState({ loading: false, data: Object.assign({}, state.data, { settings: result.settings }) });
891
+ }
892
+ } catch (failure) {
893
+ setNote(String((failure && failure.message) || failure));
894
+ } finally {
895
+ setBusy(false);
896
+ }
897
+ }
898
+
899
+ return h(
900
+ "div",
901
+ { className: "dsh-ttf-settingsPage" },
902
+ h("div", { className: "dsh-ttf-settingNote" }, t("setting.note")),
903
+ state.data.enabled === false ? h("div", { className: "dsh-ttf-note" }, t("settings.off")) : null,
904
+ h(
905
+ "div",
906
+ { className: "dsh-ttf-settings" },
907
+ SETTING_ROWS.map((row) => {
908
+ const on = settings[row.key] === true;
909
+ return h(
910
+ "div",
911
+ { className: "dsh-ttf-setting", key: row.key },
912
+ h(
913
+ "button",
914
+ {
915
+ type: "button",
916
+ role: "switch",
917
+ "aria-checked": on,
918
+ "aria-label": t("setting." + row.key),
919
+ className: "dsh-ttf-switch" + (on ? " dsh-ttf-switchOn" : ""),
920
+ disabled: busy,
921
+ onClick: () => toggle(row.key, !on),
922
+ },
923
+ h("span", { className: "dsh-ttf-switchKnob" }),
924
+ ),
925
+ h(
926
+ "div",
927
+ { className: "dsh-ttf-settingText" },
928
+ h("span", { className: "dsh-ttf-settingName" }, t("setting." + row.key)),
929
+ h("span", { className: "dsh-ttf-settingNote" }, t(row.note)),
930
+ ),
931
+ );
932
+ }),
933
+ ),
934
+ note ? h("div", { className: "dsh-ttf-note" }, note) : null,
935
+ );
936
+ }
937
+
835
938
  // ---------------------------------------------------------------- 挂载
836
939
 
837
940
  function apply(ctx) {
838
941
  ctx.effect(() => ctx.locale.register(NS, { zh, en }), "dsh-tree-task-flow: dictionaries");
839
942
  translate = ctx.locale.bind(NS);
943
+ ensureStyles();
840
944
 
841
- // 输入框上方的条目。list 槽 + 自己的 id ⇒ 只会并列加在内置
842
- // 的 queue / todo / goal 旁边,不会替换任何一个。
843
945
  ctx.effect(
844
946
  () =>
845
947
  ctx.slots.inject("conversation.input.dock", () =>
@@ -855,6 +957,21 @@ window.__ModuleLoader__.load({
855
957
  ),
856
958
  "dsh-tree-task-flow: conversation dock",
857
959
  );
960
+
961
+ ctx.effect(
962
+ () =>
963
+ ctx.slots.inject("plugins.bundle.config", () =>
964
+ ctx.slots.register(
965
+ {
966
+ name: "plugins.bundle.config",
967
+ key: BUNDLE_PACKAGE,
968
+ locale: NS,
969
+ },
970
+ TaskTreeConfigEntry,
971
+ ),
972
+ ),
973
+ "dsh-tree-task-flow: 插件配置页",
974
+ );
858
975
  }
859
976
 
860
977
  exports.name = name;