dsh-global-todo 0.6.3 → 0.6.5

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,3 +1,32 @@
1
+ ## [0.6.5] — 2026-10-20
2
+
3
+ The three loose ends the spec called out but the earlier passes left behind.
4
+
5
+ - **Five type sizes, for real.** 12.5/11.5/10.5 collapse into 12 and 11, so the panel now uses
6
+ exactly 20 / 14 / 13 / 12 / 11 — one ladder instead of eight near-identical steps.
7
+ - **Group actions move behind one kebab.** `Collect` and `Exempt` stop competing with the group
8
+ title; an exemption keeps a small always-visible mute mark (it is a state, not an action) and
9
+ the menu states both intents in words (`Collect from this conversation`, `Mute this workspace`).
10
+ - **Escape closes the innermost layer.** Settings, then the add form, then the open row — and a
11
+ click outside the settings card dismisses it, matching every other popover. The gear toggles.
12
+
13
+ ## [0.6.4] — 2026-10-20
14
+
15
+ The settings panel, rebuilt as an inspector instead of a stack of labelled rows.
16
+
17
+ - **Header and footer, finally.** A title row with a close button, three labelled groups divided
18
+ by hairlines, and a save row pinned at the bottom — the token field no longer pushes the save
19
+ buttons into the middle of the form with a field stranded beneath them.
20
+ - **Every field says what it does.** Help text sits under each label (`settings.*Hint`) rather than
21
+ being implied by the label; the label-and-control grid keeps number inputs aligned at a fixed
22
+ 76px right-aligned box.
23
+ - **Ingest fields dim when ingest is off**, and `New token` becomes a quiet text link on the token
24
+ label row (`Rotate token`) instead of a third equal-weight button.
25
+ - **About section** shows the data file and version on labelled rows; the card sizes to its content
26
+ and scrolls internally when the window is short.
27
+ - `npm run screenshots` now also photographs a settings variant, so the README image is
28
+ reproducible instead of hand-made.
29
+
1
30
  ## [0.6.3] — 2026-10-20
2
31
 
3
32
  States that were still speaking to nobody.
package/README.md CHANGED
@@ -33,6 +33,8 @@ one record:
33
33
 
34
34
  ![One row opened: inline editing, attachments with 打开 / 复制路径, activity](docs/screenshots/panel-detail.png)
35
35
 
36
+ ![Settings: grouped fields, hints under each label, one save row](docs/screenshots/panel-settings.png)
37
+
36
38
  These are produced by `npm run screenshots` (which chains `npm run preview`): the real client
37
39
  half is rendered to static HTML with no Host and no dependencies, then photographed at 1280×720
38
40
  into `docs/screenshots/`. Playwright is not a dependency — the tool borrows the copy a DSH
@@ -339,7 +341,7 @@ npm run preview # render the panel to preview/panel.html (+ detail and sel
339
341
  npm run screenshots # re-photograph those previews into docs/screenshots/, 1280×720
340
342
  ```
341
343
 
342
- The three PNGs under `docs/screenshots/` ship with the package and are embedded in both
344
+ The PNGs under `docs/screenshots/` ship with the package and are embedded in both
343
345
  READMEs, so re-run `npm run screenshots` whenever the panel UI changes.
344
346
 
345
347
  ```text
package/README.zh.md CHANGED
@@ -30,6 +30,8 @@ DSH 自带的 `todo_write` 属于**某一个 agent 会话**:整表替换、三
30
30
 
31
31
  ![展开一条:就地编辑、附件带「打开 / 复制路径」、动态轨迹](docs/screenshots/panel-detail.png)
32
32
 
33
+ ![设置:分组字段、说明写在标签下方、底部一行保存](docs/screenshots/panel-settings.png)
34
+
33
35
  图都由 `npm run screenshots`(内部串起 `npm run preview`)生成:先把真实的客户端半区渲染成静态
34
36
  HTML(不依赖宿主、零依赖),再以 1280×720 拍进 `docs/screenshots/`。Playwright 不是依赖——工具
35
37
  借的是 DSH profile 里已经装好的那份,或 `DSH_PLAYWRIGHT` 指向的那份。
@@ -230,7 +232,7 @@ npm run preview # 把面板渲染成 preview/panel.html(另附详情态
230
232
  npm run screenshots # 把这些预览重新拍成 docs/screenshots/,1280×720
231
233
  ```
232
234
 
233
- `docs/screenshots/` 下那三张 PNG 会随包发布、并嵌在两份 README 里,所以面板 UI 一改就要重跑
235
+ `docs/screenshots/` 下那几张 PNG 会随包发布、并嵌在两份 README 里,所以面板 UI 一改就要重跑
234
236
  `npm run screenshots`。
235
237
 
236
238
  ```text
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 = 'ui39'
53
+ const UI_REV = 'ui41'
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
 
