dsh-tree-task-flow 0.3.14 → 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,28 +143,23 @@ 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
- * 三层节点总数与已完成数,给单行条上的进度用。
178
- *
179
- * **丢弃的节点两边都不算。** 丢弃是"这件事我不做了",它不该压着进度——
180
- * 早先 total 无条件 +1、done 只认 `done`,于是丢一个节点进度就永远差一格,
181
- * 满不了。丢弃的证据在树里(划掉那一行),不需要分数再替它记一笔。
182
- */
183
- function countNodes(goal) {
184
- if (goal.status === "dropped") return { total: 0, done: 0 };
185
- let total = 1;
186
- let done = goal.status === "done" ? 1 : 0;
187
- 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 || []) {
188
163
  if (task.status === "dropped") continue;
189
164
  total += 1;
190
165
  if (task.status === "done") done += 1;
@@ -197,60 +172,12 @@ window.__ModuleLoader__.load({
197
172
  return { total, done };
198
173
  }
199
174
 
200
- /** 计划里的全部目标。一个会话可以有多个目标,它们是最外层的兄弟。 */
201
- function goalsOf(data) {
202
- return data?.plan?.goals || [];
203
- }
204
-
205
- /** 当前该盯着哪个目标:活动路径上的那个;没有就取第一个还没结束的,再退到最后一个。 */
206
- function focusGoal(data) {
207
- const goals = goalsOf(data);
208
- if (goals.length === 0) return null;
209
- const here = goals.find((each) => each.id === data?.active?.goalId);
210
- if (here) return here;
211
- return (
212
- goals.find((each) => each.status !== "done" && each.status !== "dropped") ||
213
- goals[goals.length - 1]
214
- );
215
- }
216
-
217
- /** 当前活动节点的标题:子任务优先,其次任务,最后目标。 */
218
- function currentLabel(data) {
219
- const active = data.active;
220
- if (!active) return "";
221
- const goal = focusGoal(data);
222
- if (!goal) return "";
223
- const task = (goal.tasks || []).find((each) => each.id === active.taskId);
224
- if (!task) return goal.title;
225
- const step = (task.steps || []).find((each) => each.id === active.stepId);
226
- return step ? step.title : task.title;
227
- }
228
-
229
- /** 活动路径上三层各自的 id。节点 id 全局唯一,所以一个集合就够判"是不是当前节点"。 */
230
- function currentIds(data) {
231
- const active = data.active;
232
- return new Set(
233
- [active?.goalId, active?.taskId, active?.stepId].filter(
234
- (each) => typeof each === "string" && each !== "",
235
- ),
236
- );
237
- }
238
-
239
- /**
240
- * 面板一打开该摊开哪些节点:活动路径上的三层,加上检查点节点以及它的祖先。
241
- *
242
- * 其余节点一律收起——正在走的那条路径本来就窄,摊平整棵树只会让人翻半天。
243
- * 检查点落在某个任务上时,目标也得一起摊开,否则连那一行都看不见。
244
- */
245
- function defaultOpenIds(data) {
246
- const ids = currentIds(data);
247
- const checkpoint = data.checkpoint;
248
- if (checkpoint !== null && checkpoint !== undefined && typeof checkpoint.id === "string") {
249
- ids.add(checkpoint.id);
250
- const goalId = focusGoal(data)?.id;
251
- if (checkpoint.level !== "goal" && typeof goalId === "string") ids.add(goalId);
252
- }
253
- 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";
254
181
  }
255
182
 
256
183
  // ---------------------------------------------------------------- 文案
@@ -259,23 +186,42 @@ window.__ModuleLoader__.load({
259
186
  nav: "树形任务流",
260
187
  "state.running": "进行中",
261
188
  "state.paused": "已暂停",
262
- "state.done": "已全部完成",
263
- "state.checkpoint": "检查点",
264
- "checkpoint.note": "「{title}」的子节点已全部结束。要么继续给它拆子节点,要么提交结果并完成它。",
265
- "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": "总结",
266
203
  "action.pause": "暂停",
267
204
  "action.resume": "继续",
268
- "action.reset": "清空计划",
269
205
  "action.expand": "展开",
270
206
  "action.collapse": "收起",
271
- "action.done": "完成…",
272
- "action.drop": "丢弃",
273
- "action.delete": "删除",
274
- "action.submit": "提交结果",
275
- "action.expandResult": "展开看全文",
276
- "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": "记下来",
277
215
  "action.cancel": "取消",
278
- "settings.nav": "树形任务流",
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": "详情,可以很长",
279
225
  "settings.loading": "正在读设置…",
280
226
  "settings.unavailable": "读不到设置:宿主那一半没在跑,或者接口不通。",
281
227
  "settings.off": "插件在 profile 配置里被关掉了(enabled: false),这两个开关现在不起作用。",
@@ -283,45 +229,49 @@ window.__ModuleLoader__.load({
283
229
  "setting.autoContinue": "任务没做完、模型自己停下时,自动把它叫起来接着跑",
284
230
  "setting.autoContinueNote": "会往会话里推一条消息。默认关。",
285
231
  "setting.interruptRecovery": "工具出错或网络断了,自动重试把会话拉起来",
286
- "setting.interruptRecoveryNote":
287
- "按 1 / 5 / 10 / 30 分钟各拉一次,一直拉不起来就停手。你按过停止的绝不拉起。",
288
- "label.result": "结果",
289
- "label.noResult": "(还没提交结果)",
290
- "label.current": "当前",
291
- "label.checkpointHere": "检查点",
292
- "label.goals": "{count} 个目标",
293
- "placeholder.result":
294
- "写清这个节点产出了什么。它整段执行过程会被这条结果顶替,所以要能独立看懂。",
295
- "hint.noChildren": "(还没有子节点 → 让模型调用 tree_task_plan)",
296
- "confirm.reset": "确定要清空这个会话的计划树吗?",
297
- "confirm.drop": "确定要丢弃「{title}」吗?丢弃会把它标成「不做了」,节点留在树里可查。",
298
- "confirm.delete":
299
- "确定要删除「{title}」吗?它会连同下面的任务或子任务一起从树里消失,找不回来;已经提交的结果也会一起没掉。如果只是「这件事不做了」,请改用丢弃。",
232
+ "setting.interruptRecoveryNote": "按 1 / 5 / 10 / 30 分钟各拉一次,一直拉不起来就停手。你按过停止的绝不拉起。",
300
233
  };
301
234
 
302
235
  const en = {
303
- nav: "Tree Task Flow",
236
+ nav: "Task tree",
304
237
  "state.running": "In progress",
305
238
  "state.paused": "Paused",
306
- "state.done": "All done",
307
- "state.checkpoint": "Checkpoint",
308
- "checkpoint.note":
309
- "Every child of “{title}” has finished. Either break out more children, or submit its result and complete it.",
310
- "note.paused":
311
- "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",
312
253
  "action.pause": "Pause",
313
254
  "action.resume": "Resume",
314
- "action.reset": "Clear plan",
315
255
  "action.expand": "Expand",
316
256
  "action.collapse": "Collapse",
317
- "action.done": "Complete…",
318
- "action.drop": "Drop",
319
- "action.delete": "Delete",
320
- "action.submit": "Submit result",
321
- "action.expandResult": "Show the full result",
322
- "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",
323
265
  "action.cancel": "Cancel",
324
- "settings.nav": "Task tree",
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",
325
275
  "settings.loading": "Reading settings…",
326
276
  "settings.unavailable": "Settings are unreachable: the host half is not running, or the endpoint is blocked.",
327
277
  "settings.off": "The plugin is switched off in the profile config (enabled: false), so these switches do nothing right now.",
@@ -329,37 +279,21 @@ window.__ModuleLoader__.load({
329
279
  "setting.autoContinue": "When the plan is unfinished and the model stops, wake it up to carry on",
330
280
  "setting.autoContinueNote": "This pushes a message into the session. Off by default.",
331
281
  "setting.interruptRecovery": "After a tool error or a dropped connection, retry to get the session going",
332
- "setting.interruptRecoveryNote":
333
- "Retries at 1 / 5 / 10 / 30 minutes, then gives up. A session you stopped is never revived.",
334
- "label.result": "Result",
335
- "label.noResult": "(no result submitted yet)",
336
- "label.current": "current",
337
- "label.checkpointHere": "checkpoint",
338
- "label.goals": "{count} goals",
339
- "placeholder.result":
340
- "State what this node produced. Its whole execution range is replaced by this result, so it must stand on its own.",
341
- "hint.noChildren": "(no children yet → ask the model to call tree_task_plan)",
342
- "confirm.reset": "Clear this session's plan tree?",
343
- "confirm.drop": "Drop “{title}”? It is marked as not-to-do and stays in the tree.",
344
- "confirm.delete":
345
- "Delete “{title}”? It disappears from the tree together with its tasks or steps, and any submitted result goes with it. If you only mean “not doing this”, use Drop instead.",
282
+ "setting.interruptRecoveryNote": "Retries at 1 / 5 / 10 / 30 minutes, then gives up. A session you stopped is never revived.",
346
283
  };
347
284
 
348
- // ---------------------------------------------------------------- 样式
285
+ /** 插件配置页上的两个开关。 */
286
+ const SETTING_ROWS = [
287
+ { key: "autoContinue", note: "setting.autoContinueNote" },
288
+ { key: "interruptRecovery", note: "setting.interruptRecoveryNote" },
289
+ ];
349
290
 
350
- /**
351
- * 样式照内置的 dock 条目写:宽高、间距、圆角、颜色变量都取
352
- * `dsh-client-ui-goal` 的 GoalBar 与内置待办面板 TodoPanel 的同一套值。
353
- *
354
- * 注入方式也照它们:一个带 `data-plugin-css` 的 <style> 标签——
355
- * 客户端 HMR 就是靠这个属性在插件卸载时收走样式的。
356
- */
357
- const CSS_TAG = "dsh-tree-task-flow/Dock.module.css";
291
+ // ---------------------------------------------------------------- 样式
358
292
 
359
293
  const CSS = [
360
- // 条目外壳:宽度算法与内置 goal / todo 条目完全一致,否则会和消息正文对不齐。
294
+ // 外壳与宽度:**必须照抄内置条目的宽度公式**。漏掉它,这一条会按父容器的
295
+ // 宽度铺满整个输入区(实测就是这样占满全屏的)。
361
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)}",
362
- // 卡片:背景与圆角同内置条目,描边用 ::after 画(.5px 在缩放屏上才不糊)。
363
297
  ".dsh-ttf-panel{background:var(--dsw-specific-tip);border-radius:12px;position:relative;overflow:hidden}",
364
298
  '.dsh-ttf-panel:after{border:.5px solid var(--dsw-alias-border-l1);border-radius:inherit;content:"";pointer-events:none;position:absolute;inset:0}',
365
299
  ".dsh-ttf-bar{box-sizing:border-box;width:100%;height:36px;display:flex;align-items:center;gap:10px;padding:4px 5px 4px 12px}",
@@ -370,490 +304,308 @@ window.__ModuleLoader__.load({
370
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}",
371
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}",
372
306
  ".dsh-ttf-actions{flex:none;display:flex;align-items:center;gap:2px}",
373
- ".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}",
374
- ".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)}",
375
309
  ".dsh-ttf-iconBtn:disabled{opacity:.4;cursor:default}",
376
- ".dsh-ttf-iconBtn:disabled:hover{background:0 0;color:var(--dsw-alias-label-tertiary)}",
377
- // 取不到内置图标时退回文字:固定 28px 宽装不下几个字,文字会溢出到相邻
378
- // 按钮上,看着就是几个按钮叠在一起。这里按文字宽度撑开、留出边距,
379
- // 并给一个上限,长了用省略号收住——几个按钮始终各占各的位置。
380
310
  ".dsh-ttf-iconBtnText{width:auto;min-width:28px;max-width:112px;height:24px;padding:0 8px}",
381
311
  ".dsh-ttf-iconText{min-width:0;max-width:100%;overflow:hidden;text-overflow:ellipsis;font-size:12px;line-height:16px;white-space:nowrap}",
382
- // 展开体:限高滚动——这是输入框旁边的位置,不能让它把会话挤没了。
383
312
  ".dsh-ttf-body{max-height:264px;overflow-y:auto;padding:2px 8px 8px}",
384
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)}",
385
- // 节点行:行高与 hover 背景照内置待办面板。
386
- // 一个节点。行本身跟着标题长,标记与按钮对齐第一行。
387
- ".dsh-ttf-node{position:relative}",
388
- // 同一层的相邻节点之间画一条分界线——任务与任务的分界就靠它。
389
- ".dsh-ttf-node+.dsh-ttf-node{border-top:1px solid var(--dsw-alias-border-l1);margin-top:2px;padding-top:2px}",
390
- ".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}",
391
- ".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}",
392
316
  ".dsh-ttf-row:hover{background:var(--dsw-alias-interactive-bg-hover)}",
393
- // 标题不缩略:有多长写多长,多了换行。
394
- ".dsh-ttf-rowTitle{min-width:0;flex:1;white-space:pre-wrap;word-break:break-word}",
395
- ".dsh-ttf-rowActions{flex:none;display:flex;align-items:center;gap:2px;margin-top:-4px}",
396
- ".dsh-ttf-markCell{flex:none;display:inline-flex;align-items:center;margin-top:3px;color:var(--dsw-alias-label-tertiary)}",
397
- ".dsh-ttf-markDone{flex:none;display:inline-flex;align-items:center;margin-top:3px;color:var(--dsw-alias-state-success-primary,#1a7f37)}",
398
- ".dsh-ttf-markMuted{flex:none;display:inline-flex;align-items:center;margin-top:3px;color:var(--dsw-alias-label-caption,#00000066)}",
399
- // 正在运行的标记:就是原来那个虚线圆,让它匀速转起来。1s 一圈、线性、
400
- // 无限,与内置待办面板 in_progress 的节奏一致。
401
- "@keyframes dsh-ttf-spin{to{transform:rotate(360deg)}}",
402
- ".dsh-ttf-markSpin{animation:dsh-ttf-spin 1s linear infinite;transform-origin:50% 50%}",
403
- ".dsh-ttf-done{color:var(--dsw-alias-label-tertiary);text-decoration:line-through}",
404
- // 三层缩进。
405
- // 子节点:一条竖线 + 每个子节点一个拐角,把三层画成看得见的树。
406
- ".dsh-ttf-depth1,.dsh-ttf-depth2{margin-left:11px;padding-left:9px;border-left:1px solid var(--dsw-alias-border-l2)}",
407
- '.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)}',
408
- // 详情里的每一块(说明 / 结果 / 表单)自成一块,块间留白 + 描边,分得开。
409
- ".dsh-ttf-detail>*+*{border-top:.5px solid var(--dsw-alias-border-l1);padding-top:6px}",
410
- ".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)}",
411
325
  ".dsh-ttf-tagWarn{color:var(--dsw-alias-state-warning-primary,#a06800);background:var(--dsw-alias-state-warning-bg,#ffcc002e)}",
412
- // 展开详情:说明、结果、完成表单。
413
326
  ".dsh-ttf-detail{display:flex;flex-direction:column;gap:6px;padding:2px 4px 4px}",
414
- ".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}",
415
328
  ".dsh-ttf-detailText{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px;white-space:pre-wrap;word-break:break-word}",
416
- // 提交的结果:默认只占一行,点一下看全文。
417
- ".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}",
418
- ".dsh-ttf-resultClick{cursor:pointer}",
419
- ".dsh-ttf-resultOpen{display:block;max-height:220px;overflow-y:auto}",
420
- ".dsh-ttf-form{margin:6px 0 2px}",
421
- ".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}",
422
- ".dsh-ttf-input:focus{border-color:var(--dsw-alias-state-business-primary,#4d6bfe)}",
423
- ".dsh-ttf-input::placeholder{color:var(--dsw-alias-label-caption,#00000066)}",
424
- ".dsh-ttf-formBar{margin-top:6px;display:flex;align-items:center;gap:6px}",
425
- ".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}",
426
- ".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)}",
427
332
  ".dsh-ttf-btn:disabled{opacity:.4;cursor:default}",
428
- ".dsh-ttf-btn:disabled:hover{background:var(--dsw-alias-bg-base)}",
429
- ".dsh-ttf-error{color:var(--dsw-alias-state-error-primary,#c00);font-size:12px;line-height:18px;white-space:pre-wrap}",
430
- ".dsh-ttf-hint{padding:4px 4px 6px 24px;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}",
431
- // 设置:两行开关,跟树共用同一张卡片。
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}",
432
339
  ".dsh-ttf-settingsPage{display:flex;flex-direction:column;gap:10px;padding:4px 2px;max-width:640px}",
433
- ".dsh-ttf-settings{display:flex;flex-direction:column;gap:8px;padding:4px 4px 8px}",
434
- ".dsh-ttf-setting{display:flex;align-items:flex-start;gap:8px}",
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}",
435
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}",
436
- ".dsh-ttf-switch:disabled{opacity:.4;cursor:default}",
437
- ".dsh-ttf-switchOn{border-color:var(--dsw-alias-state-business-primary,#4d6bfe);background:var(--dsw-alias-state-business-primary,#4d6bfe)}",
346
+ ".dsh-ttf-switchOn{background:var(--dsw-alias-state-business-primary,#4d6bfe);border-color:transparent}",
438
347
  ".dsh-ttf-switchKnob{position:absolute;top:1px;left:1px;width:14px;height:14px;border-radius:999px;background:#fff;transition:left .12s}",
439
348
  ".dsh-ttf-switchOn .dsh-ttf-switchKnob{left:15px}",
440
- ".dsh-ttf-settingText{display:flex;flex-direction:column;gap:2px;min-width:0}",
441
- ".dsh-ttf-settingName{color:var(--dsw-alias-label-primary);font-size:12px;line-height:18px}",
442
- ".dsh-ttf-settingNote{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}",
443
- ].join("");
349
+ ].join("\n");
444
350
 
