dsh-worktodo 0.1.0 → 0.1.3

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/CHANGELOG.md CHANGED
@@ -1,5 +1,70 @@
1
1
  # 更新日志
2
2
 
3
+ > 本文提到的 `tools/`、`docs/` 等路径都在**仓库**里,不在 npm 发布包内:<https://github.com/olimc2016/dsh-worktodo>
4
+
5
+ ## 0.1.3
6
+
7
+ ### 新增
8
+
9
+ - **排序模式**:面板顶部新增「手动 / 优先级 / 创建时间」三档排序(`worktodo_list` 也开放了同样的 `sort` 参数)。
10
+ - `manual`(默认):尊重你拖动过的顺序;**没拖过的条目自动退回**优先级 → 截止日期 → 创建时间,所以一上手看到的顺序依然合理;
11
+ - `priority`:高优先级在前,同优先级按截止日期,无日期垫底;
12
+ - `created`:最近添加的在前。
13
+ 三种模式都保持「已完成永远沉底」。
14
+ - **拖动排序**:按住任意一行拖到目标位置即可调整顺序。拖动时会**自动切到手动模式**(否则松手后看起来"没生效");
15
+ 落序由宿主在校验后写盘(`POST /worktodo/reorder`,收到完整 id 顺序,只写真正变了的记录);
16
+ 传了不存在的 id 会**显式失败**(`not-found`),不会静默改一半。
17
+
18
+ ### 测试
19
+
20
+ - `verify-records` 新增 2 例:三种排序模式的顺序、`applyManualOrder` 的纯函数语义(含"没拖过的垫后");
21
+ - `smoke-host` 新增 2 例:`/list?sort=created` 的顺序、`POST /reorder` 落盘并在 manual 模式生效(含坏 id 的显式失败)。
22
+
23
+ ## 0.1.2
24
+
25
+ ### 新增
26
+
27
+ - **按项目管理待办**:面板顶部新增「**按截止 / 按项目**」视图切换。
28
+ 「按项目」视图里每个项目是一个可折叠分组,标题显示「未完成/总数 · 逾期 N」;
29
+ 没写项目的记录归入「未归类」并**永远排在最后**。
30
+ 排序规则:未完成多的项目在前 → 未完成里最早截止日期更早的在前 → 名字字典序。
31
+ 宿主侧 `/worktodo/list` 新增 `projectGroups` 字段(`groupByProject`),且**不受 `project` 参数收窄**——
32
+ 这样面板一次就能拿到全部项目用于切换。
33
+
34
+ ### AI 友好
35
+
36
+ - **给模型的用法指引**:通过官方 `ctx.systemPrompt.section()` 贡献一段提示(段名 `worktodo:guidance`,
37
+ `order` 取 3050——官方内置工具段是 1000–3000、`MCP_SERVERS` 是 3100,插在两者之间)。
38
+ 内容讲清三件事:什么时候该用(用户说出承诺/截止日期,或发现超出本会话、仍需人跟进的待办)、
39
+ 什么时候**不该**用(当前任务的步骤拆解属于会话内的 `todo_write`,两者互补)、
40
+ 以及「加之前先查重」「做完用 `worktodo_done`」「标题写成祈使句」等约定。
41
+ - **批量登记**:新增 `worktodo_add_many`,一次调用登记多条;某条不合法只跳过那一条并把原因回传,
42
+ 不影响其余条目(用户一口气说好几件事时省调用)。
43
+
44
+ ### 测试
45
+
46
+ - `verify-records` 新增 4 例覆盖 `groupByProject`(未归类最后、未完成多者在前、桶内 total/open/overdue、空输入);
47
+ - `smoke-host` 新增 1 例断言 `/list` 载荷确实带 `projectGroups` 且形状正确。
48
+
49
+ ## 0.1.1
50
+
51
+ ### 修复(重要)
52
+
53
+ - **装出来的插件根本无法启动**:0.1.0 把运行期真正 import 的三个官方平台包(`@deepseek-ai/dsh-tools`、
54
+ `@deepseek-ai/dsh-storage-domain`、`@deepseek-ai/dsh-home-paths`)只声明为 `peerDependencies`,
55
+ 于是从 npm / GitHub 装出来的包**没有任何可解析的平台包**,DSH 启动时该插件直接加载失败
56
+ (实测:工具全部消失,`worktodo_list` 报 `unknown tool`)。现在改为 `dependencies`——
57
+ 实测从 GitHub 安装后,从插件自身位置三个包都能解析。
58
+ - **npm 包页面没有 README**:0.1.0 是经「暂存发布」(staged publishing)上线的,而实测那条路径**不会写入 registry 的 `readme` 字段**
59
+ (同机对照:普通 `publish` 的包 `readme` 有 8193 字符,本包 0 字符),于是包页面没有 README。
60
+ 本版改为**直接发布**——OIDC 下同样不需要人类 2FA,所以并没有牺牲自动化。
61
+
62
+ ### 文档
63
+
64
+ - 三份文档(README 中英、CHANGELOG)里补了**绝对仓库地址**的说明:提到 `docs/`、`tools/` 等目录时指向仓库
65
+ (这些文件不在 npm 包内)。npm 页面上的读者因此能找到出处,而不会撞上够不着的路径。
66
+ - `publish.yml` 的默认发布方式改为**直接发布**,同时保留手动「暂存 + 网页批准」入口。
67
+
3
68
  ## 0.1.0
