dsh-global-todo 0.14.0 → 0.16.0
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 +32 -0
- package/README.md +7 -2
- package/README.zh.md +4 -1
- package/docs/ROADMAP.md +18 -12
- package/lib/client.js +96 -4
- package/lib/index.js +1 -1
- package/lib/links.js +28 -3
- package/lib/routes.js +70 -1
- package/lib/service.js +37 -2
- package/lib/tools.js +4 -4
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,35 @@
|
|
|
1
|
+
## [0.16.0] — 2026-10-22
|
|
2
|
+
|
|
3
|
+
The three small promises the roadmap still carried.
|
|
4
|
+
|
|
5
|
+
- **`before:` / `after:` in the search box** bound the deadline (strict at the bound). Items
|
|
6
|
+
without a deadline cannot satisfy either bound, and a half-typed date such as `before:2026`
|
|
7
|
+
simply stops filtering instead of throwing on every keystroke.
|
|
8
|
+
- **「重新挂载」 next to a dead attachment** re-points it in place (`POST /relink`, same
|
|
9
|
+
`{ todo, changed }` shape as `/update`), keeping the kind and title. `annotateLinks()` finally
|
|
10
|
+
has its unit test — and it now flags a dead **image** too, which it used to skip, so a missing
|
|
11
|
+
screenshot shows the existing chip instead of a broken frame.
|
|
12
|
+
- **Accessibility pass**: the toast is a polite live region, errors are alerts, groups and rows
|
|
13
|
+
are list items, filter buttons report `aria-pressed`, and every icon-only button has a name.
|
|
14
|
+
`test/a11y.test.mjs` reads the client source so these cannot quietly regress without a browser.
|
|
15
|
+
|
|
16
|
+
## [0.15.0] — 2026-10-22
|
|
17
|
+
|
|
18
|
+
Image attachments: a reference that renders, not a copy that goes stale.
|
|
19
|
+
|
|
20
|
+
- **`kind: "image"` is a third attachment kind** beside `url` and `file`. The path stays an
|
|
21
|
+
absolute reference — the list still never stores bytes — but it must have a renderable
|
|
22
|
+
extension (png / jpg / jpeg / webp / gif / avif; SVG is excluded because it can carry script
|
|
23
|
+
when served inline). A bare path is never guessed into an image: the caller states it.
|
|
24
|
+
- **`GET /asset?id=&index=` serves the bytes**, reading the path out of the store rather than from
|
|
25
|
+
the request, so there is no traversal surface. Capped at 8 MB with `nosniff`, and only for
|
|
26
|
+
`image` links — a document link asked through this route is a 400, a missing index a 404.
|
|
27
|
+
- **The panel shows a thumbnail on the row and a preview in the detail**, both lazy, and the
|
|
28
|
+
existing "file is gone" marker still applies: a dead image renders as a bad chip, not a broken
|
|
29
|
+
frame.
|
|
30
|
+
- Tool schemas accept `kind: "image"` wherever an attachment does, so an agent can attach a
|
|
31
|
+
screenshot of what it built.
|
|
32
|
+
|
|
1
33
|
## [0.14.0] — 2026-10-22
|
|
2
34
|
|
|
3
35
|
Choose how much surface a deployment pays for.
|
package/README.md
CHANGED
|
@@ -211,7 +211,10 @@ Your list is not touched by install or uninstall: it lives outside the plugin, a
|
|
|
211
211
|
### The panel
|
|
212
212
|
|
|
213
213
|
- **Add** — type a title and press Enter. "More fields" reveals the description, the
|
|
214
|
-
attachment box (one document path or URL per line), tags, priority and a due time.
|
|
214
|
+
attachment box (one document path or URL per line), tags, priority and a due time. An
|
|
215
|
+
attachment may also be `{ kind: "image", value: "/abs/shot.png" }` — the row then shows a
|
|
216
|
+
thumbnail and the detail a preview, with the bytes read on demand (png/jpg/jpeg/webp/gif/avif,
|
|
217
|
+
≤8 MB) and never copied into the list.
|
|
215
218
|
- **Filters** — all / open / doing / done / overdue, plus a text search across titles,
|
|
216
219
|
descriptions, tags and attachments. The count chips in the header are filters too.
|
|
217
220
|
- **Grouped by urgency** — overdue / today / next up / later / no due date / done, each
|
|
@@ -249,7 +252,7 @@ Your list is not touched by install or uninstall: it lives outside the plugin, a
|
|
|
249
252
|
That is an attempt order: an unloadable target means the next one is tried. Anything with
|
|
250
253
|
nowhere to go comes back with a picker rather than being sent somewhere unchosen, and one
|
|
251
254
|
failure does not abort the batch.
|
|
252
|
-
- **Search syntax** — `tag:release` (or `#ops`), `is:overdue`, `is:active`, `has:link`,
|
|
255
|
+
- **Search syntax** — `tag:release` (or `#ops`), `is:overdue`, `is:active`, `has:link`, `before:2026-11-01` / `after:2026-10-01` (deadline bounds; a half-typed date simply stops filtering),
|
|
253
256
|
`has:note`, `level:p0`, plus free words combined with AND (which also match note text). A
|
|
254
257
|
search without a colon behaves exactly as it always did.
|
|
255
258
|
- **Progress notes** — an append-only trail per todo (count on the row, full text and an add
|
|
@@ -390,6 +393,8 @@ fence when that service is present, and by a strict loopback check otherwise.
|
|
|
390
393
|
| POST | `/api/global-todo/review` | `{ id, decision: confirm\|reject }` — answer an agent's completion |
|
|
391
394
|
| GET | `/api/global-todo/report?days=7` | what the agents finished in the window, and the files it touched |
|
|
392
395
|
| GET | `/api/global-todo/session?id=` | what a bound conversation last said (`tail`, `alive`, `dir`) |
|
|
396
|
+
| POST | `/api/global-todo/relink` | `{ id, index, value }` — re-point a dead attachment (the value must still be a valid absolute path or URL of that kind) |
|
|
397
|
+
| GET | `/api/global-todo/asset?id=&index=` | the bytes of an **image** attachment (path read from the store, never from the request; ≤8 MB, png/jpg/jpeg/webp/gif/avif) |
|
|
393
398
|
| POST | `/api/global-todo/link` | `{ id, add?: [...], remove?: [value \| index] }` |
|
|
394
399
|
| POST | `/api/global-todo/dispatch` | `{ id, mode?: auto\|bound\|new\|current, sessionId?, dir? }` |
|
|
395
400
|
| POST | `/api/global-todo/assign` | `{ ids, items?, target?, dir? }` — hand several todos over in one call; an id that matches nothing comes back instead of being sent elsewhere |
|
package/README.zh.md
CHANGED
|
@@ -188,6 +188,7 @@ dsh plugin --profile web remove dsh-global-todo
|
|
|
188
188
|
|
|
189
189
|
### 面板
|
|
190
190
|
|
|
191
|
+
- **图片附件**:附件也可以写成 `{ "kind": "image", "value": "/绝对路径/shot.png" }`,行上显示缩略图、详情里显示预览;字节按需读取(png/jpg/jpeg/webp/gif/avif,≤8MB),**不复制进清单**。
|
|
191
192
|
- **添加**:写一句标题回车即存。「更多字段」展开描述、附件框(一行一个文档绝对路径或网址)、
|
|
192
193
|
标签、优先级、截止时间。
|
|
193
194
|
- **筛选**:全部 / 未完成 / 进行中 / 已完成 / 逾期,外加跨标题、描述、标签、附件的文本搜索;
|
|
@@ -206,7 +207,7 @@ dsh plugin --profile web remove dsh-global-todo
|
|
|
206
207
|
- **「抓取」在每个对话的分组上**:让**那个对话**里的 Agent 把该跟进的事登记成待办,结果就归在它自己那组下面(分组名旁边那个「抓取」)。工具栏上的「从本对话抓取」只在**当前对话还没有分组**时出现——否则没地方点它。抓取对象没在运行会**先唤醒**再问(与派发同一条规则)。
|
|
207
208
|
- **工作目录**:`选择` 会用宿主的目录选择器;如果这个部署没挂载它(远程命名空间缺失),面板会自动改为**手动填路径**(`手动填` → 粘绝对路径 → `使用`),并说明原因——不会只丢一条看不懂的错误。
|
|
208
209
|
- **一键分配(多选)**:工具栏点「选择」→ 勾几条 → 「一键分配」。每条按**原对话 → 同工作区对话 → 你选的目标**的顺序尝试(是**尝试顺序**,不是一次定死:原对话加载不了就自动往下走)。都找不到的会**报回来并给你选择器**,绝不擅自送到别处;其中一条失败也不会中断整批。
|
|
209
|
-
- **搜索语法**:`tag:发布`(或 `#发布`)、`is:overdue`、`is:active`、`has:link`、`has:note`、`level:p0`(也可写 `level:urgent
|
|
210
|
+
- **搜索语法**:`tag:发布`(或 `#发布`)、`is:overdue`、`is:active`、`has:link`、`has:note`、`level:p0`(也可写 `level:urgent`)、`before:2026-11-01` / `after:2026-10-01`(按截止时间卡边界;日期没打完时只是不生效,不会报错),自由词之间是 **AND**,并且会一并搜进展记录。不带冒号的普通搜索行为不变。
|
|
210
211
|
- **进展记录**:每条待办可追加「谁在什么时候说了什么」的流水(行上显示条数,详情里看全文并可直接补一条)。Agent 报「做不完、卡在哪」应该写在这里,**而不是改写你的描述**。
|
|
211
212
|
- **失效附件会标出来**:文件被移走/删除的附件在行上标红、详情里注明;这个标记是**读取时算的**,不写进记录——文件回来了就不该再标红。
|
|
212
213
|
- **新对话开在哪**:默认**继承写下这条待办的对话所在的工作区**(新会话的工作目录也就是那个工作区),所以「接着这个活干」不会跑到不相干的工作区去;想指定就选 `+ 新对话 · 工作区名`(列在工作区一栏里),指定的优先。只有当两者都不知道时,才退回按工作目录判断。
|
|
@@ -293,6 +294,8 @@ dsh plugin --profile web remove dsh-global-todo
|
|
|
293
294
|
| POST | `/api/global-todo/review` | `{ id, decision: confirm\|reject }` —— 回答 Agent 的完成声明 |
|
|
294
295
|
| GET | `/api/global-todo/report?days=7` | 这段时间里 Agent 完成了什么、触及了哪些文件 |
|
|
295
296
|
| GET | `/api/global-todo/session?id=` | 绑定的会话最后说了什么(`tail` / `alive` / `dir`) |
|
|
297
|
+
| POST | `/api/global-todo/relink` | `{ id, index, value }`——把失效附件重新指向(新值仍须是该类型的合法绝对路径/网址) |
|
|
298
|
+
| GET | `/api/global-todo/asset?id=&index=` | 读**图片附件**的字节(路径只从待办记录里取,不接受请求传路径;≤8MB,png/jpg/jpeg/webp/gif/avif) |
|
|
296
299
|
| POST | `/api/global-todo/link` | `{ id, add?: [...], remove?: [值 或 下标] }` |
|
|
297
300
|
| POST | `/api/global-todo/dispatch` | `{ id, mode?: auto\|bound\|new\|current, sessionId?, dir? }` |
|
|
298
301
|
| POST | `/api/global-todo/assign` | `{ ids, items?, target?, dir? }`——一次把多条交给 Agent;配不上的 id 原样报回,不擅自送到别处 |
|
package/docs/ROADMAP.md
CHANGED
|
@@ -200,14 +200,14 @@ of conversations involved, files touched with counts, and how many wait for revi
|
|
|
200
200
|
time, never stored. The panel shows it from `⋯ → 本周报告`. Tests:
|
|
201
201
|
`test/markdown.test.mjs` (pure aggregation) and `test/routes.test.mjs`.
|
|
202
202
|
|
|
203
|
-
### 5. Image attachments
|
|
203
|
+
### 5. ~~Image attachments~~ — landed in 0.15.0
|
|
204
204
|
|
|
205
|
-
`kind: 'image'` beside `url` / `file
|
|
206
|
-
to
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
-
|
|
210
|
-
|
|
205
|
+
`kind: 'image'` beside `url` / `file`: still an absolute reference (the store never holds
|
|
206
|
+
bytes), required to carry a renderable extension, with `GET /asset?id=&index=` reading the
|
|
207
|
+
path out of the record rather than from the request (8 MB cap, `nosniff`, image links only).
|
|
208
|
+
The row shows a thumbnail and the detail a preview; a dead image keeps the existing
|
|
209
|
+
missing-file chip instead of rendering a broken frame. `lib/links.js`, `lib/routes.js`,
|
|
210
|
+
`lib/client.js`; tests in `test/links.test.mjs` and `test/routes.test.mjs`.
|
|
211
211
|
|
|
212
212
|
### 6. Source adapters for the ingest door
|
|
213
213
|
|
|
@@ -218,11 +218,17 @@ to one.
|
|
|
218
218
|
- Effort: large, and the mode of the work is different: every adapter is an external
|
|
219
219
|
dependency with its own failure modes. Do it when a real source exists, not before.
|
|
220
220
|
|
|
221
|
-
### 7. Small, well-understood items
|
|
222
|
-
|
|
223
|
-
-
|
|
224
|
-
|
|
225
|
-
|
|
221
|
+
### 7. ~~Small, well-understood items~~ — landed in 0.16.0
|
|
222
|
+
|
|
223
|
+
- **`before:` / `after:` date bounds** in the search syntax, applied to the deadline and strict
|
|
224
|
+
at the bound (`lib/service.js`); a half-typed date stops filtering instead of erroring on
|
|
225
|
+
every keystroke.
|
|
226
|
+
- **「重新挂载」** beside a dead attachment (`POST /relink`, same shape as `/update`), plus the
|
|
227
|
+
`annotateLinks()` unit test the code owed — including the image kind, which the annotation
|
|
228
|
+
used to skip.
|
|
229
|
+
- **Accessibility pass**: the toast is a polite live region, errors are alerts, groups and rows
|
|
230
|
+
are list items, filter buttons report `aria-pressed`, and every icon-only button carries a
|
|
231
|
+
name (`test/a11y.test.mjs` guards all of it).
|
|
226
232
|
|
|
227
233
|
### Landed after this list was written
|
|
228
234
|
|
package/lib/client.js
CHANGED
|
@@ -40,6 +40,11 @@ window.__ModuleLoader__.load({
|
|
|
40
40
|
const LOCALE_NS = 'global-todo'
|
|
41
41
|
/** Route prefix, relative to the document base. */
|
|
42
42
|
const API = 'api/global-todo'
|
|
43
|
+
/**
|
|
44
|
+
* Where an image attachment's bytes come from. The route reads the path out of the
|
|
45
|
+
* store, so this URL carries an id and an index — never a filesystem path.
|
|
46
|
+
*/
|
|
47
|
+
const assetUrl = (id, index) => `${API}/asset?id=${encodeURIComponent(id)}&index=${index}`
|
|
43
48
|
const POLL_MS = 20000
|
|
44
49
|
/** How long a deleted todo can be brought back. */
|
|
45
50
|
const UNDO_MS = 8000
|
|
@@ -232,6 +237,7 @@ window.__ModuleLoader__.load({
|
|
|
232
237
|
'dir.picked': '(已指定)',
|
|
233
238
|
'action.save': '保存',
|
|
234
239
|
'action.cancel': '取消',
|
|
240
|
+
'action.relink': '重新挂载',
|
|
235
241
|
'filter.all': '全部',
|
|
236
242
|
'filter.active': '未完成',
|
|
237
243
|
'filter.doing': '进行中',
|
|
@@ -534,6 +540,7 @@ window.__ModuleLoader__.load({
|
|
|
534
540
|
'meta.origin': 'from {dir}',
|
|
535
541
|
'meta.completed': 'Completed {when}',
|
|
536
542
|
'action.save': 'Save',
|
|
543
|
+
'action.relink': 'Re-point',
|
|
537
544
|
'action.cancel': 'Cancel',
|
|
538
545
|
'filter.all': 'All',
|
|
539
546
|
'filter.active': 'Open',
|
|
@@ -1329,6 +1336,11 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
1329
1336
|
.gt-attach .gt-chip { max-width: 280px; color: var(--dsw-alias-label-secondary); }
|
|
1330
1337
|
.gt-attach a.gt-chip:hover { color: var(--dsw-alias-label-primary); border-color: var(--dsw-alias-border-l2); }
|
|
1331
1338
|
.gt-attach-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
1339
|
+
.gt-chip-img { padding: 2px 8px 2px 3px; }
|
|
1340
|
+
.gt-thumb { width: 16px; height: 16px; object-fit: cover; border-radius: 999px; display: block; }
|
|
1341
|
+
.gt-shot-wrap { display: inline-flex; }
|
|
1342
|
+
.gt-shot { width: 84px; height: 56px; object-fit: cover; border-radius: 6px;
|
|
1343
|
+
border: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-layer-2); }
|
|
1332
1344
|
.gt-menu { position: absolute; top: 40px; right: 10px; z-index: 3; min-width: 184px; display: flex;
|
|
1333
1345
|
flex-direction: column; padding: 5px; background: var(--dsw-alias-bg-overlay);
|
|
1334
1346
|
border: 1px solid var(--dsw-alias-border-l2); border-radius: 10px; }
|
|
@@ -1367,6 +1379,7 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
1367
1379
|
.gt-pre { white-space: pre-wrap; word-break: break-word; margin: 0; line-height: 1.6; }
|
|
1368
1380
|
.gt-muted { color: var(--dsw-alias-label-secondary); }
|
|
1369
1381
|
.gt-links { display: flex; flex-direction: column; gap: 4px; margin: 0; padding: 0; list-style: none; }
|
|
1382
|
+
.gt-relink { display: flex; flex: 1 1 100%; gap: 6px; align-items: center; }
|
|
1370
1383
|
.gt-link { display: flex; gap: 8px; align-items: center; padding: 4px 0; min-width: 0; }
|
|
1371
1384
|
.gt-link-main { display: flex; align-items: center; gap: 6px; min-width: 0; flex: 1; }
|
|
1372
1385
|
.gt-evidence { margin: 0; padding: 2px 0 2px 10px; border-left: 2px solid var(--dsw-alias-border-l2);
|
|
@@ -1722,6 +1735,9 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
1722
1735
|
// the only place the question "did it get anywhere" is asked.
|
|
1723
1736
|
const [tail, setTail] = useState(null)
|
|
1724
1737
|
const [linkValue, setLinkValue] = useState('')
|
|
1738
|
+
// Re-pointing a dead attachment: which row is editing, and what path it now holds.
|
|
1739
|
+
const [relinkIndex, setRelinkIndex] = useState(null)
|
|
1740
|
+
const [relinkValue, setRelinkValue] = useState('')
|
|
1725
1741
|
const [linkTitle, setLinkTitle] = useState('')
|
|
1726
1742
|
const [draft, setDraft] = useState({
|
|
1727
1743
|
title: todo.title,
|
|
@@ -1935,7 +1951,21 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
1935
1951
|
'div',
|
|
1936
1952
|
{ className: 'gt-attach' },
|
|
1937
1953
|
todo.links.slice(0, MAX_LINK_CHIPS).map((link, index) =>
|
|
1938
|
-
link.kind === '
|
|
1954
|
+
link.kind === 'image' && link.missing !== true
|
|
1955
|
+
? h(
|
|
1956
|
+
'a',
|
|
1957
|
+
{
|
|
1958
|
+
key: `${index}:${link.value}`,
|
|
1959
|
+
className: 'gt-chip gt-chip-img',
|
|
1960
|
+
href: assetUrl(todo.id, index),
|
|
1961
|
+
target: '_blank',
|
|
1962
|
+
rel: 'noreferrer noopener',
|
|
1963
|
+
title: link.value,
|
|
1964
|
+
},
|
|
1965
|
+
h('img', { className: 'gt-thumb', src: assetUrl(todo.id, index), alt: link.title, loading: 'lazy' }),
|
|
1966
|
+
h('span', { className: 'gt-attach-title' }, link.title),
|
|
1967
|
+
)
|
|
1968
|
+
: link.kind === 'url'
|
|
1939
1969
|
? h(
|
|
1940
1970
|
'a',
|
|
1941
1971
|
{
|
|
@@ -2376,6 +2406,19 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
2376
2406
|
h(
|
|
2377
2407
|
'li',
|
|
2378
2408
|
{ className: 'gt-link', key: `${link.kind}:${link.value}` },
|
|
2409
|
+
link.kind === 'image' && link.missing !== true
|
|
2410
|
+
? h(
|
|
2411
|
+
'a',
|
|
2412
|
+
{
|
|
2413
|
+
className: 'gt-shot-wrap',
|
|
2414
|
+
href: assetUrl(todo.id, index),
|
|
2415
|
+
target: '_blank',
|
|
2416
|
+
rel: 'noreferrer noopener',
|
|
2417
|
+
title: link.value,
|
|
2418
|
+
},
|
|
2419
|
+
h('img', { className: 'gt-shot', src: assetUrl(todo.id, index), alt: link.title, loading: 'lazy' }),
|
|
2420
|
+
)
|
|
2421
|
+
: null,
|
|
2379
2422
|
h(
|
|
2380
2423
|
'span',
|
|
2381
2424
|
{ className: 'gt-link-main' },
|
|
@@ -2392,11 +2435,53 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
2392
2435
|
{ type: 'button', className: 'gt-btn gt-btn-quiet', onClick: () => copy(link.value) },
|
|
2393
2436
|
copied === link.value ? t('action.copied') : t('action.copy'),
|
|
2394
2437
|
),
|
|
2438
|
+
link.missing === true
|
|
2439
|
+
? h(
|
|
2440
|
+
'button',
|
|
2441
|
+
{
|
|
2442
|
+
type: 'button',
|
|
2443
|
+
className: 'gt-btn gt-btn-quiet',
|
|
2444
|
+
disabled: busy,
|
|
2445
|
+
onClick: () => {
|
|
2446
|
+
setRelinkIndex(index)
|
|
2447
|
+
setRelinkValue(link.value)
|
|
2448
|
+
},
|
|
2449
|
+
},
|
|
2450
|
+
t('action.relink'),
|
|
2451
|
+
)
|
|
2452
|
+
: null,
|
|
2395
2453
|
h(
|
|
2396
2454
|
'button',
|
|
2397
2455
|
{ type: 'button', className: 'gt-btn gt-btn-quiet', disabled: busy, onClick: () => onAction('unlink', todo, { index }) },
|
|
2398
2456
|
t('action.unlink'),
|
|
2399
2457
|
),
|
|
2458
|
+
relinkIndex === index
|
|
2459
|
+
? h(
|
|
2460
|
+
'span',
|
|
2461
|
+
{ className: 'gt-relink' },
|
|
2462
|
+
h('input', {
|
|
2463
|
+
className: 'gt-input',
|
|
2464
|
+
value: relinkValue,
|
|
2465
|
+
placeholder: t('field.linkValue'),
|
|
2466
|
+
onChange: (event) => setRelinkValue(event.target.value),
|
|
2467
|
+
}),
|
|
2468
|
+
h(
|
|
2469
|
+
'button',
|
|
2470
|
+
{
|
|
2471
|
+
type: 'button',
|
|
2472
|
+
className: 'gt-btn',
|
|
2473
|
+
disabled: busy || relinkValue.trim() === '',
|
|
2474
|
+
onClick: () => onAction('relink', todo, { index, value: relinkValue }),
|
|
2475
|
+
},
|
|
2476
|
+
t('action.save'),
|
|
2477
|
+
),
|
|
2478
|
+
h(
|
|
2479
|
+
'button',
|
|
2480
|
+
{ type: 'button', className: 'gt-btn gt-btn-quiet', onClick: () => setRelinkIndex(null) },
|
|
2481
|
+
t('action.cancel'),
|
|
2482
|
+
),
|
|
2483
|
+
)
|
|
2484
|
+
: null,
|
|
2400
2485
|
),
|
|
2401
2486
|
),
|
|
2402
2487
|
),
|
|
@@ -3487,6 +3572,13 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
3487
3572
|
if (kind === 'steps') return run(todo.id, () => call('update', { method: 'POST', body: { id: todo.id, steps: payload.steps } }))
|
|
3488
3573
|
if (kind === 'note') return run(todo.id, () => call('note', { method: 'POST', body: { id: todo.id, ...payload } }))
|
|
3489
3574
|
if (kind === 'link') return run(todo.id, () => call('link', { method: 'POST', body: { id: todo.id, ...payload } }))
|
|
3575
|
+
if (kind === 'relink') {
|
|
3576
|
+
return run(todo.id, async () => {
|
|
3577
|
+
await call('relink', { method: 'POST', body: { id: todo.id, ...payload } })
|
|
3578
|
+
setRelinkIndex(null)
|
|
3579
|
+
setRelinkValue('')
|
|
3580
|
+
})
|
|
3581
|
+
}
|
|
3490
3582
|
if (kind === 'unlink') return run(todo.id, () => call('link', { method: 'POST', body: { id: todo.id, remove: [payload.index] } }))
|
|
3491
3583
|
if (kind === 'remove') {
|
|
3492
3584
|
return run(todo.id, async () => {
|
|
@@ -4745,7 +4837,7 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
4745
4837
|
const exemptGroup = typeof group.dir === 'string' && group.dir !== '' && exemptWorkspaces.includes(group.dir)
|
|
4746
4838
|
return h(
|
|
4747
4839
|
'div',
|
|
4748
|
-
{ className: 'gt-group', key: group.key, 'data-collapsed': isCollapsed ? '1' : '0' },
|
|
4840
|
+
{ className: 'gt-group', key: group.key, role: 'listitem', 'data-collapsed': isCollapsed ? '1' : '0' },
|
|
4749
4841
|
h(
|
|
4750
4842
|
'div',
|
|
4751
4843
|
{ className: 'gt-group-head' },
|
|
@@ -4836,7 +4928,7 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
4836
4928
|
? null
|
|
4837
4929
|
: h(
|
|
4838
4930
|
'div',
|
|
4839
|
-
{ className: 'gt-list' },
|
|
4931
|
+
{ className: 'gt-list', role: 'list' },
|
|
4840
4932
|
group.todos.map((todo) =>
|
|
4841
4933
|
h(TodoItem, {
|
|
4842
4934
|
selecting: selectMode,
|
|
@@ -4872,7 +4964,7 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
4872
4964
|
notice !== null && notice !== undefined
|
|
4873
4965
|
? h(
|
|
4874
4966
|
'div',
|
|
4875
|
-
{ className: 'gt-toast' },
|
|
4967
|
+
{ className: 'gt-toast', role: 'status', 'aria-live': 'polite' },
|
|
4876
4968
|
h('span', null, notice.text),
|
|
4877
4969
|
// Only offer the jump when the capability is really there: a button
|
|
4878
4970
|
// that does nothing is worse than no button.
|
package/lib/index.js
CHANGED
|
@@ -37,7 +37,7 @@ import { createStore, resolveStorePath } from './store.js'
|
|
|
37
37
|
import { createTools, normalizeToolGroups, selectTools } from './tools.js'
|
|
38
38
|
|
|
39
39
|
/** Must equal `package.json`'s version; `npm run check` enforces that. */
|
|
40
|
-
export const VERSION = '0.
|
|
40
|
+
export const VERSION = '0.16.0'
|
|
41
41
|
|
|
42
42
|
/** Plugin id. Also the Loader row id in `cordis.patch.yml`. */
|
|
43
43
|
export const name = 'dsh-global-todo'
|
package/lib/links.js
CHANGED
|
@@ -17,8 +17,28 @@
|
|
|
17
17
|
|
|
18
18
|
import { TodoError } from './errors.js'
|
|
19
19
|
|
|
20
|
-
/**
|
|
21
|
-
|
|
20
|
+
/**
|
|
21
|
+
* Accepted attachment kinds.
|
|
22
|
+
*
|
|
23
|
+
* `image` is a reference like `file` — the bytes stay where they are — but it is
|
|
24
|
+
* declared rather than inferred, because the panel renders it and a guess would
|
|
25
|
+
* turn a random document into a broken thumbnail. The list still never copies
|
|
26
|
+
* bytes; `GET /asset` reads the referenced file on demand.
|
|
27
|
+
*/
|
|
28
|
+
export const LINK_KINDS = Object.freeze(['url', 'file', 'image'])
|
|
29
|
+
|
|
30
|
+
/** Extensions the panel can actually render. SVG is excluded: served inline from the same
|
|
31
|
+
* origin it can carry script. */
|
|
32
|
+
export const IMAGE_EXTENSIONS = Object.freeze(['.png', '.jpg', '.jpeg', '.webp', '.gif', '.avif'])
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {string} value
|
|
36
|
+
* @returns {boolean} whether the path looks like a renderable image
|
|
37
|
+
*/
|
|
38
|
+
export function isImagePath(value) {
|
|
39
|
+
const lower = String(value ?? '').toLowerCase()
|
|
40
|
+
return IMAGE_EXTENSIONS.some((extension) => lower.endsWith(extension))
|
|
41
|
+
}
|
|
22
42
|
|
|
23
43
|
const MAX_URL = 2048
|
|
24
44
|
const MAX_PATH = 4096
|
|
@@ -27,6 +47,8 @@ const MAX_TITLE = 200
|
|
|
27
47
|
/**
|
|
28
48
|
* Guess the kind of a bare string attachment.
|
|
29
49
|
*
|
|
50
|
+
* `image` is never inferred: it is a rendering decision the caller states.
|
|
51
|
+
*
|
|
30
52
|
* @param {string} value
|
|
31
53
|
* @returns {'url' | 'file'}
|
|
32
54
|
*/
|
|
@@ -91,6 +113,9 @@ export function normalizeLink(input, options = {}) {
|
|
|
91
113
|
if (!isAbsolutePath(value)) {
|
|
92
114
|
throw new TodoError('invalid-link', `文档请给绝对路径(相对路径会在不同会话下指向不同文件):${value}`)
|
|
93
115
|
}
|
|
116
|
+
if (kind === 'image' && !isImagePath(value)) {
|
|
117
|
+
throw new TodoError('invalid-link', `图片附件需要图片扩展名(${IMAGE_EXTENSIONS.join(' / ')}):${value}`)
|
|
118
|
+
}
|
|
94
119
|
if (options.requireExistingFiles === true && typeof options.exists === 'function' && !options.exists(value)) {
|
|
95
120
|
throw new TodoError('invalid-link', `找不到这个文件:${value}`)
|
|
96
121
|
}
|
|
@@ -113,7 +138,7 @@ export function isAbsolutePath(value) {
|
|
|
113
138
|
* @returns {string}
|
|
114
139
|
*/
|
|
115
140
|
export function defaultLinkTitle(kind, value) {
|
|
116
|
-
if (kind === 'file') {
|
|
141
|
+
if (kind === 'file' || kind === 'image') {
|
|
117
142
|
const parts = value.split(/[\\/]/).filter(Boolean)
|
|
118
143
|
return parts.length === 0 ? value : parts[parts.length - 1]
|
|
119
144
|
}
|
package/lib/routes.js
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
import { createHash, timingSafeEqual } from 'node:crypto'
|
|
24
|
-
import { readdir, stat } from 'node:fs/promises'
|
|
24
|
+
import { readFile, readdir, stat } from 'node:fs/promises'
|
|
25
25
|
import { homedir } from 'node:os'
|
|
26
26
|
import path from 'node:path'
|
|
27
27
|
|
|
@@ -81,6 +81,21 @@ export function isLoopbackRequest(request) {
|
|
|
81
81
|
* @param {number} status
|
|
82
82
|
* @param {unknown} body
|
|
83
83
|
*/
|
|
84
|
+
/** Cap for an inline image; a screenshot is a few hundred KB, a 4K render is not. */
|
|
85
|
+
const MAX_ASSET_BYTES = 8 * 1024 * 1024
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* @param {string} file
|
|
89
|
+
* @returns {string} the content type for a served image extension
|
|
90
|
+
*/
|
|
91
|
+
function mimeForImage(file) {
|
|
92
|
+
const lower = String(file).toLowerCase()
|
|
93
|
+
if (lower.endsWith('.png')) return 'image/png'
|
|
94
|
+
if (lower.endsWith('.webp')) return 'image/webp'
|
|
95
|
+
if (lower.endsWith('.gif')) return 'image/gif'
|
|
96
|
+
if (lower.endsWith('.avif')) return 'image/avif'
|
|
97
|
+
return 'image/jpeg'
|
|
98
|
+
}
|
|
84
99
|
function sendJson(response, status, body) {
|
|
85
100
|
const payload = JSON.stringify(body)
|
|
86
101
|
response.statusCode = status
|
|
@@ -403,6 +418,38 @@ export function createRoutes(deps) {
|
|
|
403
418
|
* Derived, never stored: completion records already hold the facts, and a cached
|
|
404
419
|
* aggregate would be a second truth to keep honest.
|
|
405
420
|
*/
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Serve the bytes of an image attachment.
|
|
424
|
+
*
|
|
425
|
+
* The path comes from the store, never from the request: the caller names a todo and
|
|
426
|
+
* a link index, so there is no traversal surface here. The list still stores only a
|
|
427
|
+
* reference; this route reads the file when the panel needs a thumbnail.
|
|
428
|
+
*/
|
|
429
|
+
exact('GET', `${ROUTE_BASE}/asset`, async (request, response) => {
|
|
430
|
+
const query = params(request)
|
|
431
|
+
const id = (query.get('id') ?? '').trim()
|
|
432
|
+
const index = Number.parseInt(query.get('index') ?? '', 10)
|
|
433
|
+
if (id === '' || !Number.isInteger(index) || index < 0) {
|
|
434
|
+
throw new TodoError('invalid-body', 'asset 需要 id 与 index')
|
|
435
|
+
}
|
|
436
|
+
const todo = await service.get(id)
|
|
437
|
+
const link = (todo.links ?? [])[index]
|
|
438
|
+
if (link === undefined) throw new TodoError('not-found', '这条待办没有这个附件')
|
|
439
|
+
if (link.kind !== 'image') throw new TodoError('invalid-link', '只有图片附件可以这样读取')
|
|
440
|
+
const info = await stat(link.value).catch(() => null)
|
|
441
|
+
if (info === null || info.isFile() !== true) throw new TodoError('not-found', '图片不在了')
|
|
442
|
+
if (info.size > MAX_ASSET_BYTES) {
|
|
443
|
+
throw new TodoError('payload-too-large', `图片超过 ${Math.round(MAX_ASSET_BYTES / 1024 / 1024)}MB`)
|
|
444
|
+
}
|
|
445
|
+
const bytes = await readFile(link.value)
|
|
446
|
+
response.statusCode = 200
|
|
447
|
+
response.setHeader('content-type', mimeForImage(link.value))
|
|
448
|
+
response.setHeader('cache-control', 'no-store')
|
|
449
|
+
response.setHeader('x-content-type-options', 'nosniff')
|
|
450
|
+
response.end(bytes)
|
|
451
|
+
}),
|
|
452
|
+
|
|
406
453
|
exact('GET', `${ROUTE_BASE}/report`, async (request) => {
|
|
407
454
|
const days = Number.parseInt(params(request).get('days') ?? '', 10)
|
|
408
455
|
const todos = await service.listAll()
|
|
@@ -671,6 +718,28 @@ export function createRoutes(deps) {
|
|
|
671
718
|
return { todo: await service.remove(body.id, { by: 'human' }) }
|
|
672
719
|
}),
|
|
673
720
|
|
|
721
|
+
exact('POST', `${ROUTE_BASE}/relink`, async (request) => {
|
|
722
|
+
// Re-pointing a dead attachment: the byte-free equivalent of "upload it again".
|
|
723
|
+
const body = await readJsonBody(request)
|
|
724
|
+
if (typeof body.id !== 'string' || body.id.trim() === '') {
|
|
725
|
+
throw new TodoError('invalid-body', 'relink 需要 id')
|
|
726
|
+
}
|
|
727
|
+
const index = Number(body.index)
|
|
728
|
+
if (!Number.isInteger(index) || index < 0) {
|
|
729
|
+
throw new TodoError('invalid-body', 'relink 需要 index(附件在列表里的位置)')
|
|
730
|
+
}
|
|
731
|
+
const todo = await service.get(body.id)
|
|
732
|
+
const link = (todo.links ?? [])[index]
|
|
733
|
+
if (link === undefined) throw new TodoError('not-found', '这条待办没有这个附件')
|
|
734
|
+
if (typeof body.value !== 'string' || body.value.trim() === '') {
|
|
735
|
+
throw new TodoError('invalid-body', 'relink 需要新的 value(绝对路径或网址)')
|
|
736
|
+
}
|
|
737
|
+
const links = todo.links.slice()
|
|
738
|
+
links[index] = { kind: link.kind, value: body.value.trim(), ...(link.title === '' ? {} : { title: link.title }) }
|
|
739
|
+
// Same shape as /update: the service already answers `{ todo, changed }`.
|
|
740
|
+
return service.update(body.id, { links }, { by: 'human' })
|
|
741
|
+
}),
|
|
742
|
+
|
|
674
743
|
exact('POST', `${ROUTE_BASE}/restore`, async (request) => {
|
|
675
744
|
// The panel's undo: put a removed record back verbatim, timestamps and
|
|
676
745
|
// attachments included, instead of re-creating it as a new todo.
|
package/lib/service.js
CHANGED
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
newId,
|
|
29
29
|
normalizeActor,
|
|
30
30
|
normalizeStored,
|
|
31
|
+
parseDueText,
|
|
31
32
|
pushActivity,
|
|
32
33
|
recordDispatch as recordDispatchOn,
|
|
33
34
|
removeLink as removeLinkFrom,
|
|
@@ -132,8 +133,21 @@ export function createService(options) {
|
|
|
132
133
|
* @param {string} raw
|
|
133
134
|
* @returns {{ text: string[], tags: string[], levels: string[], flags: string[] }}
|
|
134
135
|
*/
|
|
136
|
+
/**
|
|
137
|
+
* A due-date bound from a `before:` / `after:` token.
|
|
138
|
+
*
|
|
139
|
+
* Only the explicit deadline shapes are accepted (date, date+time), because a search bound
|
|
140
|
+
* that silently became New Year's Day would filter the list without saying so.
|
|
141
|
+
*
|
|
142
|
+
* @param {string} text e.g. `2026-11-01` or `2026-11-01T18:00`
|
|
143
|
+
* @returns {number | null} epoch millis, or null when the token is not a date bound
|
|
144
|
+
*/
|
|
145
|
+
function dueBound(text) {
|
|
146
|
+
const verdict = parseDueText(text)
|
|
147
|
+
return verdict.ok === true ? Date.parse(verdict.iso) : null
|
|
148
|
+
}
|
|
135
149
|
function parseQuery(raw) {
|
|
136
|
-
const parsed = { text: [], tags: [], levels: [], flags: [] }
|
|
150
|
+
const parsed = { text: [], tags: [], levels: [], flags: [], before: null, after: null }
|
|
137
151
|
for (const token of String(raw).split(/\s+/)) {
|
|
138
152
|
if (token === '') continue
|
|
139
153
|
const lower = token.toLowerCase()
|
|
@@ -142,6 +156,17 @@ export function createService(options) {
|
|
|
142
156
|
else if (lower.startsWith('level:') && lower.length > 6) {
|
|
143
157
|
const value = lower.slice(6)
|
|
144
158
|
parsed.levels.push(LEVEL_ALIAS[value] ?? value)
|
|
159
|
+
} else if (lower.startsWith('before:') && lower.length > 7) {
|
|
160
|
+
// A partial date is what a search box contains while it is being typed; turning that
|
|
161
|
+
// into an error would make the list flicker red on every keystroke. An unparseable
|
|
162
|
+
// bound is therefore ignored, and the token stays a plain search term.
|
|
163
|
+
const bound = dueBound(lower.slice(7))
|
|
164
|
+
if (bound === null) parsed.text.push(lower)
|
|
165
|
+
else parsed.before = bound
|
|
166
|
+
} else if (lower.startsWith('after:') && lower.length > 6) {
|
|
167
|
+
const bound = dueBound(lower.slice(6))
|
|
168
|
+
if (bound === null) parsed.text.push(lower)
|
|
169
|
+
else parsed.after = bound
|
|
145
170
|
} else if (lower.startsWith('is:') && lower.length > 3) parsed.flags.push(lower)
|
|
146
171
|
else if (lower.startsWith('has:') && lower.length > 4) parsed.flags.push(lower)
|
|
147
172
|
else parsed.text.push(lower)
|
|
@@ -162,6 +187,14 @@ export function createService(options) {
|
|
|
162
187
|
for (const level of parsed.levels) {
|
|
163
188
|
if (todo.priority !== level) return false
|
|
164
189
|
}
|
|
190
|
+
// A date bound is about the deadline. A todo without one cannot satisfy either bound:
|
|
191
|
+
// "what is due before Friday" has no meaningful answer for something with no deadline.
|
|
192
|
+
if (parsed.before !== null || parsed.after !== null) {
|
|
193
|
+
const due = typeof todo.dueAt === 'string' ? Date.parse(todo.dueAt) : Number.NaN
|
|
194
|
+
if (!Number.isFinite(due)) return false
|
|
195
|
+
if (parsed.before !== null && due >= parsed.before) return false
|
|
196
|
+
if (parsed.after !== null && due <= parsed.after) return false
|
|
197
|
+
}
|
|
165
198
|
for (const flag of parsed.flags) {
|
|
166
199
|
if (flag === 'is:overdue' && !isOverdue(todo, at)) return false
|
|
167
200
|
if (flag === 'is:due' && (todo.dueAt === null || todo.dueAt === undefined)) return false
|
|
@@ -437,7 +470,9 @@ export function createService(options) {
|
|
|
437
470
|
return {
|
|
438
471
|
...todo,
|
|
439
472
|
links: todo.links.map((link) => {
|
|
440
|
-
|
|
473
|
+
// A dead image must be flagged exactly like a dead document: the panel would
|
|
474
|
+
// otherwise render a broken frame instead of the existing missing-file chip.
|
|
475
|
+
if (link.kind !== 'file' && link.kind !== 'image') return { ...link }
|
|
441
476
|
let present = true
|
|
442
477
|
try {
|
|
443
478
|
present = check(link.value) !== false
|
package/lib/tools.js
CHANGED
|
@@ -52,14 +52,14 @@ const STEPS_SHAPE = {
|
|
|
52
52
|
const LINKS_INPUT = {
|
|
53
53
|
type: 'array',
|
|
54
54
|
description:
|
|
55
|
-
'相关文档与地址。字符串写法按内容自动判别:http(s):// 开头算地址,其余必须是以 / 或盘符开头的绝对路径(文档原地引用,不复制内容)。也可给对象 { kind: "url"|"file", value, title }
|
|
55
|
+
'相关文档与地址。字符串写法按内容自动判别:http(s):// 开头算地址,其余必须是以 / 或盘符开头的绝对路径(文档原地引用,不复制内容)。也可给对象 { kind: "url"|"file"|"image", value, title };kind:"image" 用于截图,必须是图片路径(png/jpg/jpeg/webp/gif/avif),面板会显示缩略图。',
|
|
56
56
|
items: {
|
|
57
57
|
oneOf: [
|
|
58
58
|
{ type: 'string' },
|
|
59
59
|
{
|
|
60
60
|
type: 'object',
|
|
61
61
|
properties: {
|
|
62
|
-
kind: { type: 'string', enum: ['url', 'file'] },
|
|
62
|
+
kind: { type: 'string', enum: ['url', 'file', 'image'] },
|
|
63
63
|
value: { type: 'string' },
|
|
64
64
|
title: { type: 'string' },
|
|
65
65
|
},
|
|
@@ -136,7 +136,7 @@ const TODO_SHAPE = { type: 'object',
|
|
|
136
136
|
items: {
|
|
137
137
|
type: 'object',
|
|
138
138
|
properties: {
|
|
139
|
-
kind: { type: 'string', enum: ['url', 'file'] },
|
|
139
|
+
kind: { type: 'string', enum: ['url', 'file', 'image'] },
|
|
140
140
|
value: { type: 'string' },
|
|
141
141
|
title: { type: 'string' },
|
|
142
142
|
},
|
|
@@ -815,7 +815,7 @@ export function createTools(service, options = {}) {
|
|
|
815
815
|
items: {
|
|
816
816
|
type: 'object',
|
|
817
817
|
properties: {
|
|
818
|
-
kind: { type: 'string', enum: ['url', 'file'] },
|
|
818
|
+
kind: { type: 'string', enum: ['url', 'file', 'image'] },
|
|
819
819
|
value: { type: 'string' },
|
|
820
820
|
title: { type: 'string' },
|
|
821
821
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-global-todo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"icon": "./docs/icon.svg",
|
|
5
5
|
"description": "Cross-session todo list for DeepSeek Harness: one JSON file a person and an agent both write to, with step plans that ship as a numbered hand-off, session read-back, and a human confirmation before an agent's \"done\" counts.",
|
|
6
6
|
"type": "module",
|