445
- if (
446
- typeof document !== "undefined" &&
447
- document.querySelector("style[data-plugin-css=" + JSON.stringify(CSS_TAG) + "]") === null
448
- ) {
449
- const tag = document.createElement("style");
450
- tag.dataset.plugin = "dsh-tree-task-flow";
451
- tag.dataset.pluginCss = CSS_TAG;
452
- tag.textContent = CSS;
453
- document.head.appendChild(tag);
454
- }
455
-
456
- // ---------------------------------------------------------------- 零件
457
-
458
- /**
459
- * 节点状态标记:完成打勾、丢弃划掉、未完成画圈(当前节点用虚线圈,和内置待办一致)。
460
- *
461
- * 正在运行的那个圈还会匀速转起来——虚线一转就能看出这条路径还在推进。
462
- */
463
- function StatusMark({ node, isCurrent }) {
464
- if (node.status === "done") {
465
- const check = icon("IconCheckOutline", 14);
466
- return h("span", { className: "dsh-ttf-markDone" }, check === null ? "✓" : check);
467
- }
468
- if (node.status === "dropped") {
469
- const close = icon("IconCloseOutline", 14);
470
- return h("span", { className: "dsh-ttf-markMuted" }, close === null ? "×" : close);
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
+ // 样式注入失败不该拖垮条目:没有样式也还能用。
471
366
  }
472
- const circle = { cx: 7, cy: 7, r: 5.2, stroke: "currentColor", strokeWidth: 1.2 };
473
- if (isCurrent) circle.strokeDasharray = "2.4 2.4";
474
- return h(
475
- "span",
476
- { className: "dsh-ttf-markCell" },
477
- h(
478
- "svg",
479
- {
480
- width: 14,
481
- height: 14,
482
- viewBox: "0 0 14 14",
483
- fill: "none",
484
- "aria-hidden": true,
485
- className: isCurrent ? "dsh-ttf-markSpin" : undefined,
486
- },
487
- h("circle", circle),
488
- ),
489
- );
490
367
  }