4
69
 
5
70
  首个可用版本。
@@ -43,7 +108,7 @@
43
108
  - 新增可观测性:`/worktodo/health` 现在报告运行中的实际 `version` 与
44
109
  `tools: { status, registered, names, error }`——工具静默没注册这类问题不用再翻日志猜。
45
110
 
46
- ### 测试(116 例,均不需要启动 DSH)
111
+ ### 测试(172 例,均不需要启动 DSH)
47
112
 
48
113
  - `tools/verify-records.mjs`(22):日期归一化与相对日期、状态/优先级同义词、完成时间维护、
49
114
  引用解析(id / 标题 / 前缀 / 歧义)、分组边界(本周日 vs 下周一)、统计口径、筛选、坏记录修复。
@@ -54,7 +119,7 @@
54
119
  统计不随筛选变化、提升去重、变更通知。
55
120
  - `tools/smoke-host.mjs`(26):假 ctx 里跑通 `apply` → 7 条路由 → 服务 → 存储的完整增删改查,
56
121
  方法围栏与 `connection` 准入,失败路径(空标题、坏 JSON、未知 filter),health 自检字段,
57
- 6 个工具经**官方 `defineTool`** 校验注册并真实执行,以及 5 条「工具返回值必须无损」的断言——
122
+ 7 个工具经**官方 `defineTool`** 校验注册并真实执行,以及 5 条「工具返回值必须无损」的断言——
58
123
  判定用的是 **DSH 自己的校验器** `snapshotJsonValue`(来自 `@deepseek-ai/dsh-util-values`,
59
124
  就是 `@deepseek-ai/dsh-tools` 报 "value is not lossless JSON" 时用的那一个函数),
60
125
  并有一条专门断言「旧写法 `due: undefined` 必须被它拒绝」,用来证明这条守卫确实有牙齿。
package/README.en.md CHANGED
@@ -1,8 +1,12 @@
1
1
  # Work Todo · dsh-worktodo
2
2
 
3
+
3
4
  **A work todo list that belongs to you, not to a session.** A dedicated DSH sidebar panel, plus `worktodo_*` tools so the agent reads and writes **the same** list you do.
4
5
 