@@ -100,12 +100,22 @@ window.__ModuleLoader__.load({
100
100
  'settings.ingestEnable': '启用外部写入',
101
101
  'settings.token': '访问 token',
102
102
  'settings.tokenSet': '已设置,留空保持不变',
103
- 'settings.generate': '生成新 token',
104
- 'settings.confirmBelow': '低于此置信度进收件箱',
103
+ 'settings.generate': '轮换 token',
104
+ 'settings.confirmBelow': '置信度阈值',
105
105
  'settings.save': '保存',
106
106
  'settings.cancel': '取消',
107
107
  'settings.saved': '设置已保存',
108
108
  'settings.about': '关于',
109
+ 'group.actions': '分组操作',
110
+ 'group.capture': '从本对话抓取',
111
+ 'group.exemptOn': '本工作区免打扰',
112
+ 'group.exemptOff': '取消本工作区免打扰',
113
+ 'settings.remindersHint': '到期时在面板顶部提示,并按设置提前。',
114
+ 'settings.ingestHint': '允许其他工具带 token 写入,先进收件箱。',
115
+ 'settings.tokenHint': '留空表示不改动;轮换后旧 token 立刻失效。',
116
+ 'settings.confirmBelowHint': '置信度低于这个值的条目进收件箱,不进清单。',
117
+ 'settings.storeFile': '数据文件',
118
+ 'settings.version': '版本',
109
119
  'inbox.explain': '以下由外部写入或 Agent 提交,确认后才进入清单。',
110
120
  'notice.exported': '已导出 {n} 条待办',
111
121
  'notice.imported': '已导入 {n} 条(合并,现有共 {total} 条)',
@@ -347,12 +357,22 @@ window.__ModuleLoader__.load({
347
357
  'settings.ingestEnable': 'Accept external writes',
348
358
  'settings.token': 'Access token',
349
359
  'settings.tokenSet': 'Set — leave blank to keep',
350
- 'settings.generate': 'New token',
351
- 'settings.confirmBelow': 'Below this confidence → inbox',
360
+ 'settings.generate': 'Rotate token',
361
+ 'settings.confirmBelow': 'Confidence threshold',
352
362
  'settings.save': 'Save',
353
363
  'settings.cancel': 'Cancel',
354
364
  'settings.saved': 'Settings saved',
355
365
  'settings.about': 'About',
366
+ 'group.actions': 'Group actions',
367
+ 'group.capture': 'Collect from this conversation',
368
+ 'group.exemptOn': 'Mute this workspace',
369
+ 'group.exemptOff': 'Unmute this workspace',
370
+ 'settings.remindersHint': 'Flag a deadline at the top of the panel when it comes due.',
371
+ 'settings.ingestHint': 'Let other tools POST todos with a token; they start in the inbox.',
372
+ 'settings.tokenHint': 'Leave blank to keep the current one; rotating invalidates the old token.',
373
+ 'settings.confirmBelowHint': 'Items below this confidence go to the inbox instead of the list.',
374
+ 'settings.storeFile': 'Data file',
375
+ 'settings.version': 'Version',
356
376
  'inbox.explain': 'Written by external sources or the agent. Confirm it before it joins the list.',
357
377
  'notice.exported': 'Exported {n} todos',
358
378
  'notice.imported': 'Imported {n} (merged; {total} in the list now)',
@@ -971,7 +991,7 @@ window.__ModuleLoader__.load({
971
991
  font: inherit; font-size: 13px; padding: 0; min-width: 0; }
972
992
  .gt-search input:focus { outline: none; }
973
993
  .gt-search:focus-within { border-color: var(--dsw-alias-brand-primary); }
974
- .gt-kbd { font-size: 10.5px; border: 1px solid var(--dsw-alias-border-l1); border-radius: 4px;
994
+ .gt-kbd { font-size: 11px; border: 1px solid var(--dsw-alias-border-l1); border-radius: 4px;
975
995
  padding: 0 4px; color: var(--dsw-alias-label-secondary); }
976
996
  .gt-dir { display: inline-flex; align-items: center; gap: 5px; font-size: 12px;
977
997
  color: var(--dsw-alias-label-secondary); border: 1px solid var(--dsw-alias-border-l1);
@@ -1002,7 +1022,7 @@ window.__ModuleLoader__.load({
1002
1022
  the two toggles. Chips stay for facts about a todo (tags, levels, notes). */
1003
1023
  .gt-tabs { display: inline-flex; gap: 2px; padding: 2px; border-radius: 8px; background: var(--dsw-alias-bg-layer-2); }
1004
1024
  .gt-tab { border: none; background: transparent; color: var(--dsw-alias-label-secondary); font: inherit;
1005
- font-size: 12.5px; border-radius: 6px; padding: 0 10px; min-height: 24px; box-sizing: border-box;
1025
+ font-size: 12px; border-radius: 6px; padding: 0 10px; min-height: 24px; box-sizing: border-box;
1006
1026
  cursor: pointer; white-space: nowrap; }
1007
1027
  .gt-tab:hover { color: var(--dsw-alias-label-primary); }
1008
1028
  .gt-tab[data-on="1"] { background: var(--dsw-alias-button-ghost-active-fill); color: var(--dsw-alias-label-primary); font-weight: 600; }
@@ -1035,29 +1055,26 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
1035
1055
  display: inline-flex; align-items: center; gap: 6px; font: inherit; padding: 2px 0; }
1036
1056
  .gt-group-head svg { transition: transform .12s ease; transform: rotate(90deg); }
1037
1057
  .gt-group[data-collapsed="1"] .gt-group-head svg { transform: rotate(0deg); }
1038
- .gt-group-action { margin-left: 4px; border: 1px solid transparent; background: transparent; color: inherit;
1039
- border-radius: 999px; padding: 3px 8px; min-height: 24px; box-sizing: border-box; font: inherit;
1040
- font-size: 0; letter-spacing: .04em; cursor: pointer; display: inline-flex; align-items: center; gap: 5px;
1041
- opacity: .72; transition: opacity .12s ease; }
1042
- .gt-group:hover .gt-group-action, .gt-group-action:focus-visible { font-size: 11px; }
1043
- .gt-group:hover .gt-group-action, .gt-group-action:focus-visible { opacity: 1; }
1044
- .gt-group-action:hover { color: var(--dsw-alias-label-primary); border-color: var(--dsw-alias-border-l2); }
1045
- /* An exemption is a state, not an action: it stays visible and reads as "on" without a hover. */
1046
- .gt-group-action[data-on="1"] { opacity: 1; color: var(--dsw-alias-label-primary);
1047
- border-color: var(--dsw-alias-border-l2); }
1058
+ .gt-group-flag { margin-left: 2px; display: inline-flex; align-items: center; color: var(--dsw-alias-label-secondary); }
1059
+ /* The group kebab stays quiet until the row is hovered or focused. */
1060
+ .gt-group-head .gt-iconbtn { margin-left: 2px; min-width: 24px; min-height: 24px; opacity: 0;
1061
+ transition: opacity .12s ease; }
1062
+ .gt-group:hover .gt-group-head .gt-iconbtn, .gt-group-head .gt-iconbtn:focus-visible,
1063
+ .gt-group-head .gt-iconbtn[aria-expanded="true"] { opacity: .72; }
1064
+ .gt-group-head .gt-iconbtn:hover { opacity: 1; }
1048
1065
  .gt-group-count { color: var(--dsw-alias-label-secondary); font-size: 12px; font-weight: 400; }
1049
1066
  /* A group's level is redundant while it is expanded — the rows show it. */
1050
1067
  .gt-group:not([data-collapsed="1"]) .gt-group-head .gt-level { display: none; }
1051
1068
  .gt-group-label { color: var(--dsw-alias-label-primary); font-weight: 600; font-size: 12px;
1052
1069
  line-height: 18px; max-width: 480px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1053
- .gt-level { display: inline-flex; align-items: center; gap: 3px; font-size: 11.5px; padding: 0;
1070
+ .gt-level { display: inline-flex; align-items: center; gap: 3px; font-size: 11px; padding: 0;
1054
1071
  border: none; color: var(--dsw-alias-label-secondary); }
1055
1072
  .gt-level[data-level="urgent"] { color: var(--dsw-alias-state-error-primary); border-color: var(--dsw-alias-state-error-primary); font-weight: 600; }
1056
1073
  .gt-level[data-level="high"] { color: var(--dsw-alias-state-warn-primary); border-color: var(--dsw-alias-state-warn-primary); }
1057
1074
  .gt-level[data-level="low"] { color: var(--dsw-alias-label-secondary); }
1058
1075
  .gt-dot { width: 6px; height: 6px; border-radius: 999px; background: var(--dsw-alias-state-success-primary);
1059
1076
  display: inline-block; }
1060
- .gt-here { font-size: 10.5px; padding: 0 5px; border-radius: 999px; border: 1px solid var(--dsw-alias-brand-primary);
1077
+ .gt-here { font-size: 11px; padding: 0 5px; border-radius: 999px; border: 1px solid var(--dsw-alias-brand-primary);
1061
1078
  color: var(--dsw-alias-brand-primary); }
1062
1079
  .gt-list { display: flex; flex-direction: column; gap: 8px; }
1063
1080
  .gt-item { position: relative; display: flex; flex-direction: column; gap: 8px;
@@ -1090,7 +1107,7 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
1090
1107
  .gt-item-title { font-weight: 600; font-size: 14px; word-break: break-word; line-height: 22px;
1091
1108
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
1092
1109
  .gt-input-xs { width: 64px; flex: 0 0 auto; min-width: 0; padding: 0 8px; min-height: 28px;
1093
- box-sizing: border-box; font-size: 12.5px; }
1110
+ box-sizing: border-box; font-size: 12px; }
1094
1111
  .gt-seq { font-variant-numeric: tabular-nums; color: var(--dsw-alias-label-secondary); }
1095
1112
  .gt-reminders { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 8px 12px;
1096
1113
  min-height: 36px; box-sizing: border-box; border: 1px solid var(--dsw-alias-border-l1);
@@ -1102,20 +1119,45 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
1102
1119
  border-radius: 6px; cursor: pointer; max-width: 300px; overflow: hidden; text-overflow: ellipsis;
1103
1120
  white-space: nowrap; }
1104
1121
  .gt-reminder-item:hover { background: var(--dsw-alias-button-ghost-active-fill); }
1105
- .gt-settings { display: flex; flex-direction: column; gap: 12px; padding: 16px; width: 340px;
1106
- box-sizing: border-box; border: 1px solid var(--dsw-alias-border-l2); border-radius: 12px;
1107
- background: var(--dsw-alias-bg-overlay); font-size: 13px; line-height: 20px; }
1122
+ .gt-settings { display: flex; flex-direction: column; width: 360px; max-width: 100%;
1123
+ box-sizing: border-box; overflow: hidden; border: 1px solid var(--dsw-alias-border-l2);
1124
+ border-radius: 12px; background: var(--dsw-alias-bg-overlay); font-size: 13px; line-height: 20px; }
1125
+ .gt-settings-head { display: flex; flex: 0 0 auto; align-items: center; justify-content: space-between;
1126
+ gap: 8px; padding: 10px 10px 10px 16px; border-bottom: 1px solid var(--dsw-alias-border-l1); }
1127
+ .gt-settings-title { font-size: 14px; font-weight: 600; }
1128
+ .gt-settings-body { display: flex; flex: 1 1 auto; flex-direction: column; gap: 16px;
1129
+ padding: 14px 16px; overflow: auto; min-height: 0; }
1130
+ .gt-settings-group { display: flex; flex-direction: column; gap: 8px; }
1131
+ .gt-settings-group + .gt-settings-group { border-top: 1px solid var(--dsw-alias-border-l1); padding-top: 14px; }
1132
+ .gt-settings-group-title { font-size: 12px; line-height: 18px; font-weight: 600;
1133
+ color: var(--dsw-alias-label-secondary); }
1134
+ .gt-check-row { display: flex; align-items: flex-start; gap: 8px; cursor: pointer; }
1135
+ .gt-check-row input { margin: 3px 0 0; flex: 0 0 auto; }
1136
+ .gt-check-main { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
1137
+ .gt-check-label { color: var(--dsw-alias-label-primary); }
1138
+ .gt-hint { font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-secondary); }
1139
+ .gt-field { display: flex; flex-direction: column; gap: 6px; }
1140
+ .gt-field[data-muted="1"], .gt-field-row[data-muted="1"] { opacity: .55; }
1141
+ .gt-field-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
1142
+ .gt-field-label { font-size: 13px; color: var(--dsw-alias-label-primary); }
1143
+ .gt-field-row { display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 8px; }
1144
+ .gt-input-num { width: 76px; text-align: right; font-variant-numeric: tabular-nums; }
1145
+ .gt-link-btn { border: none; background: transparent; padding: 0; font: inherit; font-size: 12px;
1146
+ color: var(--dsw-alias-link); cursor: pointer; }
1147
+ .gt-link-btn:disabled { color: var(--dsw-alias-label-secondary); cursor: default; }
1148
+ .gt-about-row { display: grid; grid-template-columns: 88px minmax(0, 1fr); align-items: baseline; gap: 8px; }
1149
+ .gt-settings-foot { display: flex; flex: 0 0 auto; justify-content: flex-end; gap: 8px;
1150
+ padding: 12px 16px; border-top: 1px solid var(--dsw-alias-border-l1); }
1108
1151
  .gt-group-select { display: none; min-height: 28px; max-width: 150px; padding: 0 8px; font: inherit;
1109
- font-size: 12.5px; color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-layer-1);
1152
+ font-size: 12px; color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-layer-1);
1110
1153
  border: 1px solid var(--dsw-alias-border-l1); border-radius: 8px; }
1111
1154
  .gt-root[data-wide="0"] .gt-group-tabs { display: none; }
1112
1155
  .gt-root[data-wide="0"] .gt-group-select { display: inline-flex; align-items: center; }
1113
1156
  .gt-inbox-note { padding: 0 2px 2px; color: var(--dsw-alias-label-secondary); font-size: 13px;
1114
1157
  line-height: 20px; }
1115
- .gt-settings-path { font-size: 11.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1158
+ .gt-settings-path { font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1116
1159
  .gt-settings .gt-input { flex: 0 0 auto; width: 100%; min-height: 32px; }
1117
- .gt-settings .gt-check-line .gt-input { width: 64px; }
1118
- .gt-settings-head { font-weight: 650; font-size: 14px; }
1160
+ .gt-settings .gt-input-num { width: 76px; }
1119
1161
  .gt-check-line { display: flex; align-items: center; gap: 8px; justify-content: flex-start;
1120
1162
  color: var(--dsw-alias-label-primary); }
1121
1163
  .gt-settings-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; justify-content: flex-end; }
@@ -1133,7 +1175,7 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
1133
1175
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1134
1176
  .gt-truncated { color: var(--dsw-alias-state-warn-primary); font-size: 12px; }
1135
1177
  .gt-bulk-tag { flex: 0 1 120px; min-width: 90px; }
1136
- .gt-inbox { margin-left: 6px; padding: 1px 6px; border-radius: 999px; font-size: 10.5px; font-weight: 600;
1178
+ .gt-inbox { margin-left: 6px; padding: 1px 6px; border-radius: 999px; font-size: 11px; font-weight: 600;
1137
1179
  color: var(--dsw-alias-state-warn-primary); border: 1px solid var(--dsw-alias-state-warn-primary);
1138
1180
  vertical-align: 1px; }
1139
1181
  .gt-item[data-status="done"] .gt-item-title, .gt-item[data-status="cancelled"] .gt-item-title {
@@ -1192,17 +1234,17 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
1192
1234
  .gt-link-main { display: flex; align-items: center; gap: 6px; min-width: 0; flex: 1; }
1193
1235
  .gt-note-item { display: flex; flex-direction: column; gap: 4px; padding: 8px 0; }
1194
1236
  .gt-note-item + .gt-note-item { border-top: 1px solid var(--dsw-alias-border-l1); }
1195
- .gt-note-meta { display: flex; align-items: center; gap: 6px; font-size: 11.5px; color: var(--dsw-alias-label-secondary);
1237
+ .gt-note-meta { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--dsw-alias-label-secondary);
1196
1238
  white-space: nowrap; }
1197
1239
  .gt-note-text { font-size: 13px; line-height: 1.6; white-space: pre-wrap; word-break: break-word; max-width: 68ch; }
1198
1240
  .gt-link a { color: var(--dsw-alias-brand-primary); text-decoration: none; }
1199
1241
  .gt-link a:hover { text-decoration: underline; }
1200
1242
  .gt-link-title { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 320px; }
1201
- .gt-mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11.5px;
1243
+ .gt-mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px;
1202
1244
  color: var(--dsw-alias-label-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
1203
1245
  .gt-banner { border-radius: 9px; padding: 8px 11px; border: 1px solid var(--dsw-alias-state-error-primary);
1204
1246
  color: var(--dsw-alias-state-error-primary); display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
1205
- .gt-activity { display: flex; flex-direction: column; gap: 3px; color: var(--dsw-alias-label-secondary); font-size: 11.5px; }
1247
+ .gt-activity { display: flex; flex-direction: column; gap: 3px; color: var(--dsw-alias-label-secondary); font-size: 12px; }
1206
1248
  .gt-empty { display: flex; flex-direction: column; gap: 6px; align-items: center; justify-content: center;
1207
1249
  color: var(--dsw-alias-label-secondary); padding: 42px 16px; text-align: center; flex: 1; }
1208
1250
  .gt-empty-title { font-size: 14px; color: var(--dsw-alias-label-primary); }
@@ -2169,6 +2211,9 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
2169
2211
  // The preview harness cannot re-render after a click, so it may ask to start in
2170
2212
  // selection mode; a person toggles it with the `选择` control.
2171
2213
  const [selectMode, setSelectMode] = useState(props.initialSelectMode === true)
2214
+ // Only the static preview asks for this: the panel opens settings by click, but the
2215
+ // SSR harness cannot click, so the screenshot variant has to start there.
2216
+ const initialSettingsOpen = props.initialSettingsOpen === true
2172
2217
  // The picker's filters mirror the shell's own visibility rule, but the shape of the
2173
2218
  // real snapshot has never been observable from here — a preview fixture is written by
2174
2219
  // the same hand as the filter, so it can only confirm itself. Report the shape once per
@@ -2187,7 +2232,7 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
2187
2232
  const [importAsk, setImportAsk] = useState(null)
2188
2233
  // One settings popover: reminders and the external door both need a place a person can
2189
2234
  // reach without curl.
2190
- const [settingsOpen, setSettingsOpen] = useState(false)
2235
+ const [settingsOpen, setSettingsOpen] = useState(initialSettingsOpen)
2191
2236
  const [settingsBox, setSettingsBox] = useState(null)
2192
2237
  // The detail card's breakpoint follows the panel's own width, not the viewport: the
2193
2238
  // slot is ~265px narrower than the window, so a viewport query opens the two-column
@@ -2371,6 +2416,8 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
2371
2416
  // The wide-screen detail card floats over the panel, so its offsets are measured from
2372
2417
  // the panel's own column rather than derived from the viewport width.
2373
2418
  const innerRef = useRef(null)
2419
+ const settingsRef = useRef(null)
2420
+ const settingsTriggerRef = useRef(null)
2374
2421
  const [detailAnchor, setDetailAnchor] = useState(null)
2375
2422
  const titleRef = useRef(null)
2376
2423
  const searchRef = useRef(null)
@@ -2436,13 +2483,15 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
2436
2483
  useEffect(() => {
2437
2484
  const element = innerRef.current
2438
2485
  if (element === null || typeof window === 'undefined') return undefined
2486
+ // The static preview supplies a `window` stub without event plumbing.
2487
+ const listen = typeof window.addEventListener === 'function'
2439
2488
  const measure = () => setPanelWidth(element.getBoundingClientRect().width)
2440
2489
  measure()
2441
- window.addEventListener('resize', measure)
2490
+ if (listen === true) window.addEventListener('resize', measure)
2442
2491
  const observer = typeof window.ResizeObserver === 'function' ? new window.ResizeObserver(measure) : null
2443
2492
  if (observer !== null) observer.observe(element)
2444
2493
  return () => {
2445
- window.removeEventListener('resize', measure)
2494
+ if (listen === true) window.removeEventListener('resize', measure)
2446
2495
  if (observer !== null) observer.disconnect()
2447
2496
  }
2448
2497
  }, [])
@@ -2457,14 +2506,19 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
2457
2506
  if (element === null) return
2458
2507
  const rect = element.getBoundingClientRect()
2459
2508
  setSettingsBox({
2460
- top: Math.round(rect.top + 210),
2509
+ // Just under the title block: high enough that the whole card fits, low enough
2510
+ // that it never covers the panel title or the header actions.
2511
+ top: Math.round(rect.top + 56),
2461
2512
  right: Math.round(window.innerWidth - rect.right + 20),
2462
2513
  bottom: Math.round(window.innerHeight - rect.bottom + 20),
2463
2514
  })
2464
2515
  }
2516
+ const listen = typeof window.addEventListener === 'function'
2465
2517
  measure()
2466
- window.addEventListener('resize', measure)
2467
- return () => window.removeEventListener('resize', measure)
2518
+ if (listen === true) window.addEventListener('resize', measure)
2519
+ return () => {
2520
+ if (listen === true) window.removeEventListener('resize', measure)
2521
+ }
2468
2522
  }, [settingsOpen, lang])
2469
2523
 
2470
2524
  useEffect(() => {
@@ -2525,8 +2579,8 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
2525
2579
  return () => clearInterval(timer)
2526
2580
  }, [reload])
2527
2581
 
2528
- // Keyboard: `/` search, `n` new, Escape collapse — but never while the
2529
- // user is typing in a field.
2582
+ // Keyboard: `/` search, `n` new, Escape closes the innermost layer — but never
2583
+ // while the user is typing in a field, where the first Escape only drops focus.
2530
2584
  useEffect(() => {
2531
2585
  if (typeof document === 'undefined') return undefined
2532
2586
  const onKey = (event) => {
@@ -2536,8 +2590,16 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
2536
2590
  target !== undefined &&
2537
2591
  (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable === true)
2538
2592
  if (event.key === 'Escape') {
2539
- setExpandedId('')
2540
2593
  if (typing && typeof target.blur === 'function') target.blur()
2594
+ if (settingsOpen) {
2595
+ setSettingsOpen(false)
2596
+ return
2597
+ }
2598
+ if (adding) {
2599
+ setAdding(false)
2600
+ return
2601
+ }
2602
+ setExpandedId('')
2541
2603
  return
2542
2604
  }
2543
2605
  if (typing || event.metaKey || event.ctrlKey || event.altKey) return
@@ -2551,7 +2613,23 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
2551
2613
  }
2552
2614
  document.addEventListener('keydown', onKey)
2553
2615
  return () => document.removeEventListener('keydown', onKey)
2554
- }, [])
2616
+ }, [settingsOpen, adding])
2617
+
2618
+ // The settings card floats over the list, so a click anywhere else dismisses it
2619
+ // the way every other popover on the web does.
2620
+ useEffect(() => {
2621
+ if (!settingsOpen || typeof document === 'undefined') return undefined
2622
+ const onDown = (event) => {
2623
+ const card = settingsRef.current
2624
+ const gear = settingsTriggerRef.current
2625
+ if (card === null) return
2626
+ if (card.contains(event.target) === true) return
2627
+ if (gear !== null && gear.contains(event.target) === true) return
2628
+ setSettingsOpen(false)
2629
+ }
2630
+ document.addEventListener('mousedown', onDown)
2631
+ return () => document.removeEventListener('mousedown', onDown)
2632
+ }, [settingsOpen])
2555
2633
 
2556
2634
  const run = async (id, operation) => {
2557
2635
  setBusyId(id)
@@ -3226,15 +3304,20 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
3226
3304
  'div',
3227
3305
  { className: 'gt-head-actions' },
3228
3306
  h(
3229
- 'button',
3230
- {
3231
- type: 'button',
3232
- className: 'gt-iconbtn',
3233
- title: t('settings.title'),
3234
- 'aria-label': t('settings.title'),
3235
- onClick: openSettings,
3236
- },
3237
- h(Icon, { name: 'gear' }),
3307
+ 'span',
3308
+ { ref: settingsTriggerRef, style: { display: 'inline-flex' } },
3309
+ h(
3310
+ 'button',
3311
+ {
3312
+ type: 'button',
3313
+ className: 'gt-iconbtn',
3314
+ title: t('settings.title'),
3315
+ 'aria-label': t('settings.title'),
3316
+ 'aria-expanded': settingsOpen,
3317
+ onClick: () => (settingsOpen ? setSettingsOpen(false) : openSettings()),
3318
+ },
3319
+ h(Icon, { name: 'gear' }),
3320
+ ),
3238
3321
  ),
3239
3322
  h(
3240
3323
  'button',
@@ -3276,6 +3359,7 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
3276
3359
  ? h(
3277
3360
  'div',
3278
3361
  {
3362
+ ref: settingsRef,
3279
3363
  className: 'gt-settings',
3280
3364
  role: 'dialog',
3281
3365
  'aria-label': t('settings.title'),
@@ -3286,77 +3370,141 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
3286
3370
  position: 'fixed',
3287
3371
  top: `${settingsBox.top}px`,
3288
3372
  right: `${settingsBox.right}px`,
3289
- bottom: `${settingsBox.bottom}px`,
3290
- maxHeight: 'none',
3291
- overflow: 'auto',
3373
+ // Height follows the content, capped by the room below: stretching to
3374
+ // `bottom` left a tall empty card with the footer stranded at the floor.
3375
+ maxHeight: `calc(100dvh - ${settingsBox.top + 20}px)`,
3292
3376
  zIndex: 40,
3293
3377
  },
3294
3378
  },
3295
- h('div', { className: 'gt-settings-head' }, t('settings.title')),
3296
- h('div', { className: 'gt-section-title' }, t('settings.reminders')),
3297
- h(
3298
- 'label',
3299
- { className: 'gt-check-line' },
3300
- h('input', { type: 'checkbox', checked: remindersDraft, onChange: (event) => setRemindersDraft(event.target.checked) }),
3301
- t('settings.remindersEnable'),
3302
- ),
3303
3379
  h(
3304
- 'label',
3305
- { className: 'gt-check-line' },
3306
- t('settings.lead'),
3307
- h('input', {
3308
- type: 'number',
3309
- min: 0,
3310
- max: 1440,
3311
- className: 'gt-input gt-input-xs',
3312
- value: leadDraft,
3313
- onChange: (event) => setLeadDraft(event.target.value),
3314
- }),
3380
+ 'div',
3381
+ { className: 'gt-settings-head' },
3382
+ h('span', { className: 'gt-settings-title' }, t('settings.title')),
3383
+ h(
3384
+ 'button',
3385
+ {
3386
+ type: 'button',
3387
+ className: 'gt-iconbtn',
3388
+ title: t('settings.cancel'),
3389
+ 'aria-label': t('settings.cancel'),
3390
+ onClick: () => setSettingsOpen(false),
3391
+ },
3392
+ h(Icon, { name: 'close', size: 13 }),
3393
+ ),
3315
3394
  ),
3316
- h('div', { className: 'gt-section-title' }, t('settings.ingest')),
3317
3395
  h(
3318
- 'label',
3319
- { className: 'gt-check-line' },
3320
- h('input', { type: 'checkbox', checked: ingestDraft, onChange: (event) => setIngestDraft(event.target.checked) }),
3321
- t('settings.ingestEnable'),
3396
+ 'div',
3397
+ { className: 'gt-settings-body' },
3398
+ h(
3399
+ 'div',
3400
+ { className: 'gt-settings-group' },
3401
+ h('div', { className: 'gt-settings-group-title' }, t('settings.reminders')),
3402
+ h(
3403
+ 'label',
3404
+ { className: 'gt-check-row' },
3405
+ h('input', { type: 'checkbox', checked: remindersDraft, onChange: (event) => setRemindersDraft(event.target.checked) }),
3406
+ h(
3407
+ 'span',
3408
+ { className: 'gt-check-main' },
3409
+ h('span', { className: 'gt-check-label' }, t('settings.remindersEnable')),
3410
+ h('span', { className: 'gt-hint' }, t('settings.remindersHint')),
3411
+ ),
3412
+ ),
3413
+ h(
3414
+ 'div',
3415
+ { className: 'gt-field-row' },
3416
+ h('label', { className: 'gt-field-label', htmlFor: 'gt-lead' }, t('settings.lead')),
3417
+ h('input', {
3418
+ id: 'gt-lead',
3419
+ type: 'number',
3420
+ min: 0,
3421
+ max: 1440,
3422
+ className: 'gt-input gt-input-num',
3423
+ disabled: remindersDraft !== true,
3424
+ value: leadDraft,
3425
+ onChange: (event) => setLeadDraft(event.target.value),
3426
+ }),
3427
+ ),
3428
+ ),
3429
+ h(
3430
+ 'div',
3431
+ { className: 'gt-settings-group' },
3432
+ h('div', { className: 'gt-settings-group-title' }, t('settings.ingest')),
3433
+ h(
3434
+ 'label',
3435
+ { className: 'gt-check-row' },
3436
+ h('input', { type: 'checkbox', checked: ingestDraft, onChange: (event) => setIngestDraft(event.target.checked) }),
3437
+ h(
3438
+ 'span',
3439
+ { className: 'gt-check-main' },
3440
+ h('span', { className: 'gt-check-label' }, t('settings.ingestEnable')),
3441
+ h('span', { className: 'gt-hint' }, t('settings.ingestHint')),
3442
+ ),
3443
+ ),
3444
+ h(
3445
+ 'div',
3446
+ { className: 'gt-field', 'data-muted': ingestDraft === true ? '0' : '1' },
3447
+ h(
3448
+ 'div',
3449
+ { className: 'gt-field-head' },
3450
+ h('label', { className: 'gt-field-label', htmlFor: 'gt-token' }, t('settings.token')),
3451
+ h('button', { type: 'button', className: 'gt-link-btn', disabled: ingestDraft !== true, onClick: generateToken }, t('settings.generate')),
3452
+ ),
3453
+ h('input', {
3454
+ id: 'gt-token',
3455
+ className: 'gt-input',
3456
+ type: 'password',
3457
+ disabled: ingestDraft !== true,
3458
+ value: tokenDraft,
3459
+ placeholder:
3460
+ state.health?.settings?.ingest?.tokenSet === true ? t('settings.tokenSet') : t('settings.token'),
3461
+ autoComplete: 'new-password',
3462
+ onChange: (event) => setTokenDraft(event.target.value),
3463
+ }),
3464
+ h('span', { className: 'gt-hint' }, t('settings.tokenHint')),
3465
+ ),
3466
+ h(
3467
+ 'div',
3468
+ { className: 'gt-field-row', 'data-muted': ingestDraft === true ? '0' : '1' },
3469
+ h('label', { className: 'gt-field-label', htmlFor: 'gt-below' }, t('settings.confirmBelow')),
3470
+ h('input', {
3471
+ id: 'gt-below',
3472
+ type: 'number',
3473
+ min: 0,
3474
+ max: 1,
3475
+ step: 0.05,
3476
+ className: 'gt-input gt-input-num',
3477
+ disabled: ingestDraft !== true,
3478
+ value: belowDraft,
3479
+ onChange: (event) => setBelowDraft(event.target.value),
3480
+ }),
3481
+ ),
3482
+ h('span', { className: 'gt-hint' }, t('settings.confirmBelowHint')),
3483
+ ),
3484
+ h(
3485
+ 'div',
3486
+ { className: 'gt-settings-group' },
3487
+ h('div', { className: 'gt-settings-group-title' }, t('settings.about')),
3488
+ h(
3489
+ 'div',
3490
+ { className: 'gt-about-row' },
3491
+ h('span', { className: 'gt-field-label' }, t('settings.storeFile')),
3492
+ h('span', { className: 'gt-mono gt-settings-path', title: state.health?.store?.file ?? '' }, state.health?.store?.file ?? ''),
3493
+ ),
3494
+ h(
3495
+ 'div',
3496
+ { className: 'gt-about-row' },
3497
+ h('span', { className: 'gt-field-label' }, t('settings.version')),
3498
+ h('span', { className: 'gt-mono gt-hint' }, `v${state.health?.version ?? ''} · ${UI_REV}`),
3499
+ ),
3500
+ ),
3322
3501
  ),
3323
- h('input', {
3324
- className: 'gt-input',
3325
- type: 'password',
3326
- value: tokenDraft,
3327
- placeholder:
3328
- state.health?.settings?.ingest?.tokenSet === true ? t('settings.tokenSet') : t('settings.token'),
3329
- autoComplete: 'new-password',
3330
- onChange: (event) => setTokenDraft(event.target.value),
3331
- }),
3332
3502
  h(
3333
3503
  'div',
3334
- { className: 'gt-settings-row' },
3335
- h('button', { type: 'button', className: 'gt-btn gt-btn-quiet', onClick: generateToken }, t('settings.generate')),
3504
+ { className: 'gt-settings-foot' },
3336
3505
  h('button', { type: 'button', className: 'gt-btn gt-btn-quiet', onClick: () => setSettingsOpen(false) }, t('settings.cancel')),
3337
3506
  h('button', { type: 'button', className: 'gt-btn gt-btn-primary', onClick: () => void saveSettings() }, t('settings.save')),
3338
3507
  ),
3339
- h(
3340
- 'label',
3341
- { className: 'gt-check-line' },
3342
- t('settings.confirmBelow'),
3343
- h('input', {
3344
- type: 'number',
3345
- min: 0,
3346
- max: 1,
3347
- step: 0.05,
3348
- className: 'gt-input gt-input-xs',
3349
- value: belowDraft,
3350
- onChange: (event) => setBelowDraft(event.target.value),
3351
- }),
3352
- ),
3353
- h('div', { className: 'gt-section-title' }, t('settings.about')),
3354
- h(
3355
- 'div',
3356
- { className: 'gt-muted gt-mono gt-settings-path', title: state.health?.store?.file ?? '' },
3357
- state.health?.store?.file ?? '',
3358
- ),
3359
- h('div', { className: 'gt-muted' }, `v${state.health?.version ?? ''} · ${UI_REV}`),
3360
3508
  )
3361
3509
  : null,
3362
3510
 
@@ -3997,39 +4145,59 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
3997
4145
  ? h('span', { className: 'gt-dot', title: t('meta.session.live'), 'aria-label': t('meta.session.live') })
3998
4146
  : null,
3999
4147
  ),
4000
- group.sessionId === undefined
4001
- ? null
4002
- : h(
4003
- 'button',
4004
- {
4005
- type: 'button',
4006
- className: 'gt-group-action',
4007
- title: t('action.captureGroupHint'),
4008
- onClick: (event) => {
4009
- event.stopPropagation()
4010
- void requestCapture(group.sessionId, group.label)
4011
- },
4012
- },
4013
- h(Icon, { name: 'doc', size: 11 }),
4014
- t('action.captureShort'),
4015
- ),
4016
- group.dir === undefined || group.dir === ''
4017
- ? null
4018
- : h(
4019
- 'button',
4148
+ // An exemption is a state, so it keeps a small always-visible mark; the
4149
+ // actions themselves live behind one kebab instead of two text buttons
4150
+ // competing with the group title.
4151
+ exemptGroup
4152
+ ? h(
4153
+ 'span',
4020
4154
  {
4021
- type: 'button',
4022
- className: 'gt-group-action',
4023
- 'data-on': exemptGroup ? '1' : '0',
4024
- title: exemptGroup ? t('action.exemptHintOn') : t('action.exemptHint'),
4025
- onClick: (event) => {
4026
- event.stopPropagation()
4027
- void toggleExemptWorkspace(group.dir, !exemptGroup)
4028
- },
4155
+ className: 'gt-group-flag',
4156
+ title: t('action.exemptHintOn'),
4029
4157
  },
4030
4158
  h(Icon, { name: 'mute', size: 11 }),
4031
- exemptGroup ? t('action.exemptOn') : t('action.exempt'),
4032
- ),
4159
+ )
4160
+ : null,
4161
+ [
4162
+ group.sessionId === undefined
4163
+ ? null
4164
+ : {
4165
+ key: 'capture',
4166
+ label: t('action.captureShort'),
4167
+ icon: 'doc',
4168
+ onSelect: () => void requestCapture(group.sessionId, group.label),
4169
+ },
4170
+ group.dir === undefined || group.dir === ''
4171
+ ? null
4172
+ : {
4173
+ key: 'exempt',
4174
+ label: exemptGroup ? t('action.exemptOn') : t('action.exempt'),
4175
+ icon: 'mute',
4176
+ onSelect: () => void toggleExemptWorkspace(group.dir, !exemptGroup),
4177
+ },
4178
+ ].filter((item) => item !== null).length === 0
4179
+ ? null
4180
+ : h(Menu, {
4181
+ label: t('group.actions'),
4182
+ items: [
4183
+ group.sessionId === undefined
4184
+ ? null
4185
+ : {
4186
+ key: 'capture',
4187
+ label: t('group.capture'),
4188
+ icon: 'doc',
4189
+ onSelect: () => void requestCapture(group.sessionId, group.label),
4190
+ },
4191
+ group.dir === undefined || group.dir === ''
4192
+ ? null
4193
+ : {
4194
+ key: 'exempt',
4195
+ label: exemptGroup ? t('group.exemptOff') : t('group.exemptOn'),
4196
+ icon: 'mute',
4197
+ onSelect: () => void toggleExemptWorkspace(group.dir, !exemptGroup),
4198
+ },
4199
+ ].filter((item) => item !== null && item !== undefined),
4200
+ }),
4033
4201
  ),
4034
4202
  isCollapsed
4035
4203
  ? null
package/lib/index.js CHANGED
@@ -36,7 +36,7 @@ import { createStore, resolveStorePath } from './store.js'
36
36
  import { createTools } from './tools.js'
37
37
 
38
38
  /** Must equal `package.json`'s version; `npm run check` enforces that. */
39
- export const VERSION = '0.6.3'
39
+ export const VERSION = '0.6.5'
40
40
 
41
41
  /** Plugin id. Also the Loader row id in `cordis.patch.yml`. */
42
42
  export const name = 'dsh-global-todo'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-global-todo",
3
- "version": "0.6.3",
3
+ "version": "0.6.5",
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",