491
368
 
492
- /**
493
- * 提交的结果。默认只占一行,点一下摊开看全文。
494
- *
495
- * 短到一行放得下的结果不给展开——点了也没变化,只是徒增噪音。
496
- */
497
- function ResultText({ text, t }) {
498
- const [open, setOpen] = useState(false);
499
- const body = String(text ?? "");
500
- const expandable = body.includes("\n") || body.length > 60;
501
- return h(
502
- "div",
503
- {
504
- className:
505
- "dsh-ttf-result" +
506
- (expandable ? " dsh-ttf-resultClick" : "") +
507
- (open ? " dsh-ttf-resultOpen" : ""),
508
- role: expandable ? "button" : undefined,
509
- tabIndex: expandable ? 0 : undefined,
510
- title: expandable ? t(open ? "action.collapseResult" : "action.expandResult") : undefined,
511
- onClick: expandable
512
- ? () => setOpen(!open)
513
- : undefined,
514
- },
515
- body,
516
- );
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));
517
381
  }
518
382
 
519
- /** 提交结果的输入框。空文本提交不出去——host 也会拒绝,这里先挡住。 */
520
- function CompleteForm({ onSubmit, onCancel, busy, error, t }) {
521
- 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("");
522
389
  return h(
523
390
  "div",
524
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
+ }),
525
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", {
526
413
  className: "dsh-ttf-input",