5
- > Works with **DSH 0.2.x** (tested on 0.2.0-rc.2) · **zero npm runtime dependencies** (`dependencies: {}`) · community plugin, unofficial
6
+ > Works with **DSH 0.2.x** (tested on 0.2.0-rc.2) · runtime deps are **3 official platform packages only** (no third-party deps) · community plugin, unofficial
7
+
8
+ > **About the paths in this file**: references to `docs/`, `tools/` and similar point into the **repository** —
9
+ > those files are not part of the npm package. Repository: <https://github.com/olimc2016/dsh-worktodo>
6
10
 
7
11
  ## The gap it fills
8
12
 
@@ -33,7 +37,8 @@ dsh plugin --profile desktop add github:olimc2016/dsh-worktodo
33
37
 
34
38
  This path is verified (see `docs/verification.md`): the browser half's build output `lib/client.js` is committed,
35
39
  and the package declares no `prepare`/`install` build script, so a git install needs **no build authorization**
36
- and pulls none of the development dependencies — measured: **1 package** resolved, **316 KB** installed,
40
+ measurement (install from GitHub): **1 top-level package** plus the 3 official platform packages, about **12 MB** installed
41
+ (~8 MB of that is zod, pulled in by the official `@deepseek-ai/dsh-tools`), and no development dependencies.
37
42
  no nested `node_modules`, and the installed tree matches the npm tarball file for file.
38
43
 
39
44
  **Option 2: from npm** (once the package is published)
@@ -90,10 +95,15 @@ Pin your own path in `cordis.patch.yml`:
90
95
  ```bash
91
96
  pnpm install # esbuild ships a prebuilt binary; no approve-builds needed
92
97
  pnpm run build # build lib/client.js (browser half)
93
- pnpm test # 116 cases: data model / storage / service / host wiring and tools / client contract / client bundle / catalog gate
98
+ pnpm test # 172 cases: data model / storage / service / host wiring and tools / client contract / client bundle / CI contract / privacy gate / catalog gate
94
99
  pnpm run watch # incremental client build
95
100
  ```
96
101
 
