dsh-global-todo 0.4.2 → 0.4.4
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 +21 -0
- package/docs/DESIGN.md +19 -0
- package/docs/ROADMAP.md +7 -0
- package/docs/screenshots/panel-detail.png +0 -0
- package/docs/screenshots/panel-select.png +0 -0
- package/docs/screenshots/panel.png +0 -0
- package/lib/client.js +29 -4
- package/lib/index.js +1 -1
- package/lib/store.js +62 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.4.4] — 2026-10-19
|
|
11
|
+
|
|
12
|
+
Opening a todo on a wide screen used to push the list down and leave half the window unused.
|
|
13
|
+
|
|
14
|
+
- **The open row's detail is now a card beside the list**, above a 1100px viewport: it sits at
|
|
15
|
+
the panel's right edge, the list keeps its own column, and the card scrolls on its own. Narrow
|
|
16
|
+
windows keep the familiar inline expansion. The change is CSS-only over the existing markup —
|
|
17
|
+
`.gt-root[data-wide-detail]` plus one media query — so there is still exactly one detail
|
|
18
|
+
implementation; a second wide-screen render path would drift from the inline one at the first
|
|
19
|
+
field change.
|
|
20
|
+
## [0.4.3] — 2026-10-19
|
|
21
|
+
|
|
22
|
+
The list is meant to live in a synced folder; a change from another machine should not be
|
|
23
|
+
something you find out about a day later.
|
|
24
|
+
|
|
25
|
+
- **A file changed elsewhere is counted and reported.** The store keeps an `mtimeMs:size`
|
|
26
|
+
fingerprint of the file as this process last read or wrote it; a read that disagrees with it
|
|
27
|
+
increments `externalChanges` (once per change, not once per poll) and records
|
|
28
|
+
`lastExternalAt`, both surfaced through `/health.store`. The panel turns a new count into a
|
|
29
|
+
one-line notice. Reads still trust a file that parses — this process cannot merge two writers'
|
|
30
|
+
intent — but the person can see that it happened.
|
|
10
31
|
## [0.4.2] — 2026-10-19
|
|
11
32
|
|
|
12
33
|
Noise is the failure mode that matters for a list other people are asked to trust.
|
package/docs/DESIGN.md
CHANGED
|
@@ -57,6 +57,25 @@ $DSH_HOME/global-todo/todos.json ← { version: 1, todos: [ … ] }
|
|
|
57
57
|
version, and a future migration step would run before the first write, keeping the old
|
|
58
58
|
file as a backup.
|
|
59
59
|
|
|
60
|
+
## On a wide window, the detail stands beside the list
|
|
61
|
+
|
|
62
|
+
Expanding a row inline is right on a narrow window and wrong on a wide one: the list jumps, the
|
|
63
|
+
row you were reading slides away, and the rest of the screen sits empty. Above 1100px the open
|
|
64
|
+
row's detail is lifted out of the flow into a card at the panel's right edge, and the list gets
|
|
65
|
+
right padding equal to that card. This is a CSS-only override over unchanged markup
|
|
66
|
+
(`.gt-root[data-wide-detail]` plus one media query) precisely so there is still exactly one
|
|
67
|
+
detail implementation — a second render path for wide screens would drift from the inline one at
|
|
68
|
+
the first field change.
|
|
69
|
+
## A shared file changes without asking
|
|
70
|
+
|
|
71
|
+
The list is deliberately portable — a notes repo, a synced folder, a NAS — so another machine
|
|
72
|
+
writing the same file is a normal event. It must not be a silent one either: the store keeps a
|
|
73
|
+
`mtimeMs:size` fingerprint of the file as it last read or wrote it, and a read that disagrees
|
|
74
|
+
means somebody else changed it. That is counted once per change (not once per poll, which would
|
|
75
|
+
be noise) and exported through `describe()` as `externalChanges` / `lastExternalAt`; the panel
|
|
76
|
+
turns a new count into a one-line notice. `store.read` still trusts a file that parses, because
|
|
77
|
+
**this process** has no way to merge two writers' intent — but the person can now see that it
|
|
78
|
+
happened instead of discovering it a day later.
|
|
60
79
|
## Attachments: references, not uploads
|
|
61
80
|
|
|
62
81
|
An attachment is either an address (`http`/`https`) or an absolute path. Rules:
|
package/docs/ROADMAP.md
CHANGED
|
@@ -106,6 +106,13 @@ is archived, and `global_todo_update` finally accepts `status`.
|
|
|
106
106
|
`findDuplicate` (`lib/capture.js`): normalized title against live rows, a close paraphrase inside
|
|
107
107
|
72 hours, and the same errand finished in the last 7 days. The decision is pure, the reason lands
|
|
108
108
|
in `skipped[]`, and archived/cancelled rows deliberately do not block a new one.
|
|
109
|
+
**A change made elsewhere is visible** — the store fingerprints the file on every read/write and
|
|
110
|
+
counts a disagreement once (`externalChanges`, `lastExternalAt` in `lib/store.js`; surfaced by
|
|
111
|
+
`/health.store`). The panel shows a one-line notice instead of letting a synced folder change the
|
|
112
|
+
list silently.
|
|
113
|
+
**Side detail on wide windows** — above 1100px the open row's detail becomes a card at the
|
|
114
|
+
panel's right edge instead of pushing the list down (CSS-only over the same markup; one detail
|
|
115
|
+
implementation), while narrow windows keep the inline expansion.
|
|
109
116
|
### Partly landed
|
|
110
117
|
|
|
111
118
|
- **Batch and multi-select** — the selection mode, its per-row markers and the batch
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/lib/client.js
CHANGED
|
@@ -50,7 +50,7 @@ window.__ModuleLoader__.load({
|
|
|
50
50
|
* different mechanisms (restart vs. hot reload), so "which one am I looking
|
|
51
51
|
* at" needs an answer that the bundle itself carries.
|
|
52
52
|
*/
|
|
53
|
-
const UI_REV = '
|
|
53
|
+
const UI_REV = 'ui27'
|
|
54
54
|
/** Last language resolved for the shell; module-level so a fetch failure can be worded too. */
|
|
55
55
|
let activeLang = 'zh'
|
|
56
56
|
|
|
@@ -134,6 +134,7 @@ window.__ModuleLoader__.load({
|
|
|
134
134
|
'autoCollect.reason.turn': '轮次没有干净结束({kind})',
|
|
135
135
|
'notice.autoCollectOn': '已开启自动获取:一次人为发言最多自动问一次',
|
|
136
136
|
'notice.autoCollectOff': '已关闭自动获取',
|
|
137
|
+
'notice.externalChange': '清单在别处被改过,已载入最新内容',
|
|
137
138
|
'action.exempt': '豁免',
|
|
138
139
|
'action.exemptOn': '已豁免',
|
|
139
140
|
'action.exemptHint': '这个工作区里的对话永不自动获取(再点一下取消)',
|
|
@@ -344,6 +345,7 @@ window.__ModuleLoader__.load({
|
|
|
344
345
|
'autoCollect.reason.turn': 'the turn did not end cleanly ({kind})',
|
|
345
346
|
'notice.autoCollectOn': 'Auto-collect on: at most one question per message from you',
|
|
346
347
|
'notice.autoCollectOff': 'Auto-collect off',
|
|
348
|
+
'notice.externalChange': 'The list changed somewhere else; the latest content is loaded',
|
|
347
349
|
'action.exempt': 'Exempt',
|
|
348
350
|
'action.exemptOn': 'Exempted',
|
|
349
351
|
'action.exemptHint': 'Never auto-collect in this workspace (click again to undo)',
|
|
@@ -1006,6 +1008,19 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
1006
1008
|
.gt-menu button[data-danger="1"] { color: var(--dsw-alias-state-error-primary); }
|
|
1007
1009
|
.gt-detail { display: flex; flex-direction: column; gap: 10px; border-top: 1px solid var(--dsw-alias-border-l1);
|
|
1008
1010
|
padding-top: 10px; margin-top: 2px; }
|
|
1011
|
+
/* Wide screens: the open row's detail becomes a card on the right instead of pushing the list
|
|
1012
|
+
down. Deliberately CSS-only over unchanged markup: one detail implementation, and it only
|
|
1013
|
+
applies where there is room for a second column. */
|
|
1014
|
+
@media (min-width: 1100px) {
|
|
1015
|
+
.gt-root[data-wide-detail="1"] .gt-inner { padding-right: 408px; }
|
|
1016
|
+
.gt-root[data-wide-detail="1"] .gt-item[data-open="1"] .gt-detail {
|
|
1017
|
+
/* Follow the panel's own right edge (it is centred and capped at 980px) instead of the
|
|
1018
|
+
viewport's, so the card stays attached to the list on ultra-wide screens. */
|
|
1019
|
+
position: fixed; top: 96px; right: max(24px, calc(50vw - 466px)); width: 360px; max-height: calc(100vh - 140px);
|
|
1020
|
+
overflow: auto; padding: 16px; margin: 0; border: 1px solid var(--dsw-alias-border-l2);
|
|
1021
|
+
border-radius: 12px; background: var(--dsw-alias-bg-overlay); z-index: 30;
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1009
1024
|
.gt-section { display: flex; flex-direction: column; gap: 5px; }
|
|
1010
1025
|
.gt-completion { white-space: pre-wrap; word-break: break-word; line-height: 1.5; }
|
|
1011
1026
|
.gt-section-title { color: var(--dsw-alias-label-secondary); font-size: 11.5px; text-transform: uppercase;
|
|
@@ -2125,6 +2140,9 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
2125
2140
|
: []
|
|
2126
2141
|
const conversations = [...pinnedEntry, ...labelled]
|
|
2127
2142
|
const mounted = useRef(true)
|
|
2143
|
+
// The store counts file changes made outside this process (another machine, a sync
|
|
2144
|
+
// client). A new count means the panel's view was stale a moment ago; say so once.
|
|
2145
|
+
const externalRef = useRef(null)
|
|
2128
2146
|
const titleRef = useRef(null)
|
|
2129
2147
|
const searchRef = useRef(null)
|
|
2130
2148
|
const undoTimer = useRef(null)
|
|
@@ -2165,11 +2183,18 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
2165
2183
|
const refreshHealth = useCallback(async () => {
|
|
2166
2184
|
try {
|
|
2167
2185
|
const data = await call('health')
|
|
2168
|
-
if (mounted.current)
|
|
2186
|
+
if (!mounted.current) return
|
|
2187
|
+
const external = Number(data?.store?.externalChanges) || 0
|
|
2188
|
+
if (externalRef.current !== null && external > externalRef.current) {
|
|
2189
|
+
setNotice({ text: translate(lang, 'notice.externalChange'), sessionId: '' })
|
|
2190
|
+
setTimeout(() => setNotice(null), 8000)
|
|
2191
|
+
}
|
|
2192
|
+
externalRef.current = external
|
|
2193
|
+
setState((current) => ({ ...current, health: data }))
|
|
2169
2194
|
} catch {
|
|
2170
2195
|
/* the health mirror is not worth an error banner */
|
|
2171
2196
|
}
|
|
2172
|
-
}, [])
|
|
2197
|
+
}, [lang])
|
|
2173
2198
|
|
|
2174
2199
|
useEffect(() => {
|
|
2175
2200
|
mounted.current = true
|
|
@@ -2748,7 +2773,7 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
2748
2773
|
|
|
2749
2774
|
return h(
|
|
2750
2775
|
'div',
|
|
2751
|
-
{ className: 'gt-root' },
|
|
2776
|
+
{ className: 'gt-root', 'data-wide-detail': expandedId === '' ? '0' : '1' },
|
|
2752
2777
|
h(
|
|
2753
2778
|
'div',
|
|
2754
2779
|
{ className: 'gt-inner' },
|
package/lib/index.js
CHANGED
|
@@ -35,7 +35,7 @@ import { createStore, resolveStorePath } from './store.js'
|
|
|
35
35
|
import { createTools } from './tools.js'
|
|
36
36
|
|
|
37
37
|
/** Must equal `package.json`'s version; `npm run check` enforces that. */
|
|
38
|
-
export const VERSION = '0.4.
|
|
38
|
+
export const VERSION = '0.4.4'
|
|
39
39
|
|
|
40
40
|
/** Plugin id. Also the Loader row id in `cordis.patch.yml`. */
|
|
41
41
|
export const name = 'dsh-global-todo'
|
package/lib/store.js
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* @module dsh-global-todo/store
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
-
import { open, mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
|
22
|
+
import { open, mkdir, readFile, rename, stat, writeFile } from 'node:fs/promises'
|
|
23
23
|
import { homedir } from 'node:os'
|
|
24
24
|
import path from 'node:path'
|
|
25
25
|
|
|
@@ -79,6 +79,46 @@ export function createStore(options) {
|
|
|
79
79
|
/** Serializes every mutation; also lets `update` await the writes before it. */
|
|
80
80
|
let chain = Promise.resolve()
|
|
81
81
|
|
|
82
|
+
/**
|
|
83
|
+
* Fingerprint of the file as this process last read or wrote it.
|
|
84
|
+
*
|
|
85
|
+
* The file is meant to be portable — a synced folder, a NAS — so another machine changing it
|
|
86
|
+
* is a normal event, not an error. It must not be silent either: a read that sees a different
|
|
87
|
+
* `mtimeMs:size` than the last one this process established is reported through `describe()`
|
|
88
|
+
* so the panel can say the list changed elsewhere. One increment per external change, not per
|
|
89
|
+
* poll, which is why `known` is updated as the change is noticed.
|
|
90
|
+
*/
|
|
91
|
+
let known = null
|
|
92
|
+
let externalChanges = 0
|
|
93
|
+
let lastExternalAt = null
|
|
94
|
+
|
|
95
|
+
/** @param {{ mtimeMs: number, size: number } | null} info */
|
|
96
|
+
function remember(info) {
|
|
97
|
+
if (info === null) {
|
|
98
|
+
known = null
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
known = `${info.mtimeMs}:${info.size}`
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Note a read that disagrees with what this process last saw. */
|
|
105
|
+
function observe(info) {
|
|
106
|
+
if (info === null) {
|
|
107
|
+
if (known !== null) {
|
|
108
|
+
externalChanges += 1
|
|
109
|
+
lastExternalAt = now()
|
|
110
|
+
}
|
|
111
|
+
known = null
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
const fingerprint = `${info.mtimeMs}:${info.size}`
|
|
115
|
+
if (known !== null && known !== fingerprint) {
|
|
116
|
+
externalChanges += 1
|
|
117
|
+
lastExternalAt = now()
|
|
118
|
+
}
|
|
119
|
+
known = fingerprint
|
|
120
|
+
}
|
|
121
|
+
|
|
82
122
|
/**
|
|
83
123
|
* Read the document. A missing file is an empty list; an unparseable file is
|
|
84
124
|
* an error, never a silent reset — overwriting a mangled-but-recoverable file
|
|
@@ -91,9 +131,15 @@ export function createStore(options) {
|
|
|
91
131
|
try {
|
|
92
132
|
text = await readFile(file, 'utf8')
|
|
93
133
|
} catch (error) {
|
|
94
|
-
if (error?.code === 'ENOENT')
|
|
134
|
+
if (error?.code === 'ENOENT') {
|
|
135
|
+
// The file this process knew about is gone: somebody else removed or moved it.
|
|
136
|
+
observe(null)
|
|
137
|
+
return { version: SCHEMA_VERSION, todos: [] }
|
|
138
|
+
}
|
|
95
139
|
throw new TodoError('store-unwritable', `读不到待办文件 ${file}:${error?.message ?? error}`)
|
|
96
140
|
}
|
|
141
|
+
// Compare before parsing: a corrupt write from elsewhere is still a change worth naming.
|
|
142
|
+
observe(await stat(file).catch(() => null))
|
|
97
143
|
if (text.trim() === '') return { version: SCHEMA_VERSION, todos: [] }
|
|
98
144
|
|
|
99
145
|
let parsed
|
|
@@ -147,6 +193,8 @@ export function createStore(options) {
|
|
|
147
193
|
} catch {
|
|
148
194
|
/* ignore */
|
|
149
195
|
}
|
|
196
|
+
// This write is the new baseline, so the next read does not report our own change.
|
|
197
|
+
remember(await stat(file).catch(() => null))
|
|
150
198
|
}
|
|
151
199
|
|
|
152
200
|
/**
|
|
@@ -206,7 +254,18 @@ export function createStore(options) {
|
|
|
206
254
|
logger?.warn?.(`dsh-global-todo: 无法统计 ${file}:${error?.message ?? error}`)
|
|
207
255
|
}
|
|
208
256
|
}
|
|
209
|
-
return {
|
|
257
|
+
return {
|
|
258
|
+
kind: 'json',
|
|
259
|
+
file,
|
|
260
|
+
exists,
|
|
261
|
+
bytes,
|
|
262
|
+
todos,
|
|
263
|
+
schemaVersion: SCHEMA_VERSION,
|
|
264
|
+
// How many times the file changed under this process (another machine, a sync client).
|
|
265
|
+
// In memory: it describes this process, and a restart is entitled to forget it.
|
|
266
|
+
externalChanges,
|
|
267
|
+
lastExternalAt,
|
|
268
|
+
}
|
|
210
269
|
}
|
|
211
270
|
|
|
212
271
|
return { kind: 'json', file, read, write, update, all, describe }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-global-todo",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.4",
|
|
4
4
|
"icon": "./docs/icon.svg",
|
|
5
5
|
"description": "Global, cross-session todos for DeepSeek Harness: one list owned by the human and the agent together, with creation time, attached documents and links, and one-click hand-off to an agent session. 全局待办:跨会话一份清单,记录加入时间,可挂文档与地址,可一键交给 Agent 继续干。",
|
|
6
6
|
"type": "module",
|