527
- value: text,
528
- rows: 4,
529
- placeholder: t("placeholder.result"),
530
- onChange: (event) => setText(event.target.value),
414
+ value: change,
415
+ placeholder: "这次改了什么(会写进版本历史)",
416
+ onChange: (event) => setChange(event.target.value),
531
417
  }),
532
- error ? h("div", { className: "dsh-ttf-error" }, error) : null,
418
+ h("div", { className: "dsh-ttf-note" }, t("note.edit")),
533
419
  h(
534
420
  "div",
535
- { 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")),
536
423
  h(
537
424
  "button",
538
425
  {
539
426
  type: "button",
540
427
  className: "dsh-ttf-btn",
541
- disabled: busy || text.trim() === "",
542
- onClick: () => onSubmit(text),
428
+ disabled: busy,
429
+ onClick: () =>
430
+ onSubmit({ title, intro, detail, change }),
543
431
  },
544
- t("action.submit"),
432
+ t("action.submitChange"),
545
433
  ),
546
- h("button", { type: "button", className: "dsh-ttf-btn", disabled: busy, onClick: onCancel }, t("action.cancel")),
547
434
  ),
548
435
  );
549
436
  }
550
437
 
551
- /**
552
- * 一个节点:一行标题 + 可展开的详情。
553
- *
554
- * 三层共用同一个组件——它们的字段与动作本来就一样,差别只有缩进,
555
- * 以及"这一层要不要给丢弃与删除按钮"(目标、任务、子任务都能丢、都能删)。
556
- */
557
- function NodeRow({ node, depth, activeIds, openIds, checkpointId, canDrop, sessionId, reload, t }) {
558
- const children = node.tasks || node.steps || [];
559
- const canAct = node.status === "pending";
560
- const isCurrent = activeIds.has(node.id);
561
- const isCheckpoint = checkpointId === node.id;
562
- const shouldOpen = openIds.has(node.id);
563
- const expandable = children.length > 0 || node.detail !== undefined || node.result !== undefined;
564
-
565
- // 默认只摊开"正在走的那条路径"(见 defaultOpenIds):目标一路展开到当前子任务,
566
- // 其余节点收起。否则一打开面板就是一整棵摊平的树,想找正在跑的那个得翻半天。
567
- const [open, setOpen] = useState(shouldOpen);
568
- const [composing, setComposing] = useState(false);
569
- const [busy, setBusy] = useState(false);
570
- const [error, setError] = useState(null);
571
-
572
- // 活动节点在会话里往前走了,就把它摊开:当前任务不该埋在收起里。
573
- useEffect(() => {
574
- if (shouldOpen) setOpen(true);
575
- }, [shouldOpen]);
576
-
577
- const act = useCallback(
578
- async (body) => {
579
- setBusy(true);
580
- setError(null);
581
- try {
582
- await post(Object.assign({ sessionId }, body));
583
- setComposing(false);
584
- await reload();
585
- } catch (failure) {
586
- setError(String((failure && failure.message) || failure));
587
- } finally {
588
- setBusy(false);
589
- }
590
- },
591
- [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,
592
488
  );
489
+ }
593
490
 
491
+ /** 一条需求。 */
492
+ function RequirementRow({ row, isCurrent, expanded, busy, onToggle, onStart, onArchive, onEdit, t }) {
493
+ const counts = countOf(row);
594
494
  return h(
595
495
  "div",
596
- { className: "dsh-ttf-node dsh-ttf-depth" + depth },
496
+ { className: "dsh-ttf-row" },
597
497
  h(
598
498
  "div",
599
- { className: "dsh-ttf-row" },
600
- h(StatusMark, { node, isCurrent }),
601
- h(
602
- "span",
603
- { className: "dsh-ttf-rowTitle" + (node.status === "done" ? " dsh-ttf-done" : "") },
604
- node.title,
605
- ),
606
- isCurrent ? h("span", { className: "dsh-ttf-tag" }, t("label.current")) : null,
607
- 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),
608
502
  h(
609
503
  "span",
610
- { className: "dsh-ttf-rowActions" },
611
- canAct
612
- ? h(IconButton, {
613
- iconBase: "IconCheckOutline",
614
- label: t("action.done"),
615
- disabled: busy,
616
- onClick: () => {
617
- setComposing(true);
618
- setOpen(true);
619
- },
620
- })
621
- : null,
622
- canAct && canDrop
623
- ? h(IconButton, {
624
- // 划掉 = 留个记号说"这件事不做了",和树里已丢弃节点的 × 标记是同一个意思。
625
- // 垃圾桶留给真正的删除(见下一个按钮)。
626
- iconBase: "IconCloseOutline",
627
- label: t("action.drop"),
628
- disabled: busy,
629
- onClick: () => {
630
- if (window.confirm(t("confirm.drop", { title: node.title }))) {
631
- act({ action: "drop", id: node.id });
632
- }
633
- },
634
- })
635
- : null,
636
- // 删除对**任何状态**都开着:它就是用来清理写错/建重的内容的,
637
- // 已完成的节点恰恰是最需要清掉的那种(结果记错了)。
638
- canDrop
639
- ? h(IconButton, {
640
- iconBase: "IconTrashOutline",
641
- label: t("action.delete"),
642
- disabled: busy,
643
- onClick: () => {
644
- if (window.confirm(t("confirm.delete", { title: node.title }))) {
645
- act({ action: "delete", id: node.id });
646
- }
647
- },
648
- })
649
- : null,
650
- expandable
651
- ? h(IconButton, {
652
- iconBase: open ? "IconChevronDownOutline" : "IconChevronRightOutline",
653
- label: open ? t("action.collapse") : t("action.expand"),
654
- onClick: () => setOpen(!open),
655
- })
656
- : null,
504
+ { className: "dsh-ttf-rowTitle", onClick: onToggle, title: row.title },
505
+ row.title || "(还没有标题)",
657
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
+ }),
658
527
  ),
659
- open
528
+ row.intro ? h("div", { className: "dsh-ttf-rowIntro" }, row.intro) : null,
529
+ expanded
660
530
  ? h(
661
531
  "div",
662
- { className: "dsh-ttf-detail" },
663
- node.detail ? h("div", { className: "dsh-ttf-detailText" }, node.detail) : null,
664
- node.result
665
- ? h(
666
- "div",
667
- null,
668
- h(
669
- "div",
670
- { className: "dsh-ttf-detailLabel" },
671
- t("label.result") + "(" + formatTime(node.result.at) + ")",
672
- ),
673
- h(ResultText, { text: node.result.text, t }),
674
- )
532
+ null,
533
+ h(Detail, { row, t }),
534
+ row.pending
535
+ ? null
675
536
  : h(
676
537
  "div",
677
- { className: "dsh-ttf-detailLabel" },
678
- 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")),
679
540
  ),
680
- composing
681
- ? h(CompleteForm, {
682
- t,
683
- busy,
684
- error,
685
- onSubmit: (text) => act({ action: "done", id: node.id, result: text }),
686
- onCancel: () => {
687
- setComposing(false);
688
- setError(null);
689
- },
690
- })
691
- : null,
692
- children.map((child) =>
693
- h(NodeRow, {
694
- key: child.id,
695
- node: child,
696
- depth: depth + 1,
697
- activeIds,
698
- openIds,
699
- checkpointId,
700
- canDrop: true,
701
- sessionId,
702
- reload,
703
- t,
704
- }),
705
- ),
706
- children.length === 0 && depth < 2
707
- ? h("div", { className: "dsh-ttf-hint" }, t("hint.noChildren"))
708
- : null,
709
541
  )
710
542
  : null,
711
543
  );