102
+ Release flow (trusted publishing + staged approval) is documented in `docs/releasing.md`:
103
+
104
+ ```bash
105
+ ```
106
+
97
107
  No DSH instance is needed to run the suite: `verify-records` (pure data), `verify-store` (real file IO plus a genuine **official `defineDomain` contract check**), `verify-service` (explicit statuses and boundaries), `smoke-host` (apply → routes → service → storage in a fake context, plus tool registration and execution with schemas validated by the **official `defineTool`**), `smoke-contract` (extracts the paths and field names the client actually sends and checks them against the host's real routes — catching silent failures where a mistyped field means, for instance, that due dates never save), `smoke-client` (bundle format, slot registration, and a first render that must not throw), and `verify-submission` (a local gate for the catalog PR: fields, filename, category and date, plus checks that only matter on the GitHub distribution path, such as whether every file a git install needs is actually tracked).
98
108
 
99
109
  ## License
package/README.md CHANGED
@@ -2,7 +2,10 @@
2
2
 
3
3
  **归你所有、跨会话持久的工作待办。** DSH 侧栏一个独立面板,agent 通过 `worktodo_*` 工具读写**同一份**列表。
4
4
 
5
- > 兼容 **DSH 0.2.x**(0.2.0-rc.2 实测)· **零 npm 依赖**(`dependencies: {}`)· 社区插件,非官方
5
+ > 兼容 **DSH 0.2.x**(0.2.0-rc.2 实测)· 运行期只依赖 **3 个官方平台包**(无任何第三方依赖)· 社区插件,非官方
6
+
7
+ > **关于本文里的路径**:提到 `docs/`、`tools/` 等目录时,指的是**仓库**里的文件——它们不在 npm 发布包内。
8
+ > 仓库地址:<https://github.com/olimc2016/dsh-worktodo>
6
9
 
7
10
  ---
8
11
 
@@ -35,7 +38,14 @@ dsh plugin --profile desktop add github:olimc2016/dsh-worktodo
35
38
 
36
39
  这条路是实测过的(见 `docs/verification.md`):仓库里提交了浏览器半侧的构建产物 `lib/client.js`,
37
40
  包内没有 `prepare`/`install` 这类构建脚本,所以 **git 安装不需要任何构建授权**,也不会把开发依赖装进来
38
- ——实测只解析 **1 个包**、整个安装 **316 KB**、包内无嵌套 `node_modules`,且安装结果与 npm 发布包逐一相同。
41
+ ——实测(从 GitHub 安装):顶层解析 **1 个包** + 3 个官方平台包,安装约 **12 MB**(其中约 8 MB 是官方 `@deepseek-ai/dsh-tools` 依赖的 zod),
42
+ 包内含已构建的 `lib/client.js`,**无需构建授权**。
43
+
44
+ > **为什么平台包必须是 `dependencies` 而不是 peer**:0.1.0 曾只声明 peer,结果从 npm/GitHub 装出来的包里
45
+ > **没有任何可解析的平台包**,DSH 启动时该插件直接加载失败(工具全部消失)。0.1.1 起改为 `dependencies`——
46
+ > 实测安装后从插件自身位置三个包都能解析。
47
+ (注意:DSH 的插件安装器会额外在包目录里装一次开发依赖,本机观察到 22 MB;那是安装器行为、与包本身无关,
48
+ 删掉不影响运行——见 `docs/verification.md`。)
39
49
 
40
50
  **方式二:从 npm 装**(包发布后可用)
41
51
 
@@ -97,10 +107,15 @@ dsh plugin --profile desktop add dsh-worktodo
97
107
  ```bash
98
108
  pnpm install # esbuild 用预编译二进制,无需 approve-builds
99
109
  pnpm run build # 打 lib/client.js(浏览器半侧)
100
- pnpm test # 116 个用例:数据模型 / 存储 / 服务 / 宿主接线与工具 / 客户端↔宿主契约 / 客户端 bundle / 收录门禁
110
+ pnpm test # 155 个用例:数据模型 / 存储 / 服务 / 宿主接线与工具 / 客户端↔宿主契约 / 客户端 bundle / CI 契约 / 隐私门禁 / 收录门禁
101
111
  pnpm run watch # 改客户端时增量构建
102
112
  ```
103
113
 
114
+ 发版流程(Trusted Publishing + 暂存批准)见仓库里的 `docs/releasing.md`:
115
+
116
+ ```bash
117
+ ```
118
+
104
119
  测试策略:不用启动 DSH 就能跑完——
105
120
  `verify-records`(纯数据)、`verify-store`(真实文件 IO + **官方 `defineDomain` 契约校验**)、
106
121
  `verify-service`(显式状态与边界)、`smoke-host`(假 ctx 里跑通 apply → 路由 → 服务 → 存储,
package/lib/client.js CHANGED
@@ -71,6 +71,14 @@ var zh = {
71
71
  "groupWeek": "\u672C\u5468",
72
72
  "groupLater": "\u4EE5\u540E",
73
73
  "groupCompleted": "\u5DF2\u5B8C\u6210",
74
+ "viewDue": "\u6309\u622A\u6B62",
75
+ "viewProject": "\u6309\u9879\u76EE",
76
+ "noProject": "\u672A\u5F52\u7C7B",
77
+ "sortLabel": "\u6392\u5E8F",
78
+ "sortManual": "\u624B\u52A8",
79
+ "sortPriority": "\u4F18\u5148\u7EA7",
80
+ "sortCreated": "\u521B\u5EFA\u65F6\u95F4",
81
+ "dragHint": "\u62D6\u52A8\u8C03\u6574\u987A\u5E8F\uFF08\u4F1A\u81EA\u52A8\u5207\u5230\u624B\u52A8\u6392\u5E8F\uFF09",
74
82
  "expand": "\u5C55\u5F00",
75
83
  "collapse": "\u6536\u8D77",
76
84
  "empty": "\u8FD8\u6CA1\u6709\u5F85\u529E",
@@ -129,6 +137,14 @@ var en = {
129
137
  "groupWeek": "This week",
130
138
  "groupLater": "Later",
131
139
  "groupCompleted": "Completed",
140
+ "viewDue": "By due",
141
+ "viewProject": "By project",
142
+ "noProject": "Uncategorized",
143
+ "sortLabel": "Sort",
144
+ "sortManual": "Manual",
145
+ "sortPriority": "Priority",
146
+ "sortCreated": "Created",
147
+ "dragHint": "Drag to reorder (switches to manual sort)",
132
148
  "expand": "Expand",
133
149
  "collapse": "Collapse",
134
150
  "empty": "No to-dos yet",
@@ -226,6 +242,18 @@ var STYLE_TEXT = `
226
242
  flex-direction: column; align-items: stretch; gap: 6px;
227
243
  border: 1px solid ${C.border1}; background: ${C.layer1};
228
244
  }
245
+ .wt-seg {
246
+ display: inline-flex; align-items: center; border-radius: 8px; overflow: hidden;
247
+ border: 1px solid ${C.border}; background: ${C.bg2};
248
+ }
249
+ .wt-seg-btn {
250
+ appearance: none; border: 0; background: transparent; color: ${C.label2};
251
+ font: inherit; font-size: 11.5px; line-height: 1; padding: 5px 9px; cursor: pointer;
252
+ }
253
+ .wt-seg-btn.is-on { background: ${C.hover}; color: ${C.label}; font-weight: 600; }
254
+ .wt-seg-btn:hover { color: ${C.label}; }
255
+ .wt-row[draggable="true"] { cursor: grab; }
256
+ .wt-row[draggable="true"]:active { cursor: grabbing; }
229
257
  .wt-group-head {
230
258
  display: flex; align-items: center; gap: 7px; padding: 3px 8px; border-radius: 8px;
231
259
  cursor: pointer; user-select: none; color: ${C.label2};
@@ -362,12 +390,13 @@ async function postJson(path, body) {
362
390
  return parsed.data ?? {};
363
391
  }
364
392
  var lastList = null;
365
- function useList() {
393
+ var lastSort = "manual";
394
+ function useList(sort) {
366
395
  const [state, setState] = import_react.default.useState(() => lastList ? { status: "ready", data: lastList, error: null } : { status: "loading", data: null, error: null });
367
396
  const [tick, setTick] = import_react.default.useState(0);
368
397
  import_react.default.useEffect(() => {
369
398
  let alive = true;
370
- fetch(`${ROUTE_LIST}?filter=all`, { cache: "no-store", credentials: "same-origin" }).then(async (response) => {
399
+ fetch(`${ROUTE_LIST}?filter=all&sort=${encodeURIComponent(sort || "manual")}`, { cache: "no-store", credentials: "same-origin" }).then(async (response) => {
371
400
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
372
401
  const body = await response.json().catch(() => null);
373
402
  if (!body?.ok) throw new Error(body?.error ?? body?.reason ?? "\u672A\u77E5\u9519\u8BEF");
@@ -385,7 +414,7 @@ function useList() {
385
414
  return () => {
386
415
  alive = false;
387
416
  };
388
- }, [tick]);
417
+ }, [tick, sort]);
389
418
  const refresh = import_react.default.useCallback(() => setTick((n) => n + 1), []);
390
419
  return { ...state, refresh };
391
420
  }
@@ -457,7 +486,7 @@ function QuickAdd({ t, onAdd, busy }) {
457
486
  }, t("add"))
458
487
  ]);
459
488
  }
460
- function TodoRow({ todo, today, t, onToggle, onRemove, onSave }) {
489
+ function TodoRow({ todo, today, t, onToggle, onRemove, onSave, drag }) {
461
490
  const [editing, setEditing] = import_react.default.useState(false);
462
491
  const [draft, setDraft] = import_react.default.useState(null);
463
492
  const [notesOpen, setNotesOpen] = import_react.default.useState(false);
@@ -627,7 +656,29 @@ function TodoRow({ todo, today, t, onToggle, onRemove, onSave }) {
627
656
  title: row.source === "agent" ? t("sourceAgent") : t("sourceSession")
628
657
  }, row.source === "agent" ? t("sourceAgent") : t("sourceSession")));
629
658
  }
630
- return import_react.default.createElement("div", { className: "wt-row" }, [
659
+ const dragAttrs = drag ? {
660
+ draggable: true,
661
+ onDragStart: (event) => {
662
+ try {
663
+ event.dataTransfer?.setData("text/plain", row.id ?? "");
664
+ if (event.dataTransfer) event.dataTransfer.effectAllowed = "move";
665
+ } catch {
666
+ }
667
+ drag.onStart?.(row.id);
668
+ },
669
+ onDragOver: (event) => {
670
+ event.preventDefault();
671
+ drag.onOver?.(row.id);
672
+ },
673
+ onDrop: (event) => {
674
+ event.preventDefault();
675
+ drag.onDrop?.(row.id);
676
+ },
677
+ onDragEnd: () => drag.onEnd?.(),
678
+ title: drag.hint,
679
+ style: drag.dragging ? { opacity: 0.45 } : void 0
680
+ } : {};
681
+ return import_react.default.createElement("div", { className: "wt-row", ...dragAttrs }, [
631
682
  import_react.default.createElement("button", {
632
683
  key: "check",
633
684
  type: "button",
@@ -674,7 +725,7 @@ function TodoRow({ todo, today, t, onToggle, onRemove, onSave }) {
674
725
  }, confirming ? t("confirmDelete") : import_react.default.createElement(IconTrash, { key: "i" }))
675
726
  ]);
676
727
  }
677
- function Group({ label, items, closed, t, today, onToggleOpen, onToggleTodo, onRemove, onSave }) {
728
+ function Group({ label, items, meta, closed, t, today, onToggleOpen, onToggleTodo, onRemove, onSave, drag }) {
678
729
  const list = asArray(items);
679
730
  if (!list.length) return null;
680
731
  return import_react.default.createElement("div", { style: { marginTop: 6 } }, [
@@ -688,7 +739,7 @@ function Group({ label, items, closed, t, today, onToggleOpen, onToggleTodo, onR
688
739
  }, [
689
740
  import_react.default.createElement(IconChevron, { key: "c", open: !closed }),
690
741
  import_react.default.createElement("span", { key: "l", style: { fontWeight: 600, fontSize: 12 } }, label),
691
- import_react.default.createElement("span", { key: "n", style: { fontSize: 11, color: C.label3 } }, String(list.length)),
742
+ import_react.default.createElement("span", { key: "n", style: { fontSize: 11, color: C.label3 } }, meta || String(list.length)),
692
743
  import_react.default.createElement("span", { key: "sp", style: { flex: 1 } }),
693
744
  import_react.default.createElement("span", { key: "h", style: { fontSize: 11, color: C.label3 } }, closed ? t("expand") : t("collapse"))
694
745
  ]),
@@ -699,7 +750,15 @@ function Group({ label, items, closed, t, today, onToggleOpen, onToggleTodo, onR
699
750
  t,
700
751
  onToggle: onToggleTodo,
701
752
  onRemove,
702
- onSave
753
+ onSave,
754
+ drag: drag ? {
755
+ dragging: drag.dragId === todo?.id,
756
+ hint: drag.hint,
757
+ onStart: drag.onStart,
758
+ onOver: drag.onOver,
759
+ onDrop: drag.onDrop,
760
+ onEnd: drag.onEnd
761
+ } : void 0
703
762
  })))
704
763
  ]);
705
764
  }
@@ -747,7 +806,13 @@ var GROUPS = [
747
806
  ];
748
807
  function WorkTodoPanel(props) {
749
808
  const t = typeof props?.t === "function" ? props.t : (key) => zh[key] ?? key;
750
- const { status, data, error, refresh } = useList();
809
+ const [sort, setSortState] = import_react.default.useState(lastSort);
810
+ const setSort = import_react.default.useCallback((next) => {
811
+ lastSort = next;
812
+ setSortState(next);
813
+ }, []);
814
+ const [dragId, setDragId] = import_react.default.useState(null);
815
+ const { status, data, error, refresh } = useList(sort);
751
816
  const [barError, setBarError] = import_react.default.useState(null);
752
817
  const [dismissedReadError, setDismissedReadError] = import_react.default.useState(null);
753
818
  const readError = error && error !== dismissedReadError ? error : null;
@@ -755,6 +820,7 @@ function WorkTodoPanel(props) {
755
820
  const [notice, setNotice] = import_react.default.useState(null);
756
821
  const [pending, setPending] = import_react.default.useState(0);
757
822
  const [closed, setClosed] = import_react.default.useState({ completed: true });
823
+ const [view, setView] = import_react.default.useState("due");
758
824
  const runWrite = import_react.default.useCallback(async (job) => {
759
825
  setPending((n) => n + 1);
760
826
  try {
@@ -784,6 +850,30 @@ function WorkTodoPanel(props) {
784
850
  const handleToggle = import_react.default.useCallback((ref) => runWrite(() => postJson("/status", { ref, toggle: true })), [runWrite]);
785
851
  const handleRemove = import_react.default.useCallback((ref) => runWrite(() => postJson("/remove", { ref })), [runWrite]);
786
852
  const handleSave = import_react.default.useCallback((ref, patch) => runWrite(() => postJson("/update", { ref, ...patch })), [runWrite]);
853
+ const handleDropOn = import_react.default.useCallback((targetId) => {
854
+ const dragging = dragId;
855
+ setDragId(null);
856
+ if (!dragging || !targetId || dragging === targetId) return Promise.resolve(false);
857
+ const ids = asArray(data?.todos).map((todo) => todo?.id).filter(Boolean);
858
+ const from = ids.indexOf(dragging);
859
+ if (from === -1) return Promise.resolve(false);
860
+ ids.splice(from, 1);
861
+ const to = ids.indexOf(targetId);
862
+ ids.splice(to === -1 ? ids.length : to, 0, dragging);
863
+ return runWrite(() => postJson("/reorder", { ids })).then((result) => result.ok);
864
+ }, [dragId, data, runWrite]);
865
+ const dragHandlers = import_react.default.useMemo(() => ({
866
+ dragId,
867
+ hint: t("dragHint"),
868
+ onStart: (id) => {
869
+ setDragId(id);
870
+ if (sort !== "manual") setSort("manual");
871
+ },
872
+ onOver: () => {
873
+ },
874
+ onDrop: (id) => handleDropOn(id),
875
+ onEnd: () => setDragId(null)
876
+ }), [dragId, sort, t, setSort, handleDropOn]);
787
877
  const handleAdopt = import_react.default.useCallback((sessionTodos) => runWrite(() => postJson("/adopt", { todos: sessionTodos })).then((result) => {
788
878
  if (result.ok) {
789
879
  const created = Array.isArray(result.data?.created) ? result.data.created.length : 0;
@@ -834,7 +924,14 @@ function WorkTodoPanel(props) {
834
924
  const store = data?.store ?? null;
835
925
  const num = (value) => Number.isFinite(Number(value)) ? Number(value) : 0;
836
926
  const overdueCount = num(stats.overdue);
837
- const totalVisible = GROUPS.reduce((sum, group) => sum + asArray(groups[group.key]).length, 0);
927
+ const projectGroups = asArray(data?.projectGroups);
928
+ const viewGroups = view === "project" ? projectGroups.map((bucket) => ({
929
+ key: `p:${bucket?.key ?? ""}`,
930
+ label: bucket?.project ? String(bucket.project) : t("noProject"),
931
+ meta: `${num(bucket?.open)}/${num(bucket?.total)}${num(bucket?.overdue) ? ` \xB7 ${t("statOverdue")} ${num(bucket.overdue)}` : ""}`,
932
+ items: bucket?.todos
933
+ })) : GROUPS.map((group) => ({ key: group.key, label: t(group.label), items: groups[group.key] }));
934
+ const totalVisible = viewGroups.reduce((sum, group) => sum + asArray(group.items).length, 0);
838
935
  const chips = [
839
936
  { key: "open", label: t("statOpen"), value: num(stats.open) },
840
937
  { key: "overdue", label: t("statOverdue"), value: overdueCount, alert: overdueCount > 0 },
@@ -858,6 +955,31 @@ function WorkTodoPanel(props) {
858
955
  import_react.default.createElement("span", { key: "l", style: { color: C.label3 } }, chip.label),
859
956
  import_react.default.createElement("b", { key: "v", style: { fontWeight: 650 } }, String(chip.value))
860
957
  ])),
958
+ import_react.default.createElement("span", { key: "sort", className: "wt-seg", title: t("sortLabel") }, [
959
+ ...["manual", "priority", "created"].map((mode) => import_react.default.createElement("button", {
960
+ key: mode,
961
+ type: "button",
962
+ className: `wt-seg-btn${sort === mode ? " is-on" : ""}`,
963
+ title: mode === "manual" ? t("dragHint") : `${t("sortLabel")}\uFF1A${t(`sort${mode === "manual" ? "Manual" : mode === "priority" ? "Priority" : "Created"}`)}`,
964
+ onClick: () => setSort(mode)
965
+ }, t(`sort${mode === "manual" ? "Manual" : mode === "priority" ? "Priority" : "Created"}`)))
966
+ ]),
967
+ import_react.default.createElement("span", { key: "view", className: "wt-seg" }, [
968
+ import_react.default.createElement("button", {
969
+ key: "due",
970
+ type: "button",
971
+ className: `wt-seg-btn${view === "due" ? " is-on" : ""}`,
972
+ title: t("viewDue"),
973
+ onClick: () => setView("due")
974
+ }, t("viewDue")),
975
+ import_react.default.createElement("button", {
976
+ key: "proj",
977
+ type: "button",
978
+ className: `wt-seg-btn${view === "project" ? " is-on" : ""}`,
979
+ title: t("viewProject"),
980
+ onClick: () => setView("project")
981
+ }, t("viewProject"))
982
+ ]),
861
983
  import_react.default.createElement("span", { key: "sp", style: { flex: 1 } }),
862
984
  import_react.default.createElement("button", {
863
985
  key: "rf",
@@ -938,17 +1060,19 @@ function WorkTodoPanel(props) {
938
1060
  }) : null,
939
1061
  import_react.default.createElement(QuickAdd, { key: "quick", t, busy: pending > 0, onAdd: handleAdd }),
940
1062
  /* 分组列表 */
941
- totalVisible ? import_react.default.createElement("div", { key: "groups" }, GROUPS.map((group) => import_react.default.createElement(Group, {
1063
+ totalVisible ? import_react.default.createElement("div", { key: "groups" }, viewGroups.map((group) => import_react.default.createElement(Group, {
942
1064
  key: group.key,
943
- label: t(group.label),
944
- items: groups[group.key],
1065
+ label: group.label,
1066
+ meta: group.meta,
1067
+ items: group.items,
945
1068
  closed: Boolean(closed[group.key]),
946
1069
  t,
947
1070
  today,
948
1071
  onToggleOpen: () => setClosed((prev) => ({ ...prev, [group.key]: !prev[group.key] })),
949
1072
  onToggleTodo: handleToggle,
950
1073
  onRemove: handleRemove,
951
- onSave: handleSave
1074
+ onSave: handleSave,
1075
+ drag: dragHandlers
952
1076
  }))) : import_react.default.createElement("div", {
953
1077
  key: "empty",
954
1078
  style: { padding: "22px 12px", textAlign: "center", color: C.label3 }