712
544
  }
713
545
 
714
- // ---------------------------------------------------------------- 条目
715
-
716
- /**
717
- * 设置里能改的开关。
718
- *
719
- * 键必须与 `lib/settings.js` 的 `SETTING_KEYS` **一模一样**:对不上时接口会
720
- * 400 拒掉,而界面只会安静地什么都没发生,最难查。自检里有一条断言把两份
721
- * 名单对起来(`verify-settings.mjs`)。
722
- *
723
- * 文案名就按 `setting.<键>` 拼,另配一条 `setting.<键>Note` 讲清楚它到底会做什么。
724
- */
725
- const SETTING_ROWS = [
726
- { key: "autoContinue", note: "setting.autoContinueNote" },
727
- { key: "interruptRecovery", note: "setting.interruptRecoveryNote" },
728
- ];
729
-
730
- /**
731
- * 设置面板里的那一页:两个开关。
732
- *
733
- * ★ 这一页挂在 **DSH 自己的设置面板**里(`settings.section` 槽),不在插件的
734
- * 任务条上。任务条是"看进度、按暂停"的地方;偏好属于设置,就该待在设置里。
735
- *
736
- * 数据走宿主那两个**与会话无关**的接口:读 `/dsh-tree-task-flow/settings`,
737
- * 写 `action: "setting"`。跟任务条不同,这里没有要盯的东西,所以不轮询:
738
- * 挂载时读一次,改完拿返回值就地更新。
739
- */
740
- function TaskTreeSettings() {
741
- const t = useT();
742
- const [state, setState] = useState({ loading: true, data: null });
743
- const [busy, setBusy] = useState(false);
744
- const [note, setNote] = useState("");
745
-
746
- useEffect(() => {
747
- let alive = true;
748
- (async () => {
749
- try {
750
- const data = await api("/dsh-tree-task-flow/settings");
751
- if (alive) setState({ loading: false, data });
752
- } catch {
753
- // 读不到就说读不到,别画一排骗人的开关。
754
- if (alive) setState({ loading: false, data: null });
755
- }
756
- })();
757
- return () => {
758
- alive = false;
759
- };
760
- }, []);
761
-
762
- if (state.loading) {
763
- return h("div", { className: "dsh-ttf-settingsPage" }, h("div", { className: "dsh-ttf-settingNote" }, t("settings.loading")));
764
- }
765
- if (!state.data) {
766
- return h(
767
- "div",
768
- { className: "dsh-ttf-settingsPage" },
769
- h("div", { className: "dsh-ttf-settingNote" }, t("settings.unavailable")),
770
- );
771
- }
772
-
773
- const settings = state.data.settings ?? {};
774
-
775
- async function toggle(key, value) {
776
- setBusy(true);
777
- setNote("");
778
- try {
779
- const result = await post({ action: "setting", key, value });
780
- if (result && result.settings) {
781
- setState({ loading: false, data: Object.assign({}, state.data, { settings: result.settings }) });
782
- }
783
- } catch (failure) {
784
- setNote(String((failure && failure.message) || failure));
785
- } finally {
786
- setBusy(false);
787
- }
788
- }
789
-
546
+ /** 新建需求:只产出草稿。 */
547
+ function DraftForm({ onSubmit, onCancel, busy, t }) {
548
+ const [title, setTitle] = useState("");
549
+ const [intro, setIntro] = useState("");
790
550
  return h(
791
551
  "div",
792
- { className: "dsh-ttf-settingsPage" },
793
- h("div", { className: "dsh-ttf-settingNote" }, t("setting.note")),
794
- state.data.enabled === false
795
- ? h("div", { className: "dsh-ttf-note" }, t("settings.off"))
796
- : null,
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")),
797
566
  h(
798
567
  "div",
799
- { className: "dsh-ttf-settings" },
800
- SETTING_ROWS.map((row) => {
801
- const on = settings[row.key] === true;
802
- return h(
803
- "div",
804
- { className: "dsh-ttf-setting", key: row.key },
805
- h(
806
- "button",
807
- {
808
- type: "button",
809
- role: "switch",
810
- "aria-checked": on,
811
- "aria-label": t("setting." + row.key),
812
- className: "dsh-ttf-switch" + (on ? " dsh-ttf-switchOn" : ""),
813
- disabled: busy,
814
- onClick: () => toggle(row.key, !on),
815
- },
816
- h("span", { className: "dsh-ttf-switchKnob" }),
817
- ),
818
- h(
819
- "div",
820
- { className: "dsh-ttf-settingText" },
821
- h("span", { className: "dsh-ttf-settingName" }, t("setting." + row.key)),
822
- h("span", { className: "dsh-ttf-settingNote" }, t(row.note)),
823
- ),
824
- );
825
- }),
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
+ ),
826
580
  ),
827
- note ? h("div", { className: "dsh-ttf-note" }, note) : null,
828
581
  );
829
582
  }
830
583
 
831
- /**
832
- * 输入框上方的全宽条目。
833
- *
834
- * 收起时只有一行:图标 + 阶段标签 + 目标标题 + 进度,动作全在右侧的图标
835
- * 按钮里(暂停 / 继续、清空、展开)。展开才画整棵树,并限高滚动。
836
- *
837
- * 会话没有计划时返回 null:内置的 todo / goal 条目不该因为我而挪位。
838
- */
584
+ /** 输入框上方的全宽条目。 */
839
585
  function TaskTreeDock(props) {
840
586
  const t = useT();
841
587
  const sessionId = props.sessionId;
842
- const [state, setState] = useState({ loading: true });
588
+ const [state, setState] = useState({ loading: true, data: null });
843
589
  const [open, setOpen] = useState(false);
844
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);
845
598
 
846
599
  const reload = useCallback(async () => {
847
600
  if (!sessionId) {
848
- setState({ loading: false, empty: true });
601
+ setState({ loading: false, data: null });
849
602
  return;
850
603
  }
851
604
  try {
852
605
  const data = await api("/dsh-tree-task-flow/tree?sessionId=" + encodeURIComponent(sessionId));
853
606
  setState({ loading: false, data });
854
607
  } catch {
855
- // 没有计划(404)是正常状态;真出错也不该占位。两种都当作"没有"。
856
- setState({ loading: false, empty: true });
608
+ setState({ loading: false, data: null });
857
609
  }
858
610
  }, [sessionId]);
859
611
 
@@ -861,25 +613,25 @@ window.__ModuleLoader__.load({
861
613
  reload();
862
614
  }, [reload]);
863
615
 
864
- // 模型随时会改计划,而条目没有推送通道,所以自己轮询。
865
- // 4 秒一次,读的是插件自己的一个小 JSON。
866
616
  useEffect(() => {
867
617
  if (!sessionId) return undefined;
868
- const timer = setInterval(() => {
869
- reload();
870
- }, 4000);
618
+ const timer = setInterval(() => reload(), 4000);
871
619
  return () => clearInterval(timer);
872
620
  }, [sessionId, reload]);
873
621
 
874
- // 这次操作的结果说明(已放行 / 已唤醒 / 为什么没动):恢复失败时用户最需要
875
- // 的就是这句话,不能只留在接口的返回里。
876
- const [note, setNote] = useState(null);
877
622
  useEffect(() => {
878
623
  if (!note) return undefined;
879
624
  const timer = setTimeout(() => setNote(null), 8000);
880
625
  return () => clearTimeout(timer);
881
626
  }, [note]);
882
627
 
628
+ // 打开面板 = 重新读一次,并把「延迟的归档」结算掉。
629
+ useEffect(() => {
630
+ if (!open) return;
631
+ setOverrides({});
632
+ reload();
633
+ }, [open, reload]);
634
+
883
635
  const act = useCallback(
884
636
  async (body) => {
885
637
  setBusy(true);
@@ -890,6 +642,8 @@ window.__ModuleLoader__.load({
890
642
  setNote(result.message);
891
643
  }
892
644
  await reload();
645
+ } catch (failure) {
646
+ setNote(String((failure && failure.message) || failure));
893
647
  } finally {
894
648
  setBusy(false);
895
649
  }
@@ -897,36 +651,72 @@ window.__ModuleLoader__.load({
897
651
  [sessionId, reload],
898
652
  );
899
653
 
900
- if (state.loading || state.empty || !state.data) return null;
654
+ if (state.loading || !state.data) return null;
901
655
 
902
656
  const data = state.data;
903
- const goals = goalsOf(data);
904
- if (goals.length === 0) return null;
905
- // 一个会话可能有多个目标:条上给总数与合计进度,正文里把它们都列出来,
906
- // 展开状态仍旧只摊开正在走的那条路径。
907
- const stats = goals.reduce(
908
- (acc, each) => {
909
- const one = countNodes(each);
910
- return { total: acc.total + one.total, done: acc.done + one.done };
911
- },
912
- { total: 0, done: 0 },
913
- );
914
- const heading = goals.length === 1 ? goals[0].title : t("label.goals", { count: goals.length });
915
- const current = currentLabel(data);
916
- // 「被按住」有两条来源:插件闸门(paused)与人按过界面停止(stopped)。
917
- // 对用户来说都是"停着呢,点继续就能接着跑",所以合成一个状态展示。
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
+
918
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");
919
676
  const stateLabel = held
920
677
  ? t("state.paused")
921
- : data.checkpoint
922
- ? t("state.checkpoint")
923
- : data.active === null
924
- ? t("state.done")
925
- : t("state.running");
926
- // 条首的清单图标;外壳连这个图标都没有时退回一个同类字符,别留空白。
927
- 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);
928
684
  const listGlyph =
929
- 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
+ };
930
720
 
931
721
  return h(
932
722
  "div",
@@ -940,14 +730,8 @@ window.__ModuleLoader__.load({
940
730
  h("span", { className: "dsh-ttf-glyph", "aria-hidden": true }, listGlyph),
941
731
  h("span", { className: "dsh-ttf-label" + (held ? " dsh-ttf-labelHeld" : "") }, stateLabel),
942
732
  h("span", { className: "dsh-ttf-title", title: heading }, heading),
943
- // 一个都算不上(整棵树都被丢弃了)就不报分数:0/0 比不显示更让人费解。
944
- // 有 open 节点时 total 一定 ≥ 1,所以这里不会把"当前节点"那截弄丢。
945
- stats.total > 0
946
- ? h(
947
- "span",
948
- { className: "dsh-ttf-progress" },
949
- stats.done + "/" + stats.total + (current ? " · " + current : ""),
950
- )
733
+ counts !== null && counts.total > 0
734
+ ? h("span", { className: "dsh-ttf-progress" }, counts.done + "/" + counts.total)
951
735
  : null,
952
736
  h(
953
737
  "div",
@@ -958,14 +742,6 @@ window.__ModuleLoader__.load({
958
742
  disabled: busy,
959
743
  onClick: () => act({ action: held ? "resume" : "pause" }),
960
744
  }),
961
- h(IconButton, {
962
- iconBase: "IconTrashOutline",
963
- label: t("action.reset"),
964
- disabled: busy,
965
- onClick: () => {
966
- if (window.confirm(t("confirm.reset"))) act({ action: "reset" });
967
- },
968
- }),
969
745
  h(IconButton, {
970
746
  iconBase: open ? "IconChevronDownOutline" : "IconChevronUpOutline",
971
747
  label: open ? t("action.collapse") : t("action.expand"),
@@ -973,55 +749,199 @@ window.__ModuleLoader__.load({
973
749
  }),
974
750
  ),
975
751
  ),
976
- // 这次操作的结果与原因(已放行 / 已唤醒 / 为什么没动)。
977
752
  note ? h("div", { className: "dsh-ttf-note" }, note) : null,
978
753
  open
979
754
  ? h(
980
755
  "div",
981
756
  { className: "dsh-ttf-body" },
982
- data.checkpoint
757
+ held ? h("div", { className: "dsh-ttf-note" }, t("note.paused")) : null,
758
+
759
+ lists.active.length > 0
983
760
  ? h(
984
761
  "div",
985
- { className: "dsh-ttf-note" },
986
- t("state.checkpoint") +
987
- ":" +
988
- data.checkpoint.label +
989
- "「" +
990
- data.checkpoint.title +
991
- "」",
992
- 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
+ ),
993
779
  )
994
780
  : null,
995
- held ? h("div", { className: "dsh-ttf-note" }, t("note.paused")) : null,
996
- goals.map((each) =>
997
- h(NodeRow, {
998
- key: each.id,
999
- node: each,
1000
- depth: 0,
1001
- activeIds: currentIds(data),
1002
- openIds: defaultOpenIds(data),
1003
- checkpointId: data.checkpoint ? data.checkpoint.id : null,
1004
- // 目标也能丢:丢弃会连同它下面的任务与子任务一起走。
1005
- canDrop: true,
1006
- sessionId,
1007
- reload,
1008
- t,
1009
- }),
1010
- ),
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
+ ),
1011
832
  )
1012
833
  : null,
1013
834
  ),
1014
835
  );
1015
836
  }
1016
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
+
1017
938
  // ---------------------------------------------------------------- 挂载
1018
939
 
1019
940
  function apply(ctx) {
1020
941
  ctx.effect(() => ctx.locale.register(NS, { zh, en }), "dsh-tree-task-flow: dictionaries");
1021
942
  translate = ctx.locale.bind(NS);
943
+ ensureStyles();
1022
944
 
1023
- // 输入框上方的条目。list 槽 + 自己的 id ⇒ 只会并列加在内置
1024
- // 的 queue / todo / goal 旁边,不会替换任何一个。
1025
945
  ctx.effect(
1026
946
  () =>
1027
947
  ctx.slots.inject("conversation.input.dock", () =>
@@ -1038,24 +958,19 @@ window.__ModuleLoader__.load({
1038
958
  "dsh-tree-task-flow: conversation dock",
1039
959
  );
1040
960
 
1041
- // 设置面板里的那一页。用 `settings.section` 这个**列表槽**,所以它会作为
1042
- // 一行出现在 DSH 设置面板的导航里(跟「通用」「市场」并排),点开就是
1043
- // 那两个开关。槽没被组合出来时(比如没有设置面板的宿主)这里什么都不发生。
1044
961
  ctx.effect(
1045
962
  () =>
1046
- ctx.slots.inject("settings.section", () =>
963
+ ctx.slots.inject("plugins.bundle.config", () =>
1047
964
  ctx.slots.register(
1048
965
  {
1049
- name: "settings.section",
1050
- id: ENTRY_ID,
1051
- order: 60,
1052
- label: () => translate("settings.nav"),
966
+ name: "plugins.bundle.config",
967
+ key: BUNDLE_PACKAGE,
1053
968
  locale: NS,
1054
969
  },
1055
- TaskTreeSettings,
970
+ TaskTreeConfigEntry,
1056
971
  ),
1057
972
  ),
1058
- "dsh-tree-task-flow: settings section",
973
+ "dsh-tree-task-flow: 插件配置页",
1059
974
  );
1060
975
  }
